diff --git a/.changeset/bulk-write-verdicts.md b/.changeset/bulk-write-verdicts.md new file mode 100644 index 000000000..a8b297f24 --- /dev/null +++ b/.changeset/bulk-write-verdicts.md @@ -0,0 +1,9 @@ +--- +"@nicia-ai/typegraph": patch +--- + +`bulkUpsertById` and `bulkReplaceById` on nodes, and `bulkUpsertById` and `bulkGetOrCreateByEndpoints` on edges, now reach every verdict their items can raise by reading before their first write, so a refusal caught inside `store.transaction(...)` leaves none of the batch written. They wrote their creates first and then each update in turn, so a later item refused for a unique key it loses, a validity window that ends before it starts, a cardinality or target-cardinality allowance a resurrection or reopened window would exceed, a restore whose id a disjoint kind holds, an identity fold or window end the identity rules refuse, or a required composition part an edge write would detach left the earlier creates and updates committed when the caller caught it. The update half is now prepared in full before the creates are written and judged as the state the batch produces in write order, so a later item may still take a key or an allowance an earlier one gives up, and is refused one an earlier one takes or a new row of the same batch claims. When a row's stored window moves between the batch's read and its write, the batch converges as a single update does and prepares every remaining item again before writing the next; a refusal that preparation reaches follows the items already written. A concurrent writer the per-graph write fence does not exclude can still claim a key or remove a row between the reads and the writes, and that refusal comes from the write. + +`bulkGetOrCreateByEndpoints` re-ran the whole batch once on a `CardinalityError`, also inside a caller's transaction after a create or update of the batch had been written there; it now re-runs only when the refusal left nothing of the batch written (the call opened its own transaction, or no write had run), so a raced batch inside `store.transaction(...)` reports the refusal instead of applying its writes a second time. + +A node `create`, `bulkCreate` or `bulkInsert` on an identity graph (`sameIdAcrossKinds: "fold"`) whose caller-chosen id the identity fold refuses now writes nothing: the fold's `IdentityContradictionError` verdict is read before the insert or tombstone resurrection, where it used to follow it, so a refusal caught inside `store.transaction(...)` left the created rows committed. The create half of `bulkUpsertById` and `bulkReplaceById` takes the same path, and so do the single writes that bring a tombstone back, `upsertById` and `getOrCreateByConstraint` on a tombstone and a fact `unRetract` reopens, each of which left the row live when its fold was refused. `importGraph` still folds its nodes after writing them, since a fold can join two rows of the same document, and a refused fold rolls back the import's own transaction. diff --git a/.changeset/candidate-planning-scope.md b/.changeset/candidate-planning-scope.md new file mode 100644 index 000000000..a45325779 --- /dev/null +++ b/.changeset/candidate-planning-scope.md @@ -0,0 +1,11 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**Candidate planning reports the scope it planned in.** `planCandidateWriteSet()` stages a candidate from its own rows when the target allows it and from a clone of the whole target otherwise. That choice was a silent boolean; it is now a decision with a reason, returned beside the plan as `planningScope`: `{ scope: "candidate" }`, or `{ scope: "whole-target", reason }` where `reason` is one of `composition`, `acyclic-relation`, `target-side-cardinality`, `missing-active-source-read`, `missing-match-identity-read`, `missing-scoped-identity-reads`, `match-identity-owner-not-clone-visible` or `planned-evolution`. Both scopes produce the same plan, so the scope is not part of the merge plan artifact or its digest and `MERGE_PLAN_FORMAT_VERSION` is unchanged. The new `CandidatePlanningScope`, `CandidatePlanningScopeReason`, `WholeTargetPlanningScope` and `CandidateWriteSetPlan` types are exported from `@nicia-ai/typegraph/graph-merge`. + +**`planningScope: "candidate"` makes the candidate scope a requirement.** Passed to `planCandidateWriteSet()`, it refuses with the new `CandidatePlanningScopeError` (`GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE`, `details.planningScope.reason`) instead of planning against the whole target, before any staging backend is created. A candidate the target's schema refuses still reports its own validation error (`CandidateWriteSetError`) under the option, as it does without it; and a target that cannot carry a public merge plan at all (no revision tracking, or no interactive transactions) is refused with `MergePlanCapabilityError` before any staging backend is created, with or without the option. `planCandidateWriteSetForEvolution()` always stages the whole resulting graph, so it reports `planned-evolution` and takes no `planningScope` option. The review entry points forward it to planning. A refused candidate-scoped review (`details.reason: "candidate-scope-ineligible"`) now also carries `details.planningScope` naming the cause. + +### Breaking + +- `planCandidateWriteSet()` and `planCandidateWriteSetForEvolution()` now resolve to `Result`, `{ plan, planningScope }`, instead of the bare `MergePlanArtifact`. Read the artifact from `result.data.plan`, for example `const { plan } = unwrap(await planCandidateWriteSet(args))`. diff --git a/.changeset/clear-preserve-refusal.md b/.changeset/clear-preserve-refusal.md new file mode 100644 index 000000000..3e7ba5f20 --- /dev/null +++ b/.changeset/clear-preserve-refusal.md @@ -0,0 +1,9 @@ +--- +"@nicia-ai/typegraph": patch +--- + +`store.clear({ preserveContributionMaterializations: true })` is now refused with `UnsupportedBackendCapabilityError` on a custom backend that does not implement `clearGraphPreservingContributionMaterializations`, before anything is deleted. It previously fell back to `clearGraph` and could delete the markers it was asked to keep. `store.clear()` with the option omitted is unchanged: it prefers the preserving member and otherwise clears through the backend's own `clearGraph`. Bundled backends implement the member and are unaffected. + +### Breaking + +- A custom backend that does not implement `clearGraphPreservingContributionMaterializations` now refuses `store.clear({ preserveContributionMaterializations: true })` with `UnsupportedBackendCapabilityError`. Implement the member, or drop the option. diff --git a/.changeset/clone-working-copy-query-defaults.md b/.changeset/clone-working-copy-query-defaults.md new file mode 100644 index 000000000..1fdd4c811 --- /dev/null +++ b/.changeset/clone-working-copy-query-defaults.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +`cloneWorkingCopyStrategy` (the default `branch()` strategy) and the ingestion-branch clone now open the working copy with the base store's `queryDefaults`, as the fork, namespace-fork and PostgreSQL table strategies already did. A base opened with `queryDefaults: { expansion: "exact" }` or a non-default `traversalExpansion` previously got a working copy on the library defaults, so the same query returned different rows on the branch than on the store it was branched from. Hooks, `coalesceUnchangedUpserts` and `autoRefreshStatistics` are still not carried to a clone. diff --git a/.changeset/composition.md b/.changeset/composition.md new file mode 100644 index 000000000..fc8cd4386 --- /dev/null +++ b/.changeset/composition.md @@ -0,0 +1,53 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**`partOf` and `hasPart` are now a real [composition](https://typegraph.dev/ontology#composition) relation**, realized by an edge kind that stores the containment. `partOf(part, whole, { via, partSide? })` and its mirror `hasPart(whole, part, { via, partSide? })` name that edge with `via`; orientation is inferred from the declared pair against the edge's endpoints, and when both orientations are admissible (a same-kind containment edge) the declaration names `partSide: "from" | "to"`. The realizing edge must declare its whole-side cardinality as `"one"` or `"oneActive"` (`cardinality` when the part is the edge's `from`, `targetCardinality` when the part is its `to`), must be composition-exact (every endpoint pair it admits is a declared composition pair), and every composition edge that can accept a given part kind must declare the same temporal population. `KindRegistry.compositionRelation()` returns the derived `CompositionRelation` — pairs, realizing edge kinds, and part side per pair — and `via` and `partSide` travel through every representation of an ontology relation (compile-time, named, serialized, extension document, introspection). `store.introspect()` reports a realizing edge kind as `acyclic: true`, since every write of it is probed for cycles through the composition relation. + +**`partOf` / `hasPart` gain `existence?: "optional" | "required"`** (default `"optional"`, so existing declarations keep their semantics): `"required"` says a part of that kind can never exist without a live whole, and `KindRegistry.compositionExistence(kind)` reports it. Two declarations disagreeing on `existence` for one part kind refuse at registry build with `ONTOLOGY_COMPOSITION_EXISTENCE_MIXED`. `existence` is emitted only when `"required"`, so an explicit `"optional"` serializes byte-identically to omitting it and an untouched graph's schema hash is unchanged. + +**A part names the edge that realizes its attachment.** `NodeCollection.create`'s options are extracted into `NodeCreateOptions`, and `partOf` — on `create`, `bulkCreate`'s per-item options and both get-or-create entries — is a `CompositionAttachment`, `{ whole, via?, props?, validFrom?, validTo? }`, written in the same write as the node. `whole` is a node you already hold or a `{ kind, id }` reference, and only its `kind` and `id` are read, so a whole node's own properties never bleed into the attachment. On `store.nodes.` its `kind` is limited at compile time to the whole kinds the ontology declares for that part kind through `partOf` / `hasPart` (the new `CompositionWholeKinds`); when the ontology also declares a subclass or equivalence relation, or has lost its tuple type, any node kind of the graph is accepted and the runtime check decides. Any other top-level key on the attachment refuses with `ValidationError` (issue path `partOf.`) rather than being dropped. The window is validated once for every surface (canonical bounds, ordered pair), and on a `population: "oneActive"` pair, where only an open-ended edge is a membership, a stated `validTo` refuses with `ValidationError` (code `COMPOSITION_ATTACHMENT_WINDOW_BOUNDED`) instead of writing an edge that attaches nothing. The write is atomic: a dead or missing whole, an undeclared pair, a cardinality or acyclicity refusal, or a lost composition claim aborts the node create too, leaving no orphan row. Every one of those refusals is reached before the node row is written — for `bulkCreate` before any row of the batch, including a cycle or a whole-side cardinality the batch's own items would close together — so a caller that catches one inside an enclosing `store.transaction(...)` keeps nothing of the refused create. An undeclared pair refuses with `ConfigurationError` (`COMPOSITION_WHOLE_NOT_DECLARED`). `via` names the realizing edge kind and is required only when the part kind declares more than one composition pair toward that whole kind: omitting it there refuses with `COMPOSITION_VIA_AMBIGUOUS`, and naming an edge kind that realizes no declared pair between the two refuses with `COMPOSITION_VIA_NOT_DECLARED`. `props` are the realizing edge's own properties, validated against that edge kind's schema exactly as `store.edges..create(...)` would, so a realizing edge with required schema fields is now attachable from a node create at all. A required-existence kind created with no `partOf` refuses with `CompositionExistenceError` (`COMPOSITION_WHOLE_REQUIRED`, `situation: "create"`) before any row is written, and so does restoring a soft-deleted one through an entry that takes no `partOf` (`upsertById`, `bulkUpsertById`, `bulkReplaceById`) unless it still holds a live whole; `partOf` is honored on an optional-existence pair too. A required-existence kind, or any input carrying `partOf`, always takes the portable, fully-validated write path. + +**`partOf` on `getOrCreateByConstraint` is a POSTCONDITION, not a create-only option**: when the call returns, the resolved node holds exactly the stated attachment. The attachment is resolved before the match is read, so an undeclared whole kind, an unknown `via` and an ambiguous omitted `via` refuse with the same `ConfigurationError` whether the call created the node or found it. A node that already holds this whole through the resolved pair's realizing edge satisfies the call — idempotently when stated `props` are omitted, or valid and value-identical to the edge's live stored props; a valid but DIFFERENT `props` is refused (`CompositionExistenceError`, `situation: "props"`). A stated `validFrom` / `validTo` is held to the same rule: a bound equal to the edge's stored one is satisfied, a different one refuses with `ValidationError` (issue path `partOf.validFrom` / `partOf.validTo`, code `COMPOSITION_ATTACHMENT_WINDOW_CONFLICT`). A node with no live whole has the attachment written now, whatever its pair's declared `existence`, and a resurrection restores the whole alone. A node with a DIFFERENT live whole, or the same whole through another realizing edge, is refused (`situation: "existing"`) naming both sides. `bulkGetOrCreateByConstraint` behaves identically per item and decides its matched items as a set: every verdict a read can reach for an item that matched an existing node — the attachment's (a different whole or realizing edge, differing `props` or window, a dead whole, cardinality, and acyclicity over all of the batch's attachments, counting the attachments of the rows the batch is about to create), on the `ifExists: "update"` and resurrection legs the property update's validation and unique-key probes, and on a resurrection its disjointness and identity-fold checks — precedes the batch's first write, and so does every unique key of the rows the batch creates, probed after the matched items' in the order the batch writes them so a matched update that gives up a key lets a new row take it. A refused batch therefore attaches, updates, resurrects and creates nothing, including when the caller catches the refusal inside an enclosing `store.transaction(...)`. Two refusals stay with the write: an identity fold that depends on another row the same batch restores, and a concurrent writer the fence does not exclude; the batch's convergence retry re-runs it only where the refusal left nothing of it written (the call opened its own transaction, or no leg had written), so a race inside a caller's transaction surfaces instead of applying a matched update twice. A batch with more than one item that owes a write, or one such item next to a new row, pays one extra fenced read pass for this. Writing a realizing edge directly is held to the same rule: `edges..bulkCreate` and `bulkInsert` decide every item before writing, and `bulkUpsertById` and `bulkGetOrCreateByEndpoints` decide every update, resurrection and reopen (its composition claim and existence, counting the edges the batch creates) before their first write. Two concurrent get-or-create calls naming DIFFERENT wholes for one part end with exactly one attachment and one `CompositionExistenceError`, never a silent move. When `ifExists: "update"` applies, the attachment and the property update commit together, so any refusal — including a unique or validation error from the update itself — leaves properties, ownership and history untouched, including inside an enclosing `store.transaction(...)`. An already-satisfied call with no update to make stays read-only and takes no write fence. + +**A second whole is refused identically on every write path.** `CompositionError` (`COMPOSITION_WHOLE_OCCUPIED`, `details.situation: "occupied"`) with `details.incumbentEdgeId` is raised whether the claim row or the pre-write probe decides it: a batch, a custom command port that answers `unsupported`, an edge kind declaring both cardinality axes, and a backend that carries no claim relations all count the part's attachments across every realizing edge kind before writing, and report composition ahead of the edge kind's own `CardinalityError`. A backend without claim relations therefore still holds "one whole per part" across two different realizing edge kinds, under the per-graph lock. + +**A composition window never starts or ends later than its write.** Every write of a realizing edge — `partOf` on every create and get-or-create entry, `store.edges.` `create`, `bulkCreate`, `bulkInsert`, `update`, `bulkUpsertById`, `getOrCreateByEndpoints` and `bulkGetOrCreateByEndpoints`, validating import (as a per-row error carrying the code) and a graph merge's apply — refuses a `validFrom` later than the operation clock with `CompositionError` (`COMPOSITION_VALIDITY_FUTURE_START`, `situation: "futureStart"`) and a later `validTo` with `COMPOSITION_VALIDITY_FUTURE_END` (`situation: "futureEnd"`), both carrying the stated `bound` and the `operationInstant` it was judged against; `reparent` and `bulkReparent` refuse a later `at` with `COMPOSITION_MOVE_FUTURE` (`situation: "futureMove"`), and judge a move's two edge writes (the incumbent's window end and the new edge) at the move instant rather than a second clock read. Each refusal is reached before the write's first statement. This is what makes membership a fact of the row: a composition row with no `validTo` is the part's attachment now and at every later instant, and an ended row is history for good, so the claim, the audit, the delete cascade, the acyclicity check and every current-time read decide which whole a part holds by one rule instead of each choosing an instant — a scheduled end could otherwise leave a part with two current wholes behind a released claim, and a scheduled start a required part with none. `CompositionError.details` is a union discriminated by `situation`. A schema commit that declares a `partOf`/`hasPart` pair over an edge kind whose non-deleted rows already start or end later than the commit is refused with `MigrationError` (`reason: "ontology-tightening-violated"`) carrying `family: "compositionFutureWindow"` violations, and `store.verifyConstraintFences()` reports the same rows wherever they came from. + +**`nodes..reparent(id, options)` moves a part to a new whole**, which no sequence of detach and attach could express. `options` is `{ whole, via?, props?, at? }`: the destination attachment and the one instant the move happens at. It performs both halves in one transaction and validates the final state: one whole, acyclicity over the oriented composition union, and a required part never left detached. That union holds the rows composition counts as memberships, so the ended row a `oneActive` move leaves behind does not constrain later moves, and resurrecting such an ended history row attaches nothing and is not refused because the part now holds another whole. Every write is judged on the row it leaves, by the same rule: a history row created, imported, cloned, merged or restored still ended is outside the union and closes no cycle, while reopening an ended row with `clearValidTo` (`update`, `bulkUpsertById`, `getOrCreateByEndpoints`) puts it back and is probed and fenced like a create — a batch probing its re-admitted rows together before its first write. A realizing edge kind that is also declared `acyclic: true` keeps that declaration's rule: every non-deleted row of the kind counts, ended or not, and a `bulkReparent` that only ends such an incumbent's window still walks it. The part keeps its id, its properties and every descendant beneath it, and a refused move leaves it attached where it was, including inside an enclosing `store.transaction(...)`. How the old attachment retires follows the population declared by the incumbent row's own pair: a `population: "one"` edge is deleted, so the attachment disappears from every `store.asOf(t)` coordinate before the move, while a `population: "oneActive"` edge has its window ended at the move instant, leaving the previous membership readable as valid-time history. That instant — `options.at`, or one clock read when it is omitted — is both the old window's `validTo` and the new edge's `validFrom`, so for a `oneActive` pair the two halves abut: no `store.asOf(t)` coordinate shows the part with zero wholes, and none with two. A `oneActive` window cannot end before it starts: an `at` earlier than the incumbent's `validFrom` refuses before any write with `CompositionError` (`COMPOSITION_MOVE_BEFORE_INCUMBENT`, `situation: "moveBeforeIncumbent"`, naming the incumbent edge and its `validFrom`), and an omitted `at` whose clock read has fallen behind that `validFrom` — the clock stepped backward — moves the part at the incumbent's `validFrom` instead, retiring a zero-width window. `at` is the only temporal option a move takes: a stated `validFrom` or `validTo` refuses with `ValidationError` (issue path `options.`) rather than being dropped, since the move opens the new attachment and bounding a membership is a window edit on the realizing edge. The call returns `{ edge, moved }`: `edge` is the holding realizing edge (the new one, or the incumbent on a no-op) and `moved` is false when the part already held that whole through the same realizing edge. A stated `props` is still checked on that no-op: value-identical to the live stored props the no-op stands, valid but different refuses (`situation: "props"`). A stated `at` is checked the same way: the no-op opens no window, so it stands only when `at` equals the realizing edge's stored `validFrom`, and any other instant refuses with `ValidationError` under `options.at` (`COMPOSITION_ATTACHMENT_WINDOW_CONFLICT`), as a differing `partOf.validFrom` does on get-or-create. `bulkReparent` runs the same operation for many parts in one transaction and reaches every item's verdict before its first write, so a refused batch moves nothing, including when the caller catches the refusal inside an enclosing `store.transaction(...)`. The batch is judged on the state it produces as a whole: two moves that close a cycle between them are refused, a part may move under one the same batch moves out from under it in either item order, and a part listed twice is refused with a `ValidationError`. An incumbent a `oneActive` move ends still occupies every bound that counts its whole life (a whole-side `targetCardinality: "one"`, say), so a swap under such a bound is refused at preparation rather than after the windows end. Like every other batch method, `bulkReparent` fires no operation hooks; `reparent` fires one `update` operation naming the part. + +**Detaching a live required part is refused.** Ending, soft-deleting or hard-deleting a required-existence part's composition edge while the part is LIVE refuses with `CompositionExistenceError` (`COMPOSITION_DETACH_REFUSED`, `situation: "detach"`); detaching from a soft- or hard-deleted part is allowed. Both required-existence refusals read state no claim row records, so every write that decides one takes the per-graph write fence (`edgeComposition`): restoring a soft-deleted part of a required kind, and soft-deleting, hard-deleting or stating a window end on a composition edge whose part kind is required. A concurrent restore and detach, or restore and whole delete, of one part therefore run one after the other instead of both committing and leaving a live required part with no live whole: at `READ COMMITTED` the second decides from what the first committed, and at PostgreSQL `REPEATABLE READ` / `SERIALIZABLE` a second whose snapshot predates the first's commit fails at the fence with `TransactionConflictError`, whatever other constraint its write is also fenced for. A single edge delete or hard delete judges the detach from the edge row it reads under the fence, not from its earlier lock-free gate read. On a backend without transactions those writes refuse with `CONSTRAINT_WRITE_FENCE_UNSUPPORTED`, and writes of optional parts take no fence for this. Deleting the *whole* still cascades to its required parts — `existence: "required"` governs bare creation and detachment, not whole deletion. `CompositionExistenceError.code` names the situation: `COMPOSITION_WHOLE_REQUIRED` (a bare create), `COMPOSITION_DETACH_REFUSED` (a refused detach), `COMPOSITION_WHOLE_CONFLICT` (a stated `partOf` contradicting the whole an existing node holds) and `COMPOSITION_PROPS_CONFLICT` (a stated `partOf.props` differing from the already-satisfied attachment's stored props). `details.situation` carries the same distinction and remains the discriminant for which optional detail fields are populated, so a handler may branch on either. + +**Deleting a composition whole cascades leaf-first through its live parts closure**, each part running through its own node-delete pipeline inside the whole's transaction, with restrict and cascade `onDelete` behaviors on non-composition edges honored per part. Every part's verdict, and the whole's, is reached before the first row is deleted, so a `RestrictedDeleteError` from a part deep in the closure leaves the whole closure intact, including when the caller catches it inside an enclosing `store.transaction(...)`; `bulkDelete` reaches every item's verdict before deleting any of them. Composition edges never count as a `restrict` obstacle on either end. An instance-level cycle among reflexive composition edges is refused with `CompositionCycleError` (`COMPOSITION_CYCLE_DETECTED`) rather than looping or truncating. The closure is read under the per-graph write fence, and the delete and every attach advance the graph's fence row behind it, so on PostgreSQL a `REPEATABLE READ` or `SERIALIZABLE` transaction that cannot see a part attached after its snapshot fails at the fence with a serialization failure instead of deleting from a stale view; a custom backend whose fence advances no fence row refuses the delete at those levels with `ConfigurationError` (`COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT`). Each part the cascade deletes takes the same delete row lock a direct delete of that part takes, before any part's connected edges are read, and the cascade judges each part's liveness from the row that lock read (so it locks every part its closure walk reaches, including ones already deleted): a part deleted concurrently drops out of the cascade (and out of a guarded delete's plan, which then refuses) rather than being planned, reported and skipped. So an edge written concurrently onto a part either commits first and is removed or refused with the part, or waits and is refused with `EndpointNotFoundError`; it never stays live on a deleted part. On PostgreSQL that lock can deadlock with an edge write that holds the whole or one part while waiting on another; PostgreSQL aborts one of the two, which surfaces as a retryable `TransactionConflictError` inside `store.transaction()`. A whole's delete also removes the composition edge of a part that is already tombstoned, and each part's delete releases the unique keys the part holds at that moment. Resurrecting a soft-deleted whole restores the whole alone, ending a whole's validity window is not a cascade, and the cascade emits one operation-hook event, for the whole. See [Composition Cascade](https://typegraph.dev/limitations#composition-cascade). `NodeDeletePolicy` gains `consumedEdgeIds` (connected edges the cascade has already consumed, filtered out before the kind's `onDelete` behavior applies) and `cascadeComposition`. + +**A whole's delete says which parts it took.** The delete's `onOperationEnd` hook context and `store.transactionWithReceipt`'s receipt both carry `cascadedParts`, one `CompositionCascadedPart` per part (`{ kind, id, whole, viaEdgeKind }`: the part, the whole it was directly a part of, and the composition edge kind that bound them, enough to re-attach the tree once the deleted whole itself is restored) that never names a part the cascade did not delete, in the cascade's own delete order: leaf-first, then by kind, then id, within one level of the closure. That order is fully deterministic, so the same closure reports the same list on every run and every backend and a consumer may compare `cascadedParts` for equality. The receipt covers `bulkDelete` too, accumulating every item's cascade in batch order (a node an earlier item's cascade deleted is reported once, whichever order the batch names a part and its whole in), and the batch path still emits no per-item operation hooks. A `tx.measure(...)` scope's sub-receipt carries the refs for the deletes issued THROUGH that scope, so a delete issued through the outer transaction during an open scope stays out of it. `onOperationStart` never carries them, and the refs are not folded into the receipt's write counters: one caller-issued delete stays one write intent. Resurrecting a deleted whole restores the whole alone; its parts stay deleted. + +**`nodes..planDelete(id)` previews a delete.** It runs the delete's own planner with every write skipped and returns a `NodeDeletePlan`: `cascadedParts`, in the shape and order the delete reports them, and `removedEdgeIds`, every edge the delete would remove. It returns `undefined` for an absent or already-deleted node, and a delete the planner refuses is refused with the same error (`RestrictedDeleteError`, `CompositionCycleError`). On a transaction's collections, for a composition whole, it takes the per-graph write lock the delete takes and holds it to the end of the transaction, so no concurrent attach or reparent can add a part before a `delete(id)` later in that transaction; writes that take no per-graph lock (a detach, a part's own delete, an ordinary edge) can still change the outcome, and any other kind's delete takes no per-graph lock at all. On the store's collections it is an unlocked, best-effort read. Neither form writes a graph, sidecar, history or revision row; the transactional form takes the delete's lock, which for a composition whole on PostgreSQL advances the graph's fence row as the delete does, so it can fail the way the delete's fence fails (see the `REPEATABLE READ` / `SERIALIZABLE` note on the cascade above). Inside `store.transaction(fn, { accessMode: "read_only" })` the transactional form refuses before any statement, on every backend and for every kind, with `UnsupportedBackendCapabilityError` naming the store's `planDelete` as the unlocked alternative. + +**`nodes..delete(id, { expect: plan })` deletes only if a confirmed preview still holds.** A preview the user confirms in a later request cannot hold a lock, so `delete` takes `NodeDeleteOptions`, `{ expect: NodeDeletePlan }`, on the store's and a transaction's collections. The delete computes its own plan with the same planner inside its transaction (for a composition whole, under the per-graph write lock its delete takes) and compares it with `expect` in any order: the same parts by `(kind, id, whole, viaEdgeKind)`, so a reparented part counts as changed, and the same `removedEdgeIds`. A difference refuses with the new `NodeDeletePlanMismatchError` (`NODE_DELETE_PLAN_MISMATCH`, category `"constraint"`, like `RestrictedDeleteError`, `details.situation: "changed"` with `addedParts`, `droppedParts`, `addedEdgeIds` and `droppedEdgeIds`) before any node, edge, sidecar, history or revision write and before `onOperationEnd`; a node absent or already deleted refuses with `situation: "notLive"` instead of the usual no-op. A guarded delete always takes the portable delete path, never the fused single-statement delete. `expect` is required once options are passed, so `planDelete`'s `undefined` cannot be passed as an expectation, and options of the wrong shape, `{ expect: undefined }` or a plan naming a part or edge id twice are refused with `ValidationError`. `hardDelete` and `bulkDelete` take no options and refuse any with `ValidationError`: a hard delete also purges a tombstoned node and the soft-deleted edge rows at each deleted node, which the plan does not name. The comparison covers the parts and edges the delete planned; for any kind other than a composition whole there is no per-graph lock, so every concurrent write can change the answer up to the comparison. A write that takes no per-graph lock and commits after the comparison, before the delete's statements, is treated exactly as an unguarded delete treats it: at `READ COMMITTED` an edge created on a node the delete removes is removed with it (`cascade` / `disconnect`) or refuses the delete (`restrict`), and at PostgreSQL `REPEATABLE READ` / `SERIALIZABLE` the delete's snapshot does not see it and it stays live on the deleted node, as for every delete. SQLite admits no concurrent writer. + +**Undoing a cascading delete is a documented recipe, not a feature.** Restoring a soft-deleted whole still restores the whole alone. The [composition docs](https://typegraph.dev/ontology#undoing-a-cascading-delete) show how to capture the whole, the parts `planDelete` names and their props before a guarded delete, and how to recreate them whole-first (`cascadedParts` reversed) with their ids and `partOf: { whole, via: viaEdgeKind }`, and say what that does not bring back: the ordinary edges the delete removed, and the composition edges' ids, props and windows unless captured. + +**`QueryBuilder` gains `parts()` and `wholes()`**, declared-structure sugar over the recursive `Traversal` AST for a declared composition relation. `parts("alias", { from?, maxHops?, depth?, path?, via? })` derives the edge kind(s) and direction(s) from the registry's composition relation and recurses to the full transitive closure by default, with no hop ceiling, so a chain of any depth returns whole rather than a bare `.recursive()`'s implicit 10; a `maxHops` you state bounds the walk exactly as given (up to 1000), and `maxHops: 1` reaches the direct level only. With `maxHops: 1` and neither `depth` nor `path`, the step compiles to a fixed one-hop traversal rather than a recursive one, so `paginate()`, `page()` and `stream()` accept it: that is how to page one level of a part tree. `via` narrows the walk to one realizing edge and to the kinds that edge alone reaches, so it works on a multi-level composition and on a whole with several realizing edges; a `via` that realizes no pair at the source alias itself refuses with `ConfigurationError` (`COMPOSITION_VIA_NOT_DECLARED`). `wholes()` is the mirror, returning the ancestor chain. Both follow edges by ownership exactly as `subgraph({ composition })` does — the composition rows the cascade counts, at the query's instant, rather than the edges' validity window — through every compiled form of the step (recursive, one-hop, and a count over the one-hop step), and both still filter nodes by the query's temporal mode. Direction is derived per realizing edge kind: a `part -> whole` edge is followed reversed, a `whole -> part` (`has_*`) edge in its own direction, and a relation realized by both orientations across levels compiles to one traversal step. The result alias is untyped (`DynamicNodeType`/`DynamicEdgeType`, reached via `.field(name)`), since the closure under a kind may span several node kinds with different schemas. `parts()`/`wholes()` on a kind declaring no composition parts/wholes refuses with `ConfigurationError` (`COMPOSITION_NO_PARTS_DECLARED` / `COMPOSITION_NO_WHOLES_DECLARED`) rather than returning zero rows, and `expand` is not accepted on either step. `TraversalBuilder` gains `toKindSet(kinds, alias)`, a finalizer parallel to `to()`/`toDynamic()` for an explicit, already-resolved set of target node kinds. `VariableLengthSpec` gains `exhaustive?: boolean`, which walks every path to its end with no hop ceiling and is what `parts()`/`wholes()` set when no `maxHops` is stated. It requires `cyclePolicy: "prevent"` and cannot also state a `maxDepth`; the compiler refuses either contradiction with `UnsupportedPredicateError` rather than ignoring the bound. The walk carries one path per row to prevent cycles, so on a chain deeper than a few thousand levels its cost grows faster than `subgraph({ composition: true })`'s set-semantics closure; the result is complete either way. + +A related defect is fixed: a recursive traversal combining `.recursive()` with a non-empty inverse edge-kind set (`{ expand: "inverse" }`, or the mixed-orientation case `parts()`/`wholes()` now exercise) emitted SQL that PostgreSQL rejected and SQLite accepted. Such traversals now compile and run on both dialects. + +**`subgraph({ composition: true })` returns the complete owned unit.** `SubgraphOptions` gains `composition?: boolean | { via? }`, which closes the `{ kind, id }` root over the composition edge kinds under its kind, in addition to whatever `edges` already lists; `{ via }` restricts the closure to one realizing edge kind (an edge type or its kind string) and refuses with `ConfigurationError` (`COMPOSITION_VIA_NOT_DECLARED`) when that edge realizes no composition pair at the root's kind itself — the rule `parts({ via })` applies, decided by one shared check, so an edge realizing a pair only further down the tree is refused rather than read as an empty closure. A root kind declaring no composition parts contributes nothing and the read still runs; a graph declaring no composition relation at all refuses with `ConfigurationError` (`COMPOSITION_NO_PARTS_DECLARED`). Because the composition edge kinds a read adds are not in the `edges` list the call names, `composition: true` widens the result's edge-kind type to the graph's whole edge-kind union (the new `SubgraphResultEdgeKinds`), so every edge kind the read can return in `edges` is reachable through the result type and the `indexSubgraph()` lookups; a `{ via }` selection adds exactly its one realizing edge kind, and `composition` absent or `false` keeps the existing `edges`-list typing unchanged. The `project.edges` keys a call may name widen with the result (`SubgraphProjectFor`), so a `composition: true` read can project the composition edges it receives; an edge kind outside `edges` is still refused without it. The closure walks each edge from the `(kind, id)` it reached, like `parts()` and the cascade, so another kind's node sharing a part's id never pulls its own parts in. The closure is exhaustive, bounded neither by `maxDepth` nor by the engine's 1000-hop cap on explicit traversals, so a part chain of any depth comes back whole rather than truncated, and `maxDepth` / `cyclePolicy` bound only the `edges` traversal listed alongside it. The closure's edges follow ownership, not validity: the composition rows that bind a part to its whole, the same membership the claims and the delete cascade count, so an ended `population: "one"` edge still binds its part (and is returned with its `validTo`) while an ended `population: "oneActive"` edge does not. The temporal mode sets the instant ownership is evaluated at — `asOf(t)` reads what was owned at `t`, a recorded read what its revision recorded, and every other mode what is owned now; `includeEnded` and `includeTombstones` do not widen the closure — while the `edges` list keeps the read's validity window. Nodes follow the read's temporal filter at every hop, so a part whose own validity window has ended (or not yet started) is not returned, nor is anything reachable only through it, although the delete cascade removes them: the export is not a delete preview, `planDelete` is. A closure statement the database cut short (a `statement_timeout`, a cancelled query) refuses with `ConfigurationError` (`COMPOSITION_UNIT_INDETERMINATE`) naming the root. `tx.subgraph()` accepts and honors `composition` the same way, reading through the transaction's session, so the transaction context's `subgraph` signature gains the same `C` type parameter as `store.subgraph()`. The batch-scoped `read.subgraph()` inside `batchOnce()` compiles to one statement and the composition closure is a second walk read in its own statement, so it refuses any requested `composition` with `ConfigurationError` (`SUBGRAPH_COMPOSITION_ONE_STATEMENT_UNSUPPORTED`) before the batch runs a statement. A `composition` value that is neither a boolean nor `{ via?: edge type or kind string }` is refused on every subgraph surface with `ValidationError` (issue path `composition`) instead of being treated as absent. + +**A batch create whose items carry `partOf` does per-batch rather than per-item work**, with the same refusals: whole rows are read once per kind, a part row is never re-read after the insert that wrote it, and acyclicity is walked once over every composition edge the batch will insert, before the first of them is written — which is what makes a cycle running through two of the batch's own items visible at all. A cycle refusal names a concrete offending edge: the self-attaching item's own edge for a self-loop, otherwise the first of the batch's edges lying on the cycle. + +**Import, the audit and graph merge all see composition.** Validating import tracks every required-existence part it creates and, once the payload's node and edge sets are both known, judges each of them through the same reader the `compositionExistence` audit uses: one attached in the same batch or already attached on the target is accepted, and anything left without a live whole is reported as a per-row error and its node row removed in the same transaction; the rest of the import commits. A created composition edge is not evidence on its own — an ended `oneActive` window, or a whole that does not exist (reachable with `validateReferences: false`), leaves the part refused. The check runs to a fixed point, so a required part attached only to a part this import refuses is refused with it, and the result counts track what was committed: a refused part and each edge removed with it come off every `created` and `updated` count this import had added them to, and every removed edge is reported as its own per-row error, including one that was already on the target (written ahead of the part under `validateReferences: false`). A part nothing can attach — no composition edge in the payload names it and the target holds no attachment for it — is refused before its row is written, so no other row is judged against it and the outcome does not depend on row order. A part a payload edge does name is written and judged afterwards: while its row exists a later row that collides with it is refused against it and is not judged again once the part is removed; re-run the import without the refused part to accept it. An `onConflict: "update"` whose `validTo` would end a live required part's open attachment is refused per row (`COMPOSITION_DETACH_REFUSED`), as the same edge update is through the store. Trusted import refuses every declared composition pair regardless of `existence` (`composition_unsupported`). Streamed import commits each chunk on its own, so a part and the edge attaching it arrive in separate transactions: `importGraphStream` refuses a graph declaring any required-existence part kind before reading a chunk, and `branch()`, `branchForEvolution()` and candidate staging clone such a graph through a single-transaction `importGraph` instead. The heterogeneous recorded upsert `tx.writeNodeUpsertBatch()` writes node rows only, so it refuses a required-existence part kind with `ConfigurationError` `HETEROGENEOUS_NODE_BATCH_UNSUPPORTED_KIND` before writing, rather than inserting a part with no whole or resurrecting one the composition cascade deleted. `store.verifyConstraintFences()` gains a `compositionExistence` family reporting any live required-existence part with no live whole, and a `composition` family reporting two or more live edges holding one part's reserved composition axis; a schema commit newly declaring `existence: "required"` on an already-populated pair runs a tightening preflight and refuses a dirty graph rather than silently admitting orphans. A commit that widens the composition relation's population — adding a pair, redeclaring a `oneActive` realizing edge `one`, or declaring `acyclic: true` on one, so its ended rows start to count — probes the whole proposed composition relation for a cycle, built by the same owner the write path uses; redeclaring a realizing edge so its ended rows count also audits every part those rows attach for a second whole, so a part with an ended row under one realizing edge and an open row under another refuses the commit instead of landing with two wholes. `planMerge` and `planMergeIncremental` report composition orphans in `MergePlanReview.compositionOrphans` under three causes: a part attached on the target to a whole the branch deletes, which the plan neither deletes nor moves to another whole (`cause: "deleted"`); the mirror, a part the plan deletes — directly, or through the branch's cascade from its old whole — that the target has meanwhile moved under another live whole the plan neither deletes nor detaches it from (`cause: "moved"`); and a required part this merge writes — or whose composition edge this merge explicitly deletes or ends — that resolves to no live whole at all (`cause: "unattached"`). All three are judged against the state the plan would leave: a part the plan deletes or moves to another whole is not a `deleted` orphan, and a required part the plan creates together with its composition edge is not an `unattached` one. A part the plan deletes is still reported as `moved` when the target holds it under a live whole the plan keeps. Two branches that conflict over one attachment — one moves or attaches a part, or re-attaches it by reopening an ended `oneActive` row, while another deletes the part or its whole — are refused at plan time by every planning entry point with `MergeCompositionOrphanError` (`cause: "moved"` or `"deleted"`), whose new optional `details.branchId` names the attaching branch; before, the attaching branch's composition edge was silently dropped as `edge:endpoint-deleted`, and a reopened row was absorbed by its deletion without a trace. `planMergeForEvolution()` judges them against the registry the evolution will commit. `applyMergePlan` re-checks `deleted` and `moved` inside the apply transaction under the per-graph write lock, asserts `unattached` for the plan's parts after its last edge in the same transaction, and refuses with `MergeCompositionOrphanError` (`GRAPH_MERGE_COMPOSITION_ORPHAN`), and merge apply deletes nodes with `cascadeComposition: false` so the plan stays the authority over what apply deletes. A branch's composition writes merge as a unit: apply postpones the per-write required-existence rule for the parts the plan touches and asserts it for those same parts after the plan's last edge, in the same transaction, so a required part created with `partOf` and a `reparent` under either cardinality merge like any other edit. A composition edge that already attached its part at the branch point and is dropped by canonicalization's endpoint-deleted repointing needs no write to be caught: the plan deletes one of its endpoints, and a deleted part leaves nothing to orphan while for a deleted whole the `deleted` check reports the surviving part at plan time and apply refuses it. + +### Breaking + +- `partOf` / `hasPart` require `via`, in a third options argument naming the realizing edge kind. A missing realizing edge, or a `via` or `partSide` on any other meta-edge, is refused at registry build and at schema load with `ConfigurationError` (`ONTOLOGY_COMPOSITION_VIA_REQUIRED`, `ONTOLOGY_COMPOSITION_VIA_FORBIDDEN`, `ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN`). A persisted schema carrying a `partOf` / `hasPart` relation with no `via` is refused wherever it would be loaded as a registry, and upgrades forward through the ordinary schema paths: after upgrading the library, give the graph's `partOf`/`hasPart` a `via` (or delete the relation) and open it with `createStoreWithSchema()` or commit it with `migrateSchema()`. `getSchemaChanges()` reports the change beforehand as the old relation removed (`safe`, since it realized nothing) plus, when a `via` is declared, the new pair added (`warning`, probed against live data like any new composition pair). A graph that still declares the relation without `via` is refused at registry build. +- Declarations the composition relation now refuses, which were previously accepted as an unconstrained part-whole closure: `"many"` or `"unique"` on the whole side (`ONTOLOGY_COMPOSITION_CARDINALITY`), an edge admitting an endpoint pair that is not a declared composition pair (`ONTOLOGY_COMPOSITION_VIA_ENDPOINTS`), one edge kind declared in two orientations (`ONTOLOGY_COMPOSITION_VIA_MIXED`), and mixed `one` / `oneActive` populations for one part kind (`ONTOLOGY_COMPOSITION_POPULATION_MIXED`). +- Deleting a node that is a composition whole now deletes its parts. Move any relation that re-homes or retains parts on delete off composition; the [containment tiers](https://typegraph.dev/ontology#choosing-a-containment-tier) section says where each case belongs. +- `NodeCollection` gains a third type parameter, the whole kinds its attachments accept (default `string`). Code that names `NodeCollection` for a graph's own collection (rather than reading it off `Store`) no longer accepts `store.nodes.` as that type: the whole kinds sit in contravariant option positions, so a collection that accepts any whole kind string is not assignable from one limited to the kinds the ontology declares. Pass `CompositionWholeKinds` as the third argument. `StoreProjection` takes the same `CompositionWholeKinds` per projected kind, so `partOf` and `reparent` stay callable on a projection; a store of another graph is assignable to it only when that graph's collection accepts at least those whole kinds. +- `NodeCollection` gains `reparent(id, options)`, `bulkReparent` and `planDelete(id)`, and its `delete` takes an optional second `NodeDeleteOptions` argument. A hand-written implementation of that interface must add all three methods and honor or refuse `delete`'s options. +- `exportGraph` / `exportGraphStream` now include `validFrom` / `validTo` by default for a graph with any edge kind whose `cardinality` or `targetCardinality` is `"oneActive"` (every `oneActive` composition pair included), as they already did for identity-enabled graphs, and refuse an explicit `includeTemporal: false` there with `ConfigurationError` (`ACTIVE_CARDINALITY_EXPORT_REQUIRES_TEMPORAL_FIELDS`, `details.edgeKinds`). Without its `validTo` an ended row re-imported as current — a reparented part attached to its former whole again. Drop `includeTemporal: false` from such exports; a consumer of the document sees the two extra fields on every row. +- `TransactionReceipt` gains a required `cascadedParts` field, always present and empty when the transaction deleted no composition whole; anything constructing a receipt literal must add it. `OperationHookContext` gains an optional `cascadedParts`. Both are typed `readonly CompositionCascadedPart[]`. +- `ConstraintFenceViolation` gains `composition`, `compositionExistence` and `compositionFutureWindow` members; an exhaustive `switch (violation.family)` outside the library needs an arm for each. +- `MERGE_PLAN_FORMAT_VERSION` is now `2`: `applyMergePlan` refuses a stored version-1 artifact with `UnsupportedMergePlanVersionError` (`GRAPH_MERGE_PLAN_VERSION_UNSUPPORTED`), so re-plan any artifact captured before the upgrade. The exported artifact types are renamed (`MergePlanArtifactV1` and `MergePlanArtifactV1Input` become `MergePlanArtifactV2` and `MergePlanArtifactV2Input`); the version-agnostic `MergePlanArtifact` alias is unchanged. +- `MERGE_REVIEW_FORMAT_VERSION` is now `3` and the candidate-scoped review format is `4` (`MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED`, for `reviewScope: "candidate"`), because a candidate-write-set review embeds a version-2 merge plan. `MERGE_REVIEW_FORMAT_VERSION_V1` and `MERGE_REVIEW_FORMAT_VERSION_V2`, exported by the latest released tag, are REMOVED; the supported formats are exactly `3` and `4`. A stored version-1 or version-2 review is refused: revalidating it refuses with `MergeReviewError` `details.reason: "unsupported-version"` (`details.supported` lists `[3, 4]`); plan and review the candidate write set again. +- An explicitly requested candidate review scope is refused on a graph that declares a target-side cardinality, an `acyclic` edge or a composition pair: `planCandidateWriteSetReview` with `reviewScope: "candidate"` refuses with `MergeReviewError` `details.reason: "candidate-scope-ineligible"` (as does revalidating a stored candidate-scoped review against a target that can no longer be read sparsely), because the sparse base such a review reads never sees the rows those constraints depend on. Planning itself is not refused unless it is called with `planningScope: "candidate"`: `planCandidateWriteSet`, and a review planned without `reviewScope`, fall back to whole-target planning on such a graph, produce the plan and conflicts a whole-target plan would, and report the reason in `planningScope`. The default whole-target review is unaffected. diff --git a/.changeset/durable-edge-create-cardinality-probe.md b/.changeset/durable-edge-create-cardinality-probe.md new file mode 100644 index 000000000..7b5d0746d --- /dev/null +++ b/.changeset/durable-edge-create-cardinality-probe.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +A direct `create` on an edge kind with a durable `matchIdentity` now checks cardinality before it writes. It previously claimed the cardinality axis after the converge-create command had inserted the row, so a caller that caught the `CardinalityError` inside `store.transaction(...)` and committed kept a second live edge on a `"one"` or `"oneActive"` axis. The refusal now leaves nothing written, as it already did for batch writes and `getOrCreateByEndpoints`. diff --git a/.changeset/edge-kind-integrity.md b/.changeset/edge-kind-integrity.md new file mode 100644 index 000000000..c6c59f8ff --- /dev/null +++ b/.changeset/edge-kind-integrity.md @@ -0,0 +1,36 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**An edge registration can declare `targetCardinality: "many" | "one" | "oneActive"`**, bounding the edges that point AT one node independently of `cardinality`, which bounds the edges leaving one source. Target `"one"` with the default source `"many"` is many-to-one ownership: a source may point at many targets, each target may be pointed at once. The two axes compose freely — all twelve combinations with the existing `cardinality` values, `unique` included, are accepted and enforced together — and both are enforced atomically on every public write path: create, upsert, get-or-create, bulk writes, resurrection, validity-window reopening, validating import, and merge apply after endpoint canonicalization. Two concurrent writers cannot both take the same target, including on a default PostgreSQL store with history and revision tracking disabled. + +The target allowance is scoped to the graph, the edge kind, the target kind and the target id, and deliberately not to the source: two permitted source kinds contend for one allowance, while equal ids under different target kinds, edge kinds or graphs never collide. `"one"` counts every non-deleted edge at the target, including edges with a validity end; `"oneActive"` counts only those whose `validTo` is unset. Soft-deleted edges occupy no slot, a resurrection or reopened window reacquires every applicable reservation, and ending an edge frees an active-only slot but never a `"one"` slot. These bound edge count rather than distinct neighbours, so a second distinct edge from the same source to a constrained target is refused too, while an idempotent operation returning the existing edge consumes nothing. `CardinalityError` stays the violation type, its details now naming the `direction` and both endpoints, and `verifyConstraintFences()` reports target-side violations in the existing `edgeCardinality` family. + +**An edge registration also accepts `acyclic: true`**, declaring that the edge kind's live relation is a DAG. Every write path that can put an edge into that relation — `create`, `bulkCreate`, upsert and `getOrCreateByEndpoints`, resurrection into the live population, validating import, and merge apply — is refused with the new `EdgeAcyclicityError` when the new edge's `to` endpoint already reaches its `from` endpoint; a self-loop is a cycle of length one and is refused by the same rule. A batch (`bulkCreate`, `bulkInsert`, `bulkUpsertById`, `bulkGetOrCreateByEndpoints`) is checked as a whole before its first row is written, including a cycle two of its own rows close through each other, so a refusal caught inside an enclosing `store.transaction(...)` leaves none of the batch stored. A batch of any size is checked: the probe is sent in as many statements as the backend's `maxBindParameters` requires and still sees a cycle whose rows fall in different statements. The check is exhaustive, with no depth bound, so a cycle of any length is found. Non-deleted edges count regardless of their validity window, so ending an edge's window does not free the relation while soft-deleting it does. Two concurrent writers of `a → b` and `b → a` serialize and exactly one commits, at every PostgreSQL isolation level and between writers at different levels: a write that checks an acyclic relation also advances the graph's row in `typegraph_fences` behind the per-graph write fence, so a `REPEATABLE READ` or `SERIALIZABLE` session whose snapshot predates another such writer's commit fails on that row with a serialization failure (SQLSTATE `40001`) instead of checking a relation it can no longer see. TypeGraph replays a collection write, an import or a merge commit it opened the transaction for; inside `store.transaction(...)` or an adopted transaction the failure surfaces as `TransactionConflictError` for the owner to re-run. (`applyMergePlanInTransaction` still requires a `READ COMMITTED` caller transaction.) A backend with no transactions cannot enforce the rule at all and refuses the write with `CONSTRAINT_WRITE_FENCE_UNSUPPORTED` and the new `details.constraint` value `edgeAcyclicity`, rather than enforcing it only when nothing races; a custom backend whose fence is an advisory lock with no fences relation cannot detect a stale snapshot, so a `REPEATABLE READ` or `SERIALIZABLE` write is refused there with `EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT` in every frame of its transaction; and a statement the engine cuts short raises the new `EdgeAcyclicityIndeterminateError`, so an incomplete search is never reported as "no cycle". `store.verifyConstraintFences()` gains an `edgeAcyclicity` family reporting edges already on a cycle. Adding `acyclic: true` to a populated edge kind is a tightening: it is probed against live rows inside the schema-commit transaction and refused with `MigrationError` `reason: "ontology-tightening-violated"` when a cycle already exists, with `details.violations` in the shape `store.verifyConstraintFences()` returns. The classifier records that change as an `OntologyChange` with `entity: "edgeRegistration"`. `trustedImportGraphStream` refuses a store declaring an acyclic edge kind (`reason: "acyclicity_unsupported"`); the portable validating import checks each row as it writes and rolls it back per-row on a cycle. + +**Graph merge catches a would-be cycle at plan time.** `merge()`, `mergeAgainstBase()`, `planMerge()`, `planMergeIncremental()` and `mergeIncremental()` all check reachability over the resolved plan's projected edge writes, after canonicalization and repointing, layered onto the target's current live edges less the edges the same plan deletes — including a cycle formed entirely from rows the plan proposes, with nothing live yet, which was not previously detectable. A plan that reverses or re-roots edges of an acyclic relation is therefore judged by the DAG it produces and merges. A violation is the new typed `AcyclicityMergeConflictError` (code `GRAPH_MERGE_ACYCLICITY_CONFLICT`), carrying the offending relation name and every edge on the cycle (`edgeId`/`edgeKind`/`fromKind`/`fromId`/`toKind`/`toId`) in `details`, so a `planMerge()` review surfaces it before anything is written. A cycle that only appears from a write racing the plan is still caught at apply time as `MergeConstraintConflictError` wrapping `EdgeAcyclicityError`. That holds for the `SERIALIZABLE` commit of a merge into a store opened without `history: true` as well: a writer into the same acyclic or composition relation, at any isolation level, that commits after the merge transaction's snapshot fails the merge on the graph's fence row, and the merge commit is replayed against the graph that writer left, where it is refused as stale (`BaseVersionMismatchError`) or for the constraint it would now violate. + +**`defineGraph` refuses an integrity option stated outside its domain.** An unknown `cardinality`, a `targetCardinality` other than `"many"`, `"one"` or `"oneActive"`, an `endpointExistence` other than `"notDeleted"`, `"currentlyValid"` or `"ever"`, and a non-boolean `acyclic` raise `ConfigurationError` `EDGE_INTEGRITY_OPTION_INVALID` (`details.option`, `details.value`) at declaration. TypeScript already rejects these; the check is for untyped callers, whose `acyclic: "yes"` would otherwise have been read as undeclared and whose `cardinality: "bogus"` would have been written into a stored schema the loader rejects. + +Schema serialization, hashing, introspection, runtime graph extensions and schema diffing preserve both declarations; a schema stored before this release loads as `"many"` on both cardinality axes. Runtime graph-extension documents may now declare `cardinality`, `targetCardinality` and `acyclic` on an edge — previously the cardinality keys were rejected in strict mode and dropped otherwise, so an extension-authored edge kind was always unconstrained. + +### Breaking + +- On PostgreSQL, a write that makes a decision only the per-graph write fence protects now advances one row per graph in `typegraph_fences` (part of the base schema since the fence-rows release), behind the advisory lock: a write into an `acyclic` or composition relation, a delete of a composition whole, and a match-key convergence (`getOrCreateByEndpoints`, `bulkGetOrCreateByEndpoints` or a coalescing upsert on a match key no database key backs). A role that performs those writes needs `INSERT` and `UPDATE` on that table. Every other holder of the fence — a history-capturing or revision-tracking write, a cardinality, uniqueness or disjointness write, a provenance `holding()` read — takes the lock alone, writes no row and issues the statements it issued before. The row is updated once per deciding transaction, so while a transaction holding an older snapshot stays open (`pg_dump`, a long report, an idle-in-transaction session) its dead versions accumulate and those deciding writes slow until the snapshot closes and the table is vacuumed; see "Write Fence Row" in the limitations guide. +- A `store.transaction` whose callback holds a raw SQL handle (`tx.sqlAvailability: "available"`) now takes the per-graph write fence at every constraint-fenced write instead of once per transaction. A `ROLLBACK TO SAVEPOINT` issued through `tx.sql` releases an advisory lock taken after that savepoint and undoes a fence-row advance made after it, so a later fenced write in the same transaction previously ran under a lock it no longer held. Transactions without the handle (history capture, revision tracking, or no native transaction) still take the fence once, and the schema-fence lease follows the same rule. +- `TransactionReadBackend`, the `tx.backend` of every transaction context, no longer has `execute`. Its compiled-query brand is a type only, so `tx.backend.execute` ran any SQL on the transaction's session — including a `ROLLBACK TO SAVEPOINT` that released the per-graph write fence and the schema fence while the transaction's later writes kept relying on them, which let two transactions commit a cycle into an `acyclic` relation, and on a history store writes that bypassed capture. `tx.sql` is now the one raw handle a transaction context carries. Run TypeGraph queries in a transaction with `tx.query()`, and raw SQL through an `AdapterStore` transaction's `tx.sql`, whose transactions take every fence again at each write. +- A PostgreSQL schema write now opens its transaction at `READ COMMITTED` whatever the server's `default_transaction_isolation` is, and the advisory schema fence refuses a session that reports any other isolation with `ConfigurationError` code `SCHEMA_WRITE_REQUIRES_READ_COMMITTED` (`details.isolation`). A schema write is every unit that runs under the schema fence: a schema commit or activation from `createStoreWithSchema`, `migrateSchema` or a schema evolution, with the tightening preflight or emptiness probe run before it; an identity schema transition; the graph-merge provenance sidecar claim; removal materialization; and a contribution rebuild. At a snapshot-isolation default the preflight previously read a snapshot taken before the commit waited for the writers holding the schema row, and could activate a tightening such as `acyclic: true` over rows written while it waited. Two caller-owned transactions cannot be moved to `READ COMMITTED` and are now refused when they run at `REPEATABLE READ` or `SERIALIZABLE`: one handed to `adoptSchemaWriteTransaction`, and one under a transaction-scoped backend (`createPostgresTransactionBackend(tx)`, or `createPostgresBackend(tx)` on a transaction), where the schema write runs in a savepoint of the caller's transaction. The second case previously committed and now refuses every schema write above, including the first commit of a brand-new graph, which has no data check: the rule is one per fence, not per write. Run such a schema write in a `READ COMMITTED` transaction. The refusal is raised before the adopted path acquires or changes anything, and the transaction-scoped path rolls its savepoint back, so a caller that catches it keeps its transaction as it was. +- At `REPEATABLE READ` or `SERIALIZABLE`, such a PostgreSQL write now fails with SQLSTATE `40001` on that row when another such writer committed past its snapshot, reported as `TransactionConflictError` with the driver error as `cause`. Retry the whole transaction, as for any serialization failure. +- A PostgreSQL collection write or import that names a constraint, in a transaction TypeGraph opened, and every `bulkUpsertById`, `bulkReplaceById` and `bulkGetOrCreateByConstraint`, is now replayed up to three times on a serialization failure or deadlock and raises `TransactionConflictError` once the attempts are spent, where the driver error previously propagated from the first failure. A write that names no constraint takes one attempt, as before. SQLite is unchanged. +- `getOrCreateByEndpoints` and `bulkGetOrCreateByEndpoints` now accept a `REPEATABLE READ` create leg on the bundled PostgreSQL backends, where it was refused with `MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT`. A custom backend whose fence is an advisory lock with no fences relation (`tableNames.fences`) is now refused at `SERIALIZABLE` as well as `REPEATABLE READ`, with the same code, where `SERIALIZABLE` was previously accepted. +- `GraphBackend.lockSchemaVersionAndGraphWrite` resolves the new `GraphWriteFenceAcquisition` (`{ isolation, fenceRow: "advanced" | "none" }`) instead of a bare `GraphCommandIsolation`. Its params gain a required `fenceRow: "advance" | "leave"`: a custom backend implementing it advances the graph's fence row only for `"advance"`, and reports `fenceRow: "advanced"` only when its statement did. `FenceStatements` gains the optional `acquireKeyedSnapshotFence`, which is absent for an advisory fence that names no fences relation; a fence without it cannot advance a fence row, so the deciding writes above are refused on it at `REPEATABLE READ` and `SERIALIZABLE`. +- `EdgeRegistration` gains `targetCardinality?`, `EdgeIntrospection` gains a required `targetCardinality` and a required `acyclic` boolean (`false` unless declared), and `SerializedEdgeDef` gains an optional `targetCardinality?`. Code that builds either object literal by hand adds the field; everything produced through `defineGraph`, the serializer or `introspectSchema` is unaffected. The serializer omits `targetCardinality` when it is undeclared or explicitly the default `"many"`, so a graph that never uses the option serializes and hashes byte-identically to a pre-release document and keeps `ensureSchema`'s schema-hash fast path. Declaring a non-default value picks up a new schema hash, like any other schema change. +- `GraphBackend.countEdgesFrom` and its `CountEdgesFromParams` type are replaced by `countEdgesAtEndpoint` and `CountEdgesAtEndpointParams`, whose params carry `endpoint: "from" | "to"` and the endpoint's identity as `endpointKind` / `endpointId`. A custom backend renames its implementation and reads the endpoint discriminant. +- `CommonOperationStrategy.buildCountEdgesFrom` is replaced by `buildCountEdgesAtEndpoint`, which counts either endpoint for target-side cardinality (`CountEdgesFromParams` becomes `CountEdgesAtEndpointParams`). The strategy is carried by the bundled engine profiles only (`strategy` is not a derivable profile key), so this affects code that reads a profile's strategy directly, not one that authors a profile. +- `ClaimEdgeCardinalityParams` gains a required `direction: "source" | "target"` discriminant, and `EdgeCardinalityDeclaration` / `ContendedEdgeRow` gain the same. Source-side claim axis strings are byte-identical to before, so existing `typegraph_edge_claims` rows keep fencing what they fence today and no migration is required. +- `ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]`. A command port must apply every entry or return `unsupported` with the `"cardinalityClaim"` dimension before executing any SQL. +- `CardinalityErrorDetails` gains required `direction`, `toKind` and `toId`. Existing `edgeKind` / `fromKind` / `fromId` / `cardinality` / `existingCount` are unchanged. +- `ConstraintFenceViolation` gains an `edgeAcyclicity` member. Its `target` is present only on the claim-backed families, so narrow on `violation.family` before reading it. +- A trusted import now refuses a graph declaring any edge cardinality, in either direction, with `TrustedImportError` reason `"cardinality_unsupported"`, where it previously wrote such a graph without maintaining the claims that fence it; `TrustedImportErrorReason` gains that member and `"acyclicity_unsupported"`. Use `importGraph`, or declare the kind unconstrained and enforce the limit in application code. +- A schema commit that newly declares a constrained `cardinality` or `targetCardinality` — including on a brand-new kind, since a stored schema with no entry reads as unconstrained on both axes — runs an atomic data-check preflight and refuses with a `MigrationError` naming the violating endpoints. A backend that implements `commitSchemaVersion` but not `commitSchemaVersionWithPreflight` throws `ConfigurationError` code `EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND` for such a commit, including one that only adds a new edge kind. +- An edge kind name containing U+001E is refused at `defineGraph`, joining node kinds and unique constraint names. TypeGraph reserves that code point for claim axes. diff --git a/.changeset/identity-reconciliation.md b/.changeset/identity-reconciliation.md new file mode 100644 index 000000000..f4416eef3 --- /dev/null +++ b/.changeset/identity-reconciliation.md @@ -0,0 +1,37 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**Replayable identity history.** An identity-enabled graph with history capture now retains a replayable identity history: `store.identity.transitionsOf(ref)` returns every transition that changed a node's identity class — assertions, retractions, same-id folds, deletes and restores, validity-window ends, kind drops, schema transitions and the identity changes a graph merge applies — each carrying the ids of the `same` assertions that touched its own class (a bulk write names on each transition only the assertions with an endpoint in that class, never the whole batch), both temporal coordinates, and, for a change a merge applied, the branch, branch ancestry and plan and review digests that produced it. A transition an archival restore brought in carries `restored.at`, the destination's wall clock at restore time, so an audit view can tell an imported explanation from a locally replayable event. That marker is a wall clock rather than a `RecordedInstant` because a restore never advances the destination's recorded-revision counter; the retention watermark remains the only revision-shaped signal a restore leaves behind. A restored transition's `recorded` is the source graph's instant, typed `RestoredRecordedInstant` (the `RestoredIdentityTransition` variant of `IdentityTransition`, beside `RecordedIdentityTransition`), which `store.asOfRecorded` does not accept. + +`transitionsOf` and `replay` both page: `limit` caps the number of boundaries one page returns, and a capped page hands back a `nextCursor` to pass as the next call's `cursor`. The cursor is a position within the windowed lineage, so it composes with `fromRecorded`/`toRecorded` by intersection, and `fromRecorded`/`toRecorded` refuse a page cursor with a `ValidationError`. A cursor is bound to the reference whose lineage it pages; one passed to a read of another reference is refused with a `ValidationError`. Bounding the answer with `fromRecorded`/`toRecorded` never bounds the lineage search, so a window can never hide the note that names an earlier class canonical. `store.identity.replay(ref)` pairs each transition with the class membership before and after it, reconstructed through the same historical reader, at the same coordinate, as `store.asOfRecorded`: a step's `after` is what `store.asOfRecorded(step.transition.recorded).identity.membersOf(ref)` answers, and its `before` is the same read one revision earlier. Both are read under the graph's current schema, so a step recorded before a `sameIdAcrossKinds` flip or a kind removal shows that revision as the current schema reads it, and a validity window still open when a step was recorded stays open in that step however late the replay is asked for. Replay requires `history: true` and refuses with `IDENTITY_REPLAY_REQUIRES_HISTORY` otherwise, or with `IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED` on a backend that owns recorded time itself, where TypeGraph captures no history to replay; Cloudflare D1 and neon-http continue to refuse identity-enabled graphs outright. + +A lineage is discovered from the reference itself as well as from its current class, through the recorded `same` assertions and same-id nodes that ever tied it to one, so the history of a member that has since been deleted or split away is still returned from that member. Only the transitions recorded while the reference was a member of the class they name are returned: a class the reference has left keeps a history of its own, and its later merges and splits are not reported as the reference's. Membership is judged over the kinds the log names and, once a schema transition was recorded, under both same-id settings, so a later kind drop or `sameIdAcrossKinds` change does not remove the history that put the reference in the class. Every `transitionsOf` and `replay` page carries `completeness` (`IdentityHistoryCompleteness`): `{ status: "complete" }`, or `{ status: "partial", reasons }` listing each `IdentityHistoryIncompleteReason` that holds, so a short or empty page is never mistaken for a complete one. The reasons are `more-pages` (with the `nextCursor` that reads the next page), `retention-truncated` (with the watermark instant `before`, reported by `transitionsOf` as well as `replay`; only `replay` refuses a range wholly below it), `unattributed-restored` (the count of archivally restored transitions discovery can neither tie to the reference nor rule out), `uncaptured-members` (the count of the reference's current class members that arrived through writes history capture never saw, such as identity changes made before `history: true` was enabled), `restored-not-reconstructed` (on `replay`, the count of the page's restored transitions that are not `steps`), and `pending-transaction-notes` (on `tx.identity`, when the transaction has made identity changes to the graph that the log does not hold until commit). + +`IdentityReplayError` (exported from the package root, `details.code` discriminates) is the typed refusal for a request the transition log cannot answer: history capture is off (`IDENTITY_REPLAY_REQUIRES_HISTORY`), the backend keeps engine-native recorded time (`IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED`), a bounded range lies entirely below the retention watermark (`IDENTITY_REPLAY_HISTORY_TRUNCATED`), the lineage walk reached its read ceiling before converging (`IDENTITY_REPLAY_WALK_INCOMPLETE`), or a prune names a point beyond the recorded clock (`IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK`). A range with more boundaries than `limit` pages and is not a refusal. + +`pruneIdentityTransitions(store, { beforeRecorded })` trims retained explanations and records a watermark that replay reports rather than hiding. A `beforeRecorded` beyond every revision the graph holds (its next commit's revision, or one past the highest retained transition when an archival restore carried higher revisions in) is refused with `IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK`, since it would claim history the graph has yet to record as already pruned; the retention watermark itself never rises above the next commit's revision, and restored rows are pruned by their raw revision number alongside native rows. Archival interchange carries a `transitions` section plus the retention watermark, bumping the interchange format to `3.0`; older documents still import and validate unchanged. Restoring an archive's transitions validates shape only and never re-derives membership, every restored row is marked as such so `replay` never pairs it with a fabricated before/after, and when the destination had no identity transitions of its own before the import began the restore also sets its own watermark, so `replay` reports the pre-restore range honestly instead of claiming a complete history it cannot reconstruct. That decision and the watermark's value are read once, ahead of the import's first write (for a stream, at its header, before the first chunk commits), so `importGraph` and `importGraphStream` restore one archive to the same watermark, and the transitions a streamed restore's own node and assertion chunks record never count as the destination's earlier history. A streamed document whose header does not announce identity transitions (`identity.hasTransitions`) but whose chunks carry them is refused, since an unannounced transition has no restore floor and would silently drop the watermark. A restored transition's `recordedRevision` must be one a recorded instant can carry (an integer from 1, below `Number.MAX_SAFE_INTEGER`), and the transitions must be in non-decreasing `recordedRevision` order. `importGraph` refuses a document breaking either rule with a `ValidationError` before anything is written; `importGraphStream` validates each `identity-transitions` chunk when it arrives, in order across chunks as well as within one, after the stream's node, edge and assertion chunks have committed. A stream whose header announces a restore (`hasTransitions`, or a non-zero `retention` watermark) but that ends without an `identity-transitions` chunk is refused at its end with `IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING`. A `state`-mode document carrying `transitions` or a `retention` watermark is refused before anything is written (`IDENTITY_STATE_IMPORT_TRANSITIONS`, `IDENTITY_STATE_IMPORT_RETENTION`), and a zero watermark restores nothing, so it is accepted by any target. + +**Graph merge carries identity truth with fixed, reported rules.** There is no identity policy to configure. A merge three-way classifies every staged identity assertion and retraction against the target's current truth, and each one ends as a write, a `dropped` entry with a reason, or a refusal that names it: duplicate assertions of one pair reduce to one survivor (an id the target already holds, then the earliest `validFrom`, then the code-point-smallest id) with the rest reported as `identity:duplicate-assertion`; a retraction and a re-assertion of one pair land together only when every branch that retracts the pair also re-asserts it (a re-assertion the branch ended again, or a bounded historical window, is history rather than a re-assertion); branches asserting opposing relations for one pair, or branches disagreeing about whether a retracted pair is re-asserted, fail the merge with `GRAPH_MERGE_IDENTITY_CONFLICT`. A `same` assertion is ledger truth the merge carries across — it never fuses the rows it names. + +A class-lifted `different` assertion now vetoes a candidate match at plan time on every identity-enabled merge, instead of aborting inside the commit on a database constraint. The veto reads the target's ledger and the assertions the merge itself stages: a staged `different` that still holds separates the entities it names before a scored match can fuse them, and lands with both rows, and a staged `same` that still holds joins its endpoints' classes, so a scored match into a class the ledger holds apart from the other side is vetoed too rather than failing the plan on the opposing relations it would create. A scored match between separated entities is dropped, the merge continues, and the pair is reported as an `IdentityUnresolvedConflict` (`kind: "separation"`, naming both entities, the separating assertion and the source that proposed the match) on the new `MergeReport.identityConflicts` and on a plan artifact's optional `review.identityConflicts`; candidate diagnostics report the pair as `excluded` with reason `"separation"`. A definitional match that survives the base and diameter guards, or a cluster that fuses a separated pair through a third node, fails the plan with the new code `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT` on `IdentityMergeConflictError`. An assertion a branch adds and then ends — retracts, or ends by deleting an endpoint — separates and joins nothing; when the match then fuses the two entities an ended `different` named, that row is reported on `dropped` as `identity:ended-different-collapsed`. A row the target already holds is never reported on `dropped` when reconciliation fuses its endpoints or empties its remapped window: the merge writes nothing for it and it stays on the target as it is. The target's own ledger is judged as it stands before the merge, so a target-held assertion a branch retracts still separates or joins for the veto. The veto adds no format number of its own: `review.identityConflicts` is omitted when empty, so a plan the veto never touched carries nothing for it. + +Every transition a merge causes carries a `decision` naming the branch (when exactly one was merged), the root-first branch ancestry, the plan digest when a plan artifact was applied, the review digest of the compatible review revalidation the applier supplied (`MergePlanApplyOptions.review`, refused with `InvalidMergeOptionsError` unless its fresh plan is the plan being applied), and the source id the applier supplied. A transition caused by an identity assertion or retraction the merge applied is recorded with cause `reconcile`; one caused by a node row the merge wrote keeps the cause that names its mechanism — `detach` for a deleted class member, `fold` for a created same-ID peer — and carries the same `decision`. `merge()`, `mergeIncremental()`, `applyMergePlan()`, `applyMergePlanInTransaction()` and `applyDurableMergePlan()` order their node and identity writes the same way, so a plan that retracts an assertion and ends one endpoint's validity window lands through every one of them. A plan artifact also carries a branch's reopening of an ended node or edge window (`clearValidTo: true` on the upsert and on `review.validityEnds`), so a plan that reopens a window can be planned and applied through every one of them rather than being refused at planning. + +One narrow, deliberate behavior change ships in how a merge ends an assertion: when two branches end the same base identity assertion at different valid-time instants, the merge commits the earliest staged `validTo` rather than whichever branch happened to be staged last. The rule is deterministic and branch-order-independent, but it is a real change to which instant a merge commits. The same rule holds when one branch's deletion cascades an ending onto an assertion and another branch retracts it explicitly: if the delete/modify resolution overrules the deletion, the surviving retraction commits at the instant and with the cause of the contributions that still stand, not those of the overruled cascade. + +A durable merge plan built under `reconcileTypes: "ontology"` also no longer fails its own artifact validation ("A resolution must name its complete guarded cluster and carry exactly N-1 decisive edges") when a retype cluster spans several ids: the resolution now names the kind the canonical row is written under, the reconciled kind `TypeReconciliation.toType` records, rather than the staged survivor's pre-retype kind. + +A transaction receipt's `writes.identity` gains `transitions`, counted beside and never inside `total`: the number of identity transition-log notes the transaction's flush wrote. + +`applyMergePlanInTransaction()` records the same identity decision as `applyMergePlan()` for one plan, and accepts the same `review` and `sourceId` provenance as an optional fourth argument (`MergePlanDecisionOptions`). `applyDurableMergePlan()` always applies through the target Store transaction, so the plan's identity and composition work runs through TypeGraph's own write path; a strategy that still declares a native `merge()` is refused. + +### Breaking + +- A merge option `MergeOptions` does not declare is refused with `InvalidMergeOptionsError` (`details.option` names the key) by every merge and plan entry point (`merge()`, `mergeIncremental()`, `planMerge()`, `planMergeIncremental()`, …) and by candidate write-set review, where it was previously ignored. The refusal reaches inside `resolve`: an entry for a node kind the graph does not declare (`details.option` `"resolve."`), an undeclared key in a kind's resolve config, its `keyless` bag, its similarity strategy or a hybrid strategy's `weights` (`"resolve..similarity."`, …), and an unknown similarity `kind` are refused the same way. `applyMergePlan()` and `applyDurableMergePlan()` likewise refuse an apply option `MergePlanApplyOptions` does not declare. Remove the key, or correct its spelling. +- A merge where one branch retracts an inherited identity assertion while another branch retracts it and asserts the same pair again under a new id now fails with `IdentityMergeConflictError` ("retract/reassert race"), where it previously merged and silently kept the re-assertion. A retraction and a re-assertion of one pair merge only when every branch that retracts the pair also re-asserts it. Reconcile the branches — either every retracting branch re-asserts the pair or none does — then merge again. +- A candidate match between two entities whose identity classes a `different` assertion holds apart no longer reaches the commit. A scored match is dropped and reported on `MergeReport.identityConflicts`, and the merge succeeds where it previously failed in the commit; a definitional match or a transitive cluster fails at plan time with `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT` rather than the commit's generic refusal. `IdentityMergeConflictError.code` is typed `IdentityMergeConflictCode`, the union of that code and `GRAPH_MERGE_IDENTITY_CONFLICT`. +- `MergeReport` gains the required member `identityConflicts`, and `CandidateDiagnostic`'s `clusterDisposition.reason` gains `"separation"`. Code that constructs a `MergeReport`, or switches exhaustively over the exclusion reason, must account for both. +- `EntityResolution.kind` (on `MergeReport.resolutions` and a plan artifact's `review.resolutions`) names the reconciled kind for a cluster the ontology cascade retypes. Code that joined a resolution to its staged members by the survivor's pre-retype kind should read `TypeReconciliation.fromTypes` for the staged kinds; clusters no retype touches are unchanged. +- The identity transition log adds two deployment-wide relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`, created for every database whether or not any graph in it enables identity, with `createSqlSchema` accepting `identityTransitions` and `identityTransitionRetention` as optional name overrides and `ResolvedSqlTableNames` gaining both as required members. They ship as a new base-schema release: `CURRENT_BASE_SCHEMA_VERSION` advances from 5 to 6, id `"identity-transition-log"`, adopting both relations through `CREATE TABLE IF NOT EXISTS` and their indexes (idempotent, safe to run concurrently, and a no-op on a fresh install whose generated DDL already carries them). The bump is one-way and deployment-visible: a database stamped 5 or lower is caught up in place by the next privileged open (`createStoreWithSchema` / `createAdapterStoreWithSchema`) or an explicit `backend.adoptBaseSchema()`, and until one of those runs, a DML-only or zero-DDL runtime (`createVerifiedStore`, graph-template attach) refuses with `BaseSchemaMigrationError`, so privileged adoption must finish before those workers start. Once a database is stamped 6, a TypeGraph release that knows only version 5 or lower refuses it as newer; there is no downgrade path. A custom SQL engine profile's `BaseSchemaRuntime` gains two optional members, `identityTransitionsTableDdl` (the transition log's `CREATE TABLE` followed by its `CREATE INDEX` statements) and `identityTransitionRetentionTableDdl` (the retention watermark's `CREATE TABLE`), which back the version-6 adoption step; a profile that omits either still constructs, and version-6 adoption refuses with a `ConfigurationError` whose `details.missingDependencies` names what is missing. A profile whose adoption-step registry stops below 6 fails to construct (`CompilerInvariantError`, "adoption registry must end at the current version"). A deployment whose migration tooling enumerates TypeGraph's relations, or whose database role has restricted DDL, must account for both relations before upgrading; the statements are in the backend setup guide under "Base-schema version 6". On a database that has not adopted version 6, a plain `createStore` attach with `history: true` on an identity graph fails its first write that records an identity transition with a `ConfigurationError` whose `details.code` is `IDENTITY_TRANSITION_RELATIONS_MISSING`, naming both relations. A PostgreSQL durable working-copy allocation carries its own base-schema marker: reopening one provisioned under version 5 (`reopenDurableBranch`, and the durable operations that attach it) adopts version 6 into the allocation in place before its Store opens, through the `connect` session, which runs as the role that created the allocation; an allocation stamped by a newer release is refused with a `BaseSchemaMigrationError` as the cause. An unpublished namespace fork taken before the upgrade keeps its proof: a fork's content digest names a relation added after the first fork release only once that relation holds rows for the graph, so `forkGraphNamespace()` with the same operation key, and `abort()`, still verify a target the earlier release populated, including one whose database has not yet adopted the two relations. +- `exportGraph` and `exportGraphStream` emit interchange format `"3.0"` (`FORMAT_VERSION`), so a document it produces is refused by a TypeGraph release that knows only `"1.0"` and `"2.0"`. Documents in either earlier format still import and validate here unchanged. diff --git a/.changeset/identity-write-path-consistency.md b/.changeset/identity-write-path-consistency.md new file mode 100644 index 000000000..cc7d8a77e --- /dev/null +++ b/.changeset/identity-write-path-consistency.md @@ -0,0 +1,16 @@ +--- +"@nicia-ai/typegraph": patch +--- + +Three write paths now honor identity exactly as the ordinary node writes do. `importGraph` with `onConflict: "update"` sends a document's node `validTo` through the same identity check: an end an open identity assertion would outlive is reported as a per-row error carrying the `IdentityEndpointValidityError` message and the row is left unchanged, and an end that lands on an identity class member records the `window-end` transition; previously the import stored the end and recorded nothing. The transition and the row write sit behind one savepoint, so a row the import then refuses (for example on its own uniqueness claim) leaves no `window-end` transition for an end that never landed, and the same holds for a store update the write refuses. A provenance retraction that closes a fact's currency detaches the fact from its identity class, ending its open identity assertions as any soft delete does, and a reopen folds it back in as a restore; previously a closed fact kept its open assertions and stayed in its class while tombstoned. `bulkUpsertById` items that set `validTo` now go through the same identity check as `update` and `upsertById`, so a window end that an open identity assertion would outlive is refused with `IdentityEndpointValidityError` instead of committing. + +A standalone schema commit (`migrateSchema`, `initializeSchema` or `ensureSchema` called without a Store) now keeps recorded history for a graph TypeGraph captures: its identity ledger deletions (kind-drop cascades, enablement purges) close their recorded rows and its identity transitions are noted, where previously both were silently dropped. Whether a graph captures is read from the database inside the commit transaction — a graph with TypeGraph-recorded node history captures, one using only `revisionTracking` or engine-native recorded time does not. A store open with `history: true` on an engine-native backend no longer binds TypeGraph capture to its schema commit. + +`store.changesSince(anchor)` on a history-capturing store now answers `unbounded` for a span in which an identity assertion was created, retracted or ended, as its documentation states and as the revision-journal source already did. It previously returned an exact node and edge key list that omitted the identity change, so a span holding only an identity write read as an empty delta. The lineage source graph merge prunes by is unchanged and stays bounded across identity writes. + +### Breaking + +- `bulkUpsertById` items that set `validTo` are refused with `IdentityEndpointValidityError` when an open identity assertion would outlive the end, where they previously committed. End the assertion first or drop the `validTo`. +- `importGraph` with `onConflict: "update"` reports a node `validTo` an open identity assertion would outlive as a per-row error and leaves the row unchanged, where it previously stored the end. +- The internal `TransactionRuntime` port loses `runNodeOperationHooks` and gains `deleteNodeWithPolicy`: the provenance fact close and reopen path now deletes and revives through the transaction's own node operations under its buffered hooks. The port is symbol-keyed and not exported from any entrypoint, so only code that reaches into the transaction runtime directly is affected. +- `store.changesSince(anchor)` on a history-capturing store answers `unbounded` for a span holding an identity write, where it previously returned an exact key list that omitted the identity change. A consumer that read that list as a complete delta must handle `unbounded`. diff --git a/.changeset/merge-apply-consumed-edge-deletes.md b/.changeset/merge-apply-consumed-edge-deletes.md new file mode 100644 index 000000000..17e3e41f6 --- /dev/null +++ b/.changeset/merge-apply-consumed-edge-deletes.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +A graph merge no longer refuses a branch that deletes an edge and then the edge's `restrict` endpoint, the order `onDelete: "restrict"` (the default) forces on the branch itself. Merge apply deletes nodes before edges, and each node delete used to count the plan's own edge deletions as live edges, so the merge failed with `MergeConstraintConflictError` caused by `RESTRICTED_DELETE` even though the plan removed the blocking edge. The plan's edge deletions are now consumed by the node deletes that precede them, through `merge()`, `mergeIncremental()`, `applyMergePlan()`, `applyMergePlanInTransaction()` and `applyDurableMergePlan()`. A `restrict` edge the plan does not delete still refuses the merge. diff --git a/.changeset/merge-ended-identity-endpoints.md b/.changeset/merge-ended-identity-endpoints.md new file mode 100644 index 000000000..2d1a2e132 --- /dev/null +++ b/.changeset/merge-ended-identity-endpoints.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +A graph merge no longer refuses an identity assertion that has already ended because one of its endpoints is deleted. A branch that asserts `same` or `different` over two nodes and then deletes one of them — which ends the assertion — or that retracts an assertion while another branch deletes its endpoint, previously failed with `GRAPH_MERGE_IDENTITY_CONFLICT` ("A branch asserted an identity relation over a node another branch deleted.") even though the branch's current ledger held nothing for the pair. The merge now lands the ended assertion as history; an assertion that still holds over a deleted node is refused as before, and so is any assertion, ended or not, over a node the target of an incremental merge deleted after the fork. diff --git a/.changeset/merge-identity-temporal-ledger.md b/.changeset/merge-identity-temporal-ledger.md new file mode 100644 index 000000000..4d1e8278d --- /dev/null +++ b/.changeset/merge-identity-temporal-ledger.md @@ -0,0 +1,12 @@ +--- +"@nicia-ai/typegraph": patch +--- + +A graph merge now plans its identity changes against the same temporal ledger its commit validates them against, so a contradiction with the target's history is refused at plan time, typed, instead of being committed or failing inside the commit. + +- A base assertion a branch retracts is judged as the history it becomes, valid until the retraction's end. A merge could previously commit a ledger that held two nodes both the same and different over that span — for example one branch asserting `different(b, c)` while another asserted a backdated `same(a, c)` and retracted the inherited `same(a, b)` — and `validateIdentity()` did not report it. That merge is now refused with `GRAPH_MERGE_IDENTITY_CONFLICT`. +- A branch claim that contradicts a row the target had already ended — which a branch does not carry, so the branch accepted the claim — or a node the merge deletes, whose ended history the deletion keeps and whose current rows hold until the merge, used to pass `planMerge()` and then fail inside the commit. `planMerge()`, `planMergeIncremental()` and every merge entry point now refuse it before any write with `GRAPH_MERGE_IDENTITY_CONFLICT`; the error's details name the `different` assertion, the class it falls inside and the overlapping `window`. +- The target holds a node's assertions until the merge deletes the node, however early a branch deleted it. A branch that deleted a node and then asserted a pair the node's assertions separated used to pass `planMerge()` and fail inside the commit; it is now refused at plan time with `GRAPH_MERGE_IDENTITY_CONFLICT`. +- An incremental merge whose target commits and ends such a row between planning and the commit is refused with `BaseVersionMismatchError`, to be re-planned, rather than by the commit's identity importer. +- A branch assertion the importer would refuse for its shape, such as one valid from after the planner's clock, is refused at plan time with `IdentityMergeConflictError` (the importer's code in `details.identityCode`) rather than a generic `MergeError`. +- A refusal that still reaches the commit's identity importer no longer suggests retrying: reaching it means planning did not foresee the refusal, and re-planning against an unchanged target refuses again. diff --git a/.changeset/ontology-change-classification.md b/.changeset/ontology-change-classification.md new file mode 100644 index 000000000..0024d0144 --- /dev/null +++ b/.changeset/ontology-change-classification.md @@ -0,0 +1,38 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**`isStructuralSubtype(child, parent)`** joins the `@nicia-ai/typegraph/schema` entrypoint: a structural-subtyping check over TypeGraph's projected JSON Schema fragment that answers whether every value satisfying `child` is guaranteed to satisfy `parent`. The verdict is three-way — `subtype`, `not-subtype` with the deepest failing reason and path, or `incomparable` when either side carries a construct the projection cannot judge (`$ref`, `allOf`, `not`, unmodeled JSON Schema keywords, a parent `oneOf` whose members are not provably exclusive, or nesting past the depth guard) — so an unjudgeable pair is refused rather than accepted. The registry's `subClassOf` / `equivalentTo` check additionally reads the live Zod schema for the constraints the JSON Schema projection drops — a regex flag other than `g`, a `.meta()` key spelled like a constraining keyword, and, on the parent, a refinement or a strict object — and answers `incomparable` for a pair that carries one where the two sides differ. A `$ref` is resolved against its own side's `$defs`: two properties that name the same reference are the same schema only when the definitions they reach are equal, since the projection numbers a recursive definition by encounter order and two unrelated recursive types share a name. A parent `oneOf` is read member-wise only when no value can match two members (different `type` tokens, or one required property pinned to disjoint `const`/`enum` sets): `z.discriminatedUnion` always qualifies, while a `z.xor` whose members overlap is `incomparable`. Width subtyping is allowed, required parent properties must stay required and narrower in the child, `integer` narrows `number`, unions match member-wise with sibling keywords ANDed, and `format` is compared as a constraint because the Zod projection emits it without a `pattern` for validators such as `z.url()`. It answers a different question from the migration classifier's `isBreakingPropertyChange`, which asks whether existing rows stay valid after a schema edit; the two disagree in both directions and the module documents why. It is the predicate this release's typed `subClassOf` / `equivalentTo` contract is built on. + +**Ontology changes are now classified by what they do to existing data**, and a tightening the data violates is refused instead of auto-migrated. Adding `disjointWith`, `subClassOf` or `equivalentTo` is classified `warning` and probed inside the schema-commit transaction — before the Operational Identity closure rebuild — on every path that commits a version (`createStoreWithSchema` / `ensureSchema`, `Store.evolve()`, `migrateSchema()`, `Store.withEvolvedTransaction()`, and `rollbackSchema()`). An added `subClassOf`/`equivalentTo` is probed for the uniqueness components it merges — including a re-pairing where each kind swaps one neighbour for another, so no component grows — and for the kinds it makes required-existence composition parts: a live row of a kind that inherits `existence: "required"` with no live whole refuses the commit (`compositionExistence`). Removing `subClassOf`/`equivalentTo` is probed for live edges whose endpoints the remaining declarations no longer admit. Adding or removing `inverseOf` or `implies` is now `breaking`: both change what a default `expand` traversal returns, so they need an explicit `migrateSchema()`, exactly like the Operational Identity `sameIdAcrossKinds` flip. Adding `partOf`/`hasPart` is `warning`, probed for a part with more than one live whole and for a composition cycle (plus a part with no live whole when the pair is `existence: "required"`); removing one is `breaking`, like `inverseOf`/`implies` — except a stored relation with no `via`, which realizes no composition pair and is `safe` to remove. Removing `disjointWith` stays `safe`, and `broader`/`narrower`/`relatedTo`/`differentFrom` stay `safe` in both directions. A symmetric relation (`disjointWith`, `equivalentTo`, `sameAs`, `differentFrom`, `relatedTo`, `inverseOf`) restated with its two arguments swapped is no change at all, so it neither probes nor commits a version. A relation whose `from` or `to` names a kind the same commit removes is itself `safe`, and rows of the removed kind never block the commit. An edge kind that stops admitting an endpoint pair between surviving kinds is probed the same way whatever caused the loss — a removed `subClassOf`/`equivalentTo`, a narrowed `from`/`to` declaration (reported as an `entity: "edgeRegistration"` change), or a kind removed from the middle of a subclass chain — so `Store.removeKinds()` refuses when a surviving subclass's live edge was admitted only through the removed kind. `ChangeSeverity` itself is unchanged — `warning` now means "auto-migrates only if the data allows it". + +**A refused tightening throws `MigrationError`** with `details.reason: "ontology-tightening-violated"` and `details.violations` in exactly the shape `store.verifyConstraintFences()` returns, so the same rows that block the migration can be listed, resolved, and the migration retried. The audit gains an `edgeEndpointAssignability` family reporting non-deleted edges sitting outside every declared endpoint pair, whatever their validity window: a future-dated edge becomes current once the clock reaches it, and an ended one is what an `asOf` read inside its window returns, so a narrowing is probed against both. A backend implementing `readConstraintFenceViolations` may receive an `edgeEndpointAllowances` declaration and must answer with `misassignedEdgeEndpointRows`; omitting the key when the family was requested is refused with `CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED` rather than reported as a clean result. A backend that cannot commit a preflight atomically refuses a tightening with `ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND`, and a preflight target that cannot run the audit refuses with the existing `CONSTRAINT_FENCE_AUDIT_UNSUPPORTED`. One residual window remains: a writer that commits under the previous schema version between the probe and the version compare-and-set is not seen, so `store.verifyConstraintFences()` remains the post-hoc detector. + +**An ontology a diff cannot validate is now a refusal rather than a silent classification.** `classifyOntologyChanges` and `computeSchemaDiff` propagate `ConfigurationError` when a diff adds or removes an ontology relation and either side's stored or proposed ontology cannot be validated. The proposed side is held to every rule. The stored side is a delta input no store reads or writes through, so it is excused from the two contracts that postdate documents already stored — structural subsumption, and the `partOf`/`hasPart` declaration rules (a relation with no `via`, or a realizing edge those rules refuse) — which is what lets a fix-forward commit that corrects or removes the offending relation be classified at all. Any other stored-side incoherence (a subclass cycle, an invalid equivalence class) still refuses. Every read-only caller built on `computeSchemaDiff` inherits this: `loadAndVerifyGraph` (and so `createVerifiedStore` / `assertSchemaCurrent`) and `getSchemaChanges()` now also throw `ConfigurationError` for the same documents. `requiresMigration()` is the one exception: it treats "cannot classify" the same as "needs migration" and returns `true` instead of throwing, so a document only the privileged path can interpret still routes there. + +**Adopted evolutions and rollbacks run the same tightening preflight.** `planEvolution()` derives the tightening check from the plan's own before/after documents and lists it as an `ontology-tightening` requirement, naming the probed ontology `changes` and the newly constrained `edgeCardinalityAxes`; `withEvolvedTransaction()` runs it on the fenced caller session before the schema compare-and-swap and refuses with the same `MigrationError` `Store.evolve()` raises, so a `disjointWith`, `acyclic: true` or tighter cardinality that existing rows violate can no longer be published through a plan. A tighter cardinality is any constrained `cardinality` or `targetCardinality` that differs from the stored one, except `"one"` → `"oneActive"`: that move only stops counting ended edges, so it owes no data check and commits on a backend without the atomic preflight primitive, while `"oneActive"` → `"one"` is probed like any other tightening. `rollbackSchema()` checks the version it reactivates against the active one the same way — a rollback to a stricter version whose declarations live rows violate is refused, while a rollback that tightens nothing still flips on any backend — and refuses to reactivate a version that re-keys a populated edge kind's match identity (`reason: "edge-match-identity-rekey"`). A rollback that would change an identity-enabled graph's identity profile, ontology or node kinds is refused with `ConfigurationError` `IDENTITY_ROLLBACK_REQUIRES_MIGRATION`: rebuilding the identity closure needs the graph definition, so commit that version's definition forward with `migrateSchema()` instead. Every commit path composes these checks in one fixed order: structural gates, edge match identity, ontology tightening, then the identity closure rebuild. + +**A schema commit that would leave a uniqueness constraint unenforced over existing rows is now refused.** A schema commit validates no existing row against a unique constraint and rebuilds no claim, so two changes used to commit and silently strand enforcement: adding or reshaping a unique constraint on a populated kind (pre-existing duplicates stayed, and a new node duplicating an old value was accepted), and removing a `subClassOf`/`equivalentTo` relation — or a kind of the hierarchy — so that a `kindWithSubClasses` constraint's claims, which can sit under any member the hierarchy has had, may no longer sit where the kind reads them (a merge followed by a split reaches this too, so the rule is "the component lost a member that sorts before the kind", not "the last axis left it"). Every path that publishes a version (`createStoreWithSchema` / `ensureSchema`, `migrateSchema()`, `Store.evolve()`, `Store.withEvolvedTransaction()`, `Store.removeKinds()`, `rollbackSchema()`) now probes the affected kinds for rows inside the schema-commit transaction and refuses with `MigrationError` `details.reason: "unique-constraint-requires-empty-kind"`; `details.constraints` names each blocked constraint's `kind`, `constraintName`, `cause` (`"constraint-added"`, `"constraint-reshaped"`, `"claim-axis-moved"`) and claim `axis` (plus `lostKinds`, the earlier-sorting members the component lost, for a moved claim), typed as the new `UniquenessClaimGate` export. The probe is an existence check (`countNodesByKind` / `countEdgesByKind` gain an optional `limit`; a backend that ignores it still answers correctly), and a kind the commit adds is probed too, because `migrateSchema()` with `discardDroppedKindRows: true` leaves the dropped kind's rows in the table and a re-added kind of the same name would see them live again with no claim. An unpopulated kind, a removed constraint, and an added relation (which only widens what a constraint reads) are unaffected. `Store.evolve()` keeps refusing a unique constraint on a populated graph-extension kind with `IncompatibleChangeError`; both refusals now share one predicate. `planEvolution()` lists each gated kind as a `require-empty` requirement. + +### Breaking + +- Adding a unique constraint to a kind that holds rows, or changing an existing constraint's `fields`, `scope`, `collation` or `where` on one (an omitted `scope` or `collation` reads as its default, so stating the default is no change), is refused with `MigrationError` `reason: "unique-constraint-requires-empty-kind"` on every schema-commit path, including `rollbackSchema()` to a version that declares the constraint. These commits previously succeeded and left the constraint unenforced for existing rows. Declare the constraint before the kind is populated, or delete the kind's rows, commit, and write them back. +- Removing a `subClassOf`/`equivalentTo` relation, or removing a kind (`Store.removeKinds()` included), is refused with the same reason when it drops a member sorting before the kind from the subclass component of a `kindWithSubClasses` unique constraint a kind that holds rows declares, even if that member is not where the kind's most recent claims sit. Empty that kind first. +- Re-adding a kind that declares a unique constraint over rows a `migrateSchema(..., { discardDroppedKindRows: true })` drop left in the table is refused with the same reason. Run `store.materializeRemovals()` first. +- Backend authors: a commit that adds or reshapes a unique constraint on a kind (a newly added kind included), or that drops an earlier-sorting member from a `kindWithSubClasses` component, now needs `commitSchemaVersionWithPreflight` (`setActiveVersionWithPreflight` for a rollback) even when the kind is empty. A custom backend without it is refused with `ConfigurationError` `UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND`, where it previously committed through `commitSchemaVersion` or `commitSchemaVersionIfKindsEmpty`. +- An exhaustive `switch` over `MigrationErrorDetails["reason"]` (or `MIGRATION_FAILURE_REASONS`) must handle `"unique-constraint-requires-empty-kind"`. + +- `ConstraintFenceViolation` gains an `edgeEndpointAssignability` member, carrying `edgeKind`, `allowedPairs` and `edges` instead of a claim `target`. Narrow on `violation.family` before reading `violation.target`. +- A custom backend implementing `readConstraintFenceViolations` must answer `misassignedEdgeEndpointRows`, which the audit now requests for every registered edge kind. An un-updated custom backend finds the audit — and any ontology-tightening schema commit — refused with `ConfigurationError` `CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED` on any graph with at least one edge kind. +- `getSchemaChanges()`' `summary` counts `modified` ontology entries (`Ontology: 0 added, 0 removed, 1 modified`); a diff whose only change was one previously summarized as `No changes` beside `hasChanges: true`. +- Removing a `partOf`/`hasPart` pair, and adding or removing `inverseOf`/`implies`, now requires an explicit `migrateSchema()` even against clean data: `ensureSchema()`'s auto-migrate branch refuses these with `MigrationError` `reason: "breaking-change"`. +- A `subClassOf`/`equivalentTo`/`disjointWith`/`partOf`/`hasPart` addition the data already violates is refused with `MigrationError` `reason: "ontology-tightening-violated"`. Inspect `details.violations` to find and resolve the offending rows, then retry. +- Narrowing an edge kind's `from`/`to` out from under a live edge, and `Store.removeKinds()` of a mid-chain kind a surviving subclass's live edge relied on, are refused with `MigrationError` `reason: "ontology-tightening-violated"`. A narrowing no live edge sits outside still auto-migrates. +- Re-adding an edge kind over rows a `migrateSchema(..., { discardDroppedKindRows: true })` drop left in the table is refused with `MigrationError` `reason: "ontology-tightening-violated"` when those rows sit on endpoint pairs the re-added declaration does not admit, or form a cycle under a re-added `acyclic: true`. Run `store.materializeRemovals()` first, or declare endpoints that admit the rows. +- Narrowing an edge kind's `from`/`to` is now also refused over a future-dated or already-ended edge the narrowed declaration does not admit, where it previously committed and left a row that turned into a live violation when the clock reached it. +- Backend authors: a commit that adds an edge kind now needs `commitSchemaVersionWithPreflight`, because the commit checks for rows a previous removal left under that name. A custom backend without it is refused with `ConfigurationError` `ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND`, where it previously committed through `commitSchemaVersion`. +- A `subClassOf`/`equivalentTo` pair whose two sides differ and whose Zod schemas carry a regex flag other than `g`, a `.meta()` key named like a JSON Schema constraint (`maxLength`, `pattern`, …), or, on the parent, a `.refine()`/`.superRefine()` or a strict object, is refused at registry build with `ConfigurationError` `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` (`ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE` for an equivalence), where the check previously judged the pair on a projection that had dropped the constraint. A child reusing the parent's schema, or a field schema both share, is unaffected. +- `store.verifyConstraintFences()` now also reports the `edgeEndpointAssignability` family, so it may surface pre-existing violations on a database that has never run this check. Treat a non-empty result the way you treat the existing families: inspect, resolve, or track as known debt. +- `EvolutionRequirement` gains an `ontology-tightening` arm (`changes`, `edgeCardinalityAxes`), listed on a change plan whose delta tightens the ontology or an edge cardinality. An exhaustive `switch` over `requirement.kind` must handle it. +- `rollbackSchema()` may now refuse where it previously flipped the active version unconditionally: with `MigrationError` for a tightening live rows violate or a populated edge re-key, with `ConfigurationError` `EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND` (`details.edgeKinds`) for an edge re-key on a backend without `setActiveVersionWithPreflight`, and with `ConfigurationError` `IDENTITY_ROLLBACK_REQUIRES_MIGRATION` for an identity-relevant change on an identity-enabled target. +- Backend authors: `GraphBackend` gains an optional `setActiveVersionWithPreflight(params, preflight)`, which runs `preflight` in the same schema-write transaction and under the same fence as the active-version flip. The bundled SQL backends implement it. A custom backend without it keeps working for rollbacks that owe no preflight; a rollback that owes one is refused with the same atomic-preflight capability error a forward commit raises (`ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND`, `EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND`, or `IDENTITY_REQUIRES_ATOMIC_BACKEND`). diff --git a/.changeset/paginate-traversal-rows.md b/.changeset/paginate-traversal-rows.md new file mode 100644 index 000000000..e1aef29bf --- /dev/null +++ b/.changeset/paginate-traversal-rows.md @@ -0,0 +1,14 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**`paginate()`, `page()` and `stream()` no longer skip or repeat rows of a traversal query.** A traversal that fans one start node out into several rows produced rows that tied on the whole keyset, so every sibling of a page's last row was skipped; and an order key on a traversed alias (`orderBy("p", "name")` after `.to("Person", "p")`, the shape the pagination guide shows) was compared against the start node's row, which repeated or dropped pages. The keyset now identifies the result row — the start node's identity plus the `id` of the edge each traversal matched, so two parallel edges between one pair of nodes page as two rows — and the cursor is positioned against the completed match, where an order key may read the start node, a traversed node or an edge. An edge-alias order key also works for a `page()` read inside `batchOnce()`, which previously failed to compile. + +**A multi-hop traversal returns each matched path once on every plan.** A query with two or more chained traversals whose intermediate node was reached by more than one row returned each of those rows once per row reaching it, whenever it also carried a completed-match `where()`, an optional traversal, a `groupBy()` or an aggregate; counts and sums over such a query were inflated the same way. A cursor is a completed-match filter, so every page after the first of such a query repeated rows too. Each traversal now expands an intermediate node once however many rows reach it. + +**Cursor pagination refuses a query with a recursive traversal.** `paginate()`, `page()` and `stream()` over `recursive()`, `parts()` or `wholes()` could skip rows: a recursive row has no single edge to extend the start-node keyset with, so the rows of one start node that straddle a page boundary were lost, and paging one start node at a time dropped them too. These calls now throw a `ConfigurationError` with `details.code` `RECURSIVE_PAGINATION_UNSUPPORTED` on every backend before any statement runs, including for `recursive({ maxHops: 1 })`. `parts()` and `wholes()` with `maxHops: 1` and neither `depth` nor `path` compile to a fixed one-hop step and are not refused. To read a recursive walk in pieces, use `execute()` with an explicit `limit()` or `recursive({ maxHops })`, or page the start nodes in a query without the recursive traversal and run the recursive walk for each page. + +### Breaking + +- A cursor saved from a traversal query is refused with the existing cursor-column `ValidationError`, because the keyset gained the edge keys. Restart that pagination from the first page. Cursors of queries with no traversal are unaffected. +- `paginate()`, `page()` and `stream()` on a query with a recursive traversal (`recursive()`, `parts()`, `wholes()`) now throw `ConfigurationError` (`RECURSIVE_PAGINATION_UNSUPPORTED`) instead of returning pages that could silently drop rows. Replace them with `execute()` and an explicit `limit()`/`maxHops`, or page the start nodes in a non-recursive query and run the recursive walk per page. diff --git a/.changeset/projection-spelling-diff.md b/.changeset/projection-spelling-diff.md new file mode 100644 index 000000000..f5ba13223 --- /dev/null +++ b/.changeset/projection-spelling-diff.md @@ -0,0 +1,9 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**A stored schema document no longer diffs as a breaking change just because the JSON Schema projection spells it differently.** Zod's projection changed how it writes two constructs: a union of bare primitives (`anyOf: [{ type: "string" }, { type: "null" }]`, now `type: ["string", "null"]`) and a closed tuple's arity (a bare `prefixItems`, now `items: false` with `minItems`/`maxItems` restated). A graph whose document was stored under the earlier spelling reported every `.nullable()`, primitive `z.union()` and `z.tuple()` property as "Property schemas changed incompatibly", so `createStoreWithSchema()` refused to open it without a `migrateSchema()` and `createVerifiedStore()` / `requiresMigration()` reported a migration that was not needed. The schema diff now folds both spellings before comparing, including a union nested in a union (`z.union([...]).nullable()`, `.nullable().nullable()`) — on every path built on `computeSchemaDiff()` — and `isStructuralSubtype()` reads the two tuple spellings as the same tuple, as it already did for the two union spellings. The fold rewrites only the keywords that spell these constructs: a union member carrying any other keyword, instance data (`default`, `const`, `examples`) and unknown extension keys are compared exactly as before, so a genuine change is still reported. Two stored shapes still read as changed, because the document cannot say what was meant. A tuple with an optional trailing member (`z.tuple([a, b.optional()])`): the earlier projection wrote it identically to the required tuple, so a bare `prefixItems` is read as the required-arity tuple. And a tuple with a rest element (`z.tuple([a], rest)`): the earlier projection wrote it with no `minItems`, which is exactly how the current one writes `z.tuple([a.optional()], rest)`, so it is read as written. Beside an explicit `items`, `minItems` is always compared as stated — the current projection omits it when every prefix member is optional, so required and all-optional tuples are different schemas to both the diff and `isStructuralSubtype()`. Run `migrateSchema()` once to restamp a document holding either shape. Schema hashes are unchanged. + +### Breaking + +- `JsonSchema`, the projected schema type the `/schema` entrypoint exports, widens `items` from `JsonSchema` to `JsonSchema | boolean` and gains `prefixItems`, `minItems` and `maxItems`, because a closed tuple now projects as `items: false`. Code that reads `.items` as a schema narrows out the boolean first. diff --git a/.changeset/provenance-composition.md b/.changeset/provenance-composition.md new file mode 100644 index 000000000..b249530b0 --- /dev/null +++ b/.changeset/provenance-composition.md @@ -0,0 +1,18 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**Provenance support treats a required composition part as dependent on its whole**, so a belief-status close reaches the parts that cannot exist without it. A fact whose kind is an `existence: "required"` part is supported only while the whole it currently hangs from is itself supported (a whole that is also a fact kind) or a live row (any other whole). Closing a whole's currency therefore closes its required parts in the same transition, transitively through a part that is itself a whole; `RetractionReport.died` names every one of them, and `survivedVia` and `unaffected` stay accurate because the affected set widens along the same dependency. Reopening the whole reopens the parts that are otherwise supported, reopen remaining support-driven rather than a ledger of what a close closed, and a required part is closed even when a different source supports it, because the existence dependency dominates its own grounding. + +A non-fact whole whose validity window has ENDED still holds its parts up: it is a live row, and the write path would still accept it as a whole. Each closed fact fires its own `delete` operation hook, parts included, rather than folding into one event the way the delete cascade's single hook does. An optional part is untouched: it can exist with no whole, so it keeps its attachment to the closed whole and its own belief status, and the composition claim still stops a second whole from taking it. No edge is deleted by any of this, so the `compositionExistence` audit family reports nothing after a close — a closed required part is tombstoned, not an orphan. A graph that declares no required part pays no extra read. + +**`RetractionReport.died` now names every fact a transition closes**, not only those believed before it. A live fact is unsupported whenever no justification of it fires — reachable through ordinary store calls, for example a justification one of whose premises is a source created with its retracted flag already set, which leaves the derived fact live and unsupported with no constraint violated — and a transition that reaches such a fact closes it, so it is reported in `died`. The composition case is narrower: a required part whose whole was tombstoned outside the store's own paths, which `store.verifyConstraintFences()` also reports. + +**`createRetractionCapability` refuses a configuration whose fact kinds own required parts it cannot reach**: a fact kind that is the whole of an `existence: "required"` pair whose part kind is not itself a fact kind throws `ConfigurationError` (`PROVENANCE_REQUIRED_PART_NOT_A_FACT`, naming the whole kind, the part kind and every offending pair). Both sides are read through subsumption, so a fact kind that subclasses a declared whole kind counts, and so does a subclass of a declared required part kind. + +**The audit sees one more orphan shape.** `store.verifyConstraintFences()`' `compositionExistence` family also reports a live required part whose whole is TOMBSTONED, not only one with no composition edge at all — a state the delete cascade cannot produce but a direct backend write, a custom port or a bypassed import can. The write path's incumbent decision is unchanged and deliberately narrower: a tombstoned whole still holds its part's attachment there, so a second whole cannot quietly take it. + +### Breaking + +- `RetractionReport.died` can name a fact that was not believed before the transition, when that fact was live, unsupported and closed by it. A consumer treating `died` as a subset of the previously believed set sees an extra entry instead of a silent tombstone. +- `GraphReadBackend` gains the optional `getNodes` member it already had on `GraphBackend`, so a read surface narrowed by hand can pass the batch point read through. diff --git a/.changeset/recorded-subgraph-edge-windows.md b/.changeset/recorded-subgraph-edge-windows.md new file mode 100644 index 000000000..0a5354c40 --- /dev/null +++ b/.changeset/recorded-subgraph-edge-windows.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**A recorded-pinned `subgraph()` with `edgeWindows` no longer fails with an unknown `recorded_from` column.** The windowed edge source projected only the live edge columns, while the recorded read's row filter over it narrows by the recorded interval; the source now keeps `recorded_from` / `recorded_to` whenever the recorded binding carries them (an engine-native `AS OF` source carries none and needs none). diff --git a/.changeset/remove-custom-meta-edge.md b/.changeset/remove-custom-meta-edge.md new file mode 100644 index 000000000..5ad0be0b7 --- /dev/null +++ b/.changeset/remove-custom-meta-edge.md @@ -0,0 +1,21 @@ +--- +"@nicia-ai/typegraph": minor +--- + +The public custom `metaEdge()` factory, the public `InferenceType` union, and the deprecated `sameAs`/`differentFrom` type-level factories are removed. None of them ever drove runtime behavior beyond serialized introspection metadata: every closure and write-path decision for a meta-edge is made per literal name, never read off a `MetaEdge`'s properties. Free-form, type-level vocabulary the library has no built-in reasoning for now belongs in `defineGraph({ annotations })` — see [Type-Level Annotations](https://typegraph.dev/ontology#type-level-annotations) and `examples/08-custom-ontology.ts` for a complete example. Replace `sameAs(A, B)` with `equivalentTo(A, B)` (behaviorally identical in every release `sameAs` shipped in); delete `differentFrom(A, B)` calls (it was decorative and never enforced) or, for real cross-kind instance identity, use the TypeGraph Identity Profile (`identity: { sameIdAcrossKinds: "fold" }` and `store.identity`). The serialized schema document drops `metaEdges[*].transitive`/`symmetric`/`reflexive`/`inverse`/`inference` on the next write that a real ontology change triggers — `SerializedMetaEdge` now carries only `name`/`description` — and the deserializer's Zod validator no longer requires these fields, so a document written before this release keeps loading and its extra fields are ignored. Upgrading the library alone does not rewrite a stored document: `ensureSchema` only writes a new version when it detects a semantic change, so a graph whose ontology is otherwise untouched keeps its pre-removal document and its stored `schema_hash` until its next real schema change lands. That stored hash no longer matches the hash this release computes for the same graph, so every comparison of a stored schema version against a graph, or against another stored version, now hashes the stored document in this release's serialization instead of reading the `schema_hash` column: the dropped meta-edge fields are ignored and the precomputed closures are recomputed from the stored relations. Boot (`ensureSchema`, `createVerifiedStore`), `registerGraphTemplate`, `refreshSchema`, `planEvolution`, `branch()`, graph merge plans and candidate reviews, and the provenance sidecar ownership check therefore all treat such a document as current. The same holds for a graph template an earlier release registered and a target graph it instantiated: registering the template again and instantiating onto that target judge the stored document, so both succeed for an unchanged schema without rewriting either row, and a template registered with different content is still refused (`GRAPH_TEMPLATE_CONTENT_CONFLICT`). The handle `registerGraphTemplate` returns carries the new optional `registryHash`, the hash the registry stores the template under, which instantiation binds; it differs from `schemaHash` only for a template an earlier release registered, and a handle without it binds `schemaHash` as before. The document is parsed and hashed once per process; the `schema_hash` column stays the writing release's record and is never rewritten. A persisted `sameAs` relation keeps folding into the equivalence closure exactly like `equivalentTo`; a persisted `differentFrom` relation keeps being inert. A relation naming a custom `metaEdge()` you called before this release is different: since the factory that declared it is gone, your upgraded graph can no longer serialize that relation at all, and the stored document holds its last copy. Removing it is therefore classified `breaking` and never auto-migrates: `ensureSchema` and `createStoreWithSchema` stop with a migration action naming every such relation, and each of those changes carries the relation in the new optional `OntologyChange.removedCustomRelation`. `store.evolve()`, `store.planEvolution()` and `store.removeKinds()` would drop the relations as well, so each refuses before writing with `MigrationError` under the new `reason: "custom-relation-removal"`, naming the relations in `details.relations`. Move the relations into `annotations`, then commit once with `migrateSchema()`; see the migration guide linked below. The `OntologyChange` union's `entity` field narrows from `"metaEdge" | "relation"` to `"relation" | "edgeRegistration"`: a meta-edge name leaving the serialized `metaEdges` catalog is always a consequence of its last relation being removed or added, never an independent event, and `"edgeRegistration"` classifies an edge kind's own registration: its `acyclic` flag, or a narrowed `from`/`to` declaration. Graph extensions continue to refuse a `metaEdge` name outside the closed core set — nothing in this change reopens that. + +**Rolling deploys and rollback.** The new serializer output is a one-way door: a document written by a `@nicia-ai/typegraph` release with this change (only after that document's next real schema commit — see above) cannot be parsed by an earlier release, because the pre-change `serializedSchemaZod` required `transitive`/`symmetric`/`reflexive`/`inference` on every `ontology.metaEdges` entry. Upgrade every reader of a shared database (every application instance, in a mixed-version fleet) to this release or later before any of them commits a schema change, and do not roll back to a pre-change release once one has — an older instance's `parseSerializedSchema` throws on the rewritten document instead of degrading. + +See [Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](https://typegraph.dev/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) for the complete migration guide. + +### Breaking + +- Removed: `metaEdge()` and `MetaEdgeOptions` (`@nicia-ai/typegraph`, `@nicia-ai/typegraph/core`). +- Changed: a graph whose stored schema holds a relation naming a custom meta-edge needs a one-time acknowledged migration. Removing that relation is `breaking`, so `ensureSchema` / `createStoreWithSchema` throw `MigrationError` (or return `status: "breaking"` with `throwOnBreaking: false`) until you move the relations into `defineGraph({ annotations })` and commit with `migrateSchema()`. `store.evolve()`, `store.planEvolution()` and `store.removeKinds()` refuse with `MigrationError` (`reason: "custom-relation-removal"`) until then, so code with an exhaustive switch over `MigrationErrorDetails["reason"]` gains a case. A deployment that opens stores with the default `autoMigrate` must run that migration before the upgraded application boots. +- Removed: the public `InferenceType` union (`@nicia-ai/typegraph`, `@nicia-ai/typegraph/backend`). +- Removed: `MetaEdgeProperties.transitive`, `.symmetric`, `.reflexive`, `.inverse`, and `.inference` — `MetaEdgeProperties` now carries only `description`. +- Removed: the deprecated `sameAs()` and `differentFrom()` relation factories (`@nicia-ai/typegraph`), and the matching `core.sameAs` / `core.differentFrom` members of the exported `core` object. The `sameAs`/`differentFrom` meta-edge NAMES are still recognized when they appear in a persisted document or a declarative graph extension — only the factory functions that construct them from code, and the `core.sameAsMetaEdge` / `core.differentFromMetaEdge` objects, are gone; `core` no longer carries any way to construct a relation naming either. +- Changed: `store.reconciledSchema.hash`, `store.introspect().schemaHash`, evolution-plan baselines, merge plan schema anchors, and transaction receipts report the hash of the stored schema document as this release serializes it, not the stored `schema_hash` column. For a version written by this release the two are equal; for a version an earlier release wrote for a graph declaring ontology relations they differ, so code that compares one of these values against `schema_hash` read from `typegraph_schema_versions` directly must compare against the recomputed value instead. +- Changed: `SerializedMetaEdge` narrows to `{ name, description }`, dropping `transitive`/`symmetric`/`reflexive`/`inverse`/`inference`. This document shape is a one-way door — see "Rolling deploys and rollback" above. +- Changed: `OntologyChange["entity"]` narrows from `"metaEdge" | "relation"` to `"relation" | "edgeRegistration"`. Code that matched on `entity === "metaEdge"` no longer has a case to match. `"relation"` is the relation-level severity table. `"edgeRegistration"` classifies an edge kind's `acyclic` flag turning on or off, and an edge kind's `from`/`to` declaration narrowing so it no longer admits endpoint pairs it did — those are edge-registration properties, not ontology relations, and a switch written from the old `"relation"`-only note does not compile. +- Changed (backend authors): `GraphBackend.registerGraphTemplate` returns the row stored under the template id and no longer refuses a row with a different hash itself; `registerGraphTemplate` judges the stored row and raises `GRAPH_TEMPLATE_CONTENT_CONFLICT`. A custom member that still refuses a differing hash keeps refusing a template an earlier release registered. diff --git a/.changeset/store-view-sealed-reads.md b/.changeset/store-view-sealed-reads.md new file mode 100644 index 000000000..95e225803 --- /dev/null +++ b/.changeset/store-view-sealed-reads.md @@ -0,0 +1,9 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**A `StoreView` read refuses a temporal coordinate its caller states instead of silently answering at the pin.** `view.subgraph()`, the ten algorithm reads (on the view and on `view.algorithms`), `view.bulkFindEdgesFrom()` / `bulkFindEdgesTo()`, `view.edges..bulkFindFrom()` / `bulkFindTo()` and the point reads (`getById`, `getByIds`, `find`, `count`, and on edges `findFrom`, `findTo`, `findByEndpoints`) omit `temporalMode`, `asOf` and `recordedAsOf` from their option types, but an untyped caller could still pass them, in the position the live collection takes its own temporal options; the view answered at the pin and dropped the stated value. Each read now rejects with `ConfigurationError` `STORE_VIEW_SEALED_COORDINATE` (`details.method`, `details.stated`, and the pinned coordinate), on a `"current"` view and an `asOf` view alike, matching `view.query().temporal(...)`. A recorded-time view's `getById`, `getByIds` and `scan` (on node and edge collections) refuse a stated coordinate the same way. Read at another coordinate through the live store or a second `store.view(...)`. + +### Breaking + +- A `StoreView` read that states `temporalMode`, `asOf` or `recordedAsOf` from untyped code rejects with `ConfigurationError` `STORE_VIEW_SEALED_COORDINATE`, where it previously answered at the pin and dropped the stated value. Remove the stated coordinate, or read it through the live store or a second `store.view(...)`. diff --git a/.changeset/structural-subtype-finite-values.md b/.changeset/structural-subtype-finite-values.md new file mode 100644 index 000000000..72d5a2eb9 --- /dev/null +++ b/.changeset/structural-subtype-finite-values.md @@ -0,0 +1,7 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**The structural `subClassOf` / `equivalentTo` check now accepts a finite value set under a bounded parent.** A child pinned with `z.literal` or `z.enum` is judged value by value against the parent's length bounds, `regex()` pattern (and the patterns behind `startsWith()`, `endsWith()`, `includes()`, `lowercase()` and `uppercase()`), numeric bounds, `multipleOf()` and type, so `z.literal("x")` under `z.string().min(1)`, `z.enum(["a", "b"])` under `z.string().max(10)` and `z.literal(5)` under `z.int().min(0)` no longer fail registry build with `ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE`. A value is accepted only when both Zod and JSON Schema accept it: lengths are counted in code points and in UTF-16 units, patterns are tested with and without the `u` flag, and `multipleOf` is decided on exact decimals. Standard formats such as `z.email()` are still not judged by value. A record's key set now compares as a subtype instead of requiring identical `propertyNames`, so `z.record(z.enum(["a", "b"]), V)` extends `z.record(z.string(), V)`. A finite value set is judged only when every pinned value is a value of the child's own type and the set is not empty, so no keyword is ever satisfied vacuously. + +The live check also stays conservative around three constraints the JSON Schema projection drops, which let it accept pairs whose child admits a value the parent refuses; each is now refused as incomparable. A string format a later format replaces: `z.url().startsWith("https://")` projects only its `starts_with` format, so `z.string().startsWith("https://")` passed beneath it. An overwrite (`.trim()`, `.toLowerCase()`, `.toUpperCase()`, `.normalize()`, `.overwrite()`): on a parent, when another check runs after it and judges the transformed value (`z.string().min(2)` passed under `z.string().trim().min(2)`); on either side, when it runs after another check, so the node stores a value that check never saw. A `z.literal(NaN)` or `z.literal(±Infinity)`, which projects as `null` (`z.literal(NaN)` passed under `z.number()`). A replaced format, or an overwrite that runs only ahead of the other checks, is refused on the parent side alone; in every case a schema both sides share still compares equal. A record whose key schemas are two different `$ref` definitions reports `incomparable` (`schema-reference`) instead of `not-subtype` (`property-names-mismatch`), the same as every other unresolved reference pair. diff --git a/.changeset/subgraph-query-hooks.md b/.changeset/subgraph-query-hooks.md new file mode 100644 index 000000000..02d9c666a --- /dev/null +++ b/.changeset/subgraph-query-hooks.md @@ -0,0 +1,5 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**`store.subgraph()` now fires `onQueryStart` / `onQueryEnd` / `onError` for every statement it submits**, at the current coordinate and under `store.asOfRecorded(...)`, as the Observability Hooks guide documented. Direct subgraph reads previously ran on the store's unobserved backend, so a hook saw nothing while `tx.subgraph()`, `batchOnce()` subgraphs, neighbor reads and fluent queries were observed. A direct subgraph emits two statements on SQLite and three on PostgreSQL, and a `composition` subgraph emits four on both. diff --git a/.changeset/typed-subsumption.md b/.changeset/typed-subsumption.md new file mode 100644 index 000000000..624488170 --- /dev/null +++ b/.changeset/typed-subsumption.md @@ -0,0 +1,37 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**`subClassOf` and `equivalentTo` now carry a structural contract, checked at compile time and again at registry build.** `subClassOf(child, parent)` becomes generic and requires that the child's schema output structurally extend the parent's — every property the parent requires, the child has with a compatible type, with the child free to add properties or narrow an optional-in-parent property. `equivalentTo` between two registered kinds checks the same contract in both directions, because equivalence is mutual subsumption. A failing pair is refused at compile time with a named `StructuralSubtypeMismatch` object naming the offending fields, and at registry build — live graphs, `evolve()`-authored extensions and deserialized documents alike — with a `ConfigurationError` under one of four codes (`ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE`, `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE`, `ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE`, `ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE`) naming the child, the parent, the relation the pair comes from (`details.relation`: `subClassOf`, `equivalentTo`, or a `sameAs` a persisted document still carries), whether the pair was declared or reached transitively, and the full violation list. `getSchemaChanges` reports the same refusal before an upgrade whenever the graph declares such a relation — including when nothing changed at all, which is the case of a hierarchy an earlier release accepted, and when a migration only edits a property schema on a kind already party to an existing hierarchy; `requiresMigration` collapses it to `true` by design, so call `getSchemaChanges` directly for the detail. The new `isTypeLevelSubtype` (on the `/schema` entrypoint) is the runtime prediction of the compile-time check, for tooling that wants to know in advance whether a pair will compile. The registry check compares projected JSON Schema, so a field holding a Zod construct `z.toJSONSchema` cannot convert (`z.set()`, `z.map()`, `z.date()`, `z.bigint()`, a `.transform()`, or a `z.custom()`) is judged on its own while every other field of the pair is still compared. TypeGraph's `embedding(n)` compares as the fixed-length number array it validates, so `embedding(3)` extends `z.array(z.number())` and `z.array(z.number()).length(3)` extends `embedding(3)`, while `embedding(3)` does not extend `embedding(4)`; index options do not affect subsumption. Nothing about any other such field's values can be compared, so it is accepted only under a parent field that admits every value (`z.unknown()`, `z.any()`), or beside a parent field it provably equals: the same Zod schema (a field shared through `.extend()`) or a plain check-free field of the same type (two `z.date()`s). Any other pair is refused as `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` (`reason: "unsupported-construct"`) naming the field — a `z.string().transform(f)` child under `z.string().min(3)`, a plain `z.string()` under a parent whose `z.string().min(3).transform(f)` hides its bound, a `z.date()` replacing a `z.string()`, or two separately declared transforms or custom checks. The check is sound in the sense that a child stores nothing its parent could not store itself: a field shared through `.extend()` is accepted whatever its `.default()`/`.catch()` value, while a separately declared field has each such value judged against the parent field like a literal (`z.string().min(1).default("")` does not extend a separately declared `z.string().min(1)`; a default it cannot judge by value, such as one on `z.email()`, is refused). A parent `z.codec()`/`z.preprocess()`, or a `.pipe()` whose input side states a check, is incomparable. Which keys a kind stores and requires is read from Zod's property metadata, not the projection's `required`: a key Zod may omit from what the child stores (an exhaustive record's optional value, a `.catch()` field) counts as optional, and a key whose input the parent requires counts as required. `z.int64()`/`z.uint64()` are never read as a plain `z.bigint()`. A subclass that adds an `embedding()` opens, and one that also loosens a parent's `z.string().min(5)` is refused. A field Zod converts only in part, such as a `.catch()` with a dynamic fallback, keeps the type it states and is comparable only to the same schema. A deserialized document has no Zod schemas and stores such a kind without a property projection, so its own registry check skips a pair with that kind on either side; every commit, `createStore`, `getSchemaChanges` and `requiresMigration` also check the code graph. + +**`equivalentTo` between two registered kinds means mutual subsumption**, and every reading of "same class" now agrees with it: `isAssignableTo`, `isSubClassOf`, `expandSubClasses`, `getSubClassComponent`, disjointness propagation, edge-endpoint admission, query expansion and uniqueness fencing. With `equivalentTo(Company, Corporation)`, `isAssignableTo("Corporation", "Company")` is true, a `Company` alias (`expansion: "subclasses"`, the default) returns `Corporation` rows, an edge declared `to: [Company]` accepts a `Corporation` endpoint, `search(..., { expansion: "subclasses" })` covers both kinds, and a `kindWithSubClasses` uniqueness constraint fences across the pair. Subsumption stays strict — a kind is never its own subclass, and `expandSubClasses` still lists each kind once. External IRIs stay out of subsumption: they are inert references, so `Person ≡ schema:Person ≡ Individual` folds `Person` and `Individual` together without making the IRI a kind. `reconcileTypes` now reads the store's own validated `KindRegistry`, so a merge and a query can no longer disagree about which kinds are the same class, and every kind-name ordering in that path is code-point order. `equivalentTo`'s left parameter widens to `NodeType | AnyEdgeType` so an edge kind can be mapped to an external IRI; an equivalence class that mixes node and edge kinds, or that contains more than one registered edge kind (reachable transitively through a shared IRI), is refused at registry build and at schema load with `ConfigurationError` code `ONTOLOGY_EQUIVALENCE_INVALID_CLASS`. + +**`from()`/`to()`/`fromDynamic()`/`toDynamic()` are polymorphic by default**: a query against a supertype returns its subtype rows, typed with the parent's properties, and the alias's `kind` discriminant and `NodeId` brand widen to `string` only for a kind the ontology can actually affect. `queryDefaults.expansion: "exact"` on `createStore(...)` restores the previous exact-kind runtime behavior everywhere in one place (it does not restore the narrow alias types, see Breaking); `search()`, the collection APIs (`find`, `count`, `updateWhere`, `compareAndSet`) and `subgraph({ includeKinds })` are unaffected and stay exact-kind. A parent-kind alias reads each property as the parent declares it: a subclass that omits a parent-optional property, or narrows its type, leaves that property usable in `select()` (decoded as the parent's type on every backend), `whereNode()`, `orderBy()` and `groupBy()`, and `$fulltext.matches()` is available whenever the parent kind declares a `searchable()` field and every subclass either omits it or declares it `searchable()` too. A subclass that redeclares a parent's `searchable()` or `embedding()` field untagged (a legal subtype, since neither annotation is a structural constraint) would leave its rows out of the parent-typed accessor's index, so `$fulltext.matches()` refuses and names the kind, and the embedding accessor is refused as a field the kinds do not agree on. An explicit kind list, `from([...], alias, options?)`, takes the same `expansion` option and the same store default: each listed kind expands as `from(kind)` would, the union is deduplicated, and the alias types each listed kind as a single-kind alias over it would be. Because the alias type is computed from the compile-time graph, a subclass that only a persisted extension declares (added through `store.evolve()`, or folded back in by `createStoreWithSchema` on a later boot) cannot be typed: when an alias states no `expansion`, its kind is exact in the compile-time graph, and the live schema would expand it to further kinds, `from()`/`to()` refuse with `ConfigurationError` (`QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`, naming the kind and the extra kinds) instead of returning rows whose `kind` and `NodeId` contradict their type. A list source is refused only for an extra kind no member of its type admits: a kind the list names is typed exactly by the list, as is a kind in the expansion of a listed kind the compile-time graph already types polymorphically. State `{ expansion: "subclasses" }` (typed polymorphically) or `{ expansion: "exact" }` on the query, or use `fromDynamic()`/`toDynamic()`. `store.search()` is not refused: its `expansion: "subclasses"` hit is typed polymorphically whatever the compile-time graph declares. The refusal is decided from the graph's ontology values at run time, which cannot show the type a store was declared with, so it also fires for a store typed over a widened graph type (`Store`) or an ontology whose literals an `OntologyRelation` annotation erased, although such a store already types the alias polymorphically; such code states `expansion` or uses `fromDynamic()`. An `updateWhere({ candidates })` query is an ordinary query and follows the default, and a candidate query must resolve to exactly one kind: over a kind with subclasses or `equivalentTo` kinds it is refused with `ConfigurationError` (`SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED`) unless it passes `{ expansion: "exact" }`. + +**An alias states its expansion as one option**, `expansion: "exact" | "subclasses" | "narrower"` (default `"subclasses"`), on `from()`/`to()`/`fromDynamic()`/`toDynamic()`. `"narrower"` expands a kind through its `broader`/`narrower` kind-taxonomy relations instead of `subClassOf`, and types the alias untyped since no schema relationship is claimed. An alias cannot state two axes at once; a value outside the three is refused (`QUERY_ALIAS_EXPANSION_INVALID`), as is a narrower expansion naming an unregistered kind or, on `to()`/`toDynamic()`, a kind the traversed edge does not admit as an endpoint (`ONTOLOGY_NARROWER_KIND_NOT_REGISTERED` / `ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED`). Endpoint admission is read from the edge's registration, so it holds whether the endpoints are declared on the edge type or on the graph's `{ type, from, to }` entry; `toDynamic()` refuses a target kind such a registration does not admit with `EndpointError` the same way. Omitting the options argument, passing `{}` and passing an explicit `undefined` all mean "take the store default", so an options bag forwards through a caller's own helper without special-casing the empty case; `queryDefaults.expansion` and `createQueryBuilder`'s `defaultExpansion` accept a stated `undefined` too. Any other default outside `"exact"` / `"subclasses"`, `"narrower"` included, is refused with `QUERY_ALIAS_EXPANSION_INVALID` when the store or builder is constructed rather than at the first alias that falls back to it. A bag that can carry an axis the call site does not pin, such as `{ expansion?: "exact" }`, forwards as well and types its alias untyped, as `"narrower"` does. `store.search()` spells the same axis under the same name (`expansion: "exact" | "subclasses"`, default `"exact"`; the search facade is not polymorphic by default). A search hit is typed by the axis it states, as the `from()` alias stating that axis is: `store.search.fulltext` / `vector` / `hybrid` with `expansion: "subclasses"` returns hits whose `node` keeps the searched kind's properties while its `kind` and `NodeId` brand widen, since the row may be a subclass or an equivalent kind, including one only a persisted extension declares; `"exact"` or an omitted option keeps the exact type, and an `expansion` value that is not a literal at the call site types the hit in the widened form. Spelling the kind generic by hand, `store.search.fulltext<"Doc">(...)`, takes the exact overload, or the widened one when the call states an `expansion`. An options value typed by the library's own `FulltextSearchOptions` / `VectorSearchOptions` / `HybridSearchOptions`, whose `expansion` is optional and either axis, forwards through a caller's wrapper unchanged and types the hit in the widened form. A search that would silently miss rows the index cannot reach is refused with `ConfigurationError` (`SEARCH_EXPANSION_KIND_UNINDEXED`, naming the kinds) instead of under-returning, exactly where `$fulltext.matches()` and the embedding accessor refuse the same alias: a fulltext search of any kind list, exact included, in which a kind has no `searchable()` field or redeclares it untagged, and a vector expansion in which a subclass declares the `embedding()` field with a tag that differs from the subsuming kind's declaration. An exact `store.search.fulltext()` (or hybrid fulltext leg) over a kind that declares no `searchable()` field therefore refuses where it returned `[]`. A subclass that merely omits the field holds no content there and is skipped. + +**Every meta-edge factory now returns a relation typed with its meta-edge name literal and its endpoint kinds**, not only `subClassOf`/`equivalentTo`, the IRI overload (`equivalentTo(Kind, iri)`, typed `from: Kind, to: string`) included. A kind whose only equivalence is IRI-routed therefore widens its alias like any other equivalenced kind, and a graph mixing `subClassOf` with `broader` / `inverseOf` / `disjointWith` keeps exact alias types for the kinds no subsumption relation names. The flip side: a relation annotated as the bare `OntologyRelation` widens every alias in that graph conservatively. An endpoint declared as a union of node types keeps its literals and stays precise, widening exactly the kinds the union can hold and nothing else. Typed edge collections (`store.edges.`, the view and recorded-time collections, `bulkFindEdgesFrom`/`bulkFindEdgesTo` and the edges `neighbors()` returns) type each declared endpoint through the same alias typing (`SubsumedNodeType`): an endpoint kind the ontology can affect reads back with a `string` kind and a widened `NodeId` brand, and endpoint arguments accept a node or `{ kind, id }` of any kind assignable to the declared one (`AssignableNodeKinds`, transitive over `subClassOf` and `equivalentTo`) and a polymorphic query row or widened endpoint reference (`{ kind: edge.toKind, id: edge.toId }`), so a subclass row from a supertype query connects without a cast. `bulkFindEdgesFrom`/`bulkFindEdgesTo` sources and targets and the `neighbors()`/`countNeighbors()` source accept the same widened reference, so an endpoint read off one edge feeds the next hop as it is; each result bucket's `source` / `target` echoes that reference back and is typed as the same widened input (`GraphNodeReferenceInput`), so on an affected graph its `kind` reads as `string`. The widened arm is structural: a node or reference of an unrelated kind whose properties include the declared kind's also compiles, and the runtime refuses it (`EndpointError` on an edge write, `KindNotFoundError` for an unregistered kind). The new `store.getNodeByRef({ kind, id })` (also on transactions and views) reads the node a reference names through that kind's collection, typed as the kind's node for a literal kind and as the kind-discriminated union of the graph's node types plus the base `Node` for a `string` kind; it returns `undefined` for a missing node and rejects with `KindNotFoundError` for an unregistered kind. + +### Breaking + +- **`subClassOf`/`equivalentTo` are now generic and carry a structural contract**, which a callers-bypassing path cannot skip: an extension's raw `{ metaEdge: "subClassOf", from, to }` relation and a persisted document are both refused at registry build. Run `getSchemaChanges(backend, graph)` before upgrading a populated deployment: an incompatible hierarchy surfaces there as a `ConfigurationError` naming the child, the parent and the offending property path. No data migration is required; every refusal is a schema-authoring fix. +- **Supertype queries now return subtype rows**, and rows of kinds declared `equivalentTo` the queried kind rather than only its `subClassOf` descendants. To keep the previous row behavior everywhere, pass `createStore(graph, backend, { queryDefaults: { expansion: "exact" } })`; per call site, pass `{ expansion: "exact" }`. Omitting the option no longer stops expansion. The store default governs which rows come back, not how an alias is typed: see the alias-type bullet below. If an `equivalentTo` pair was never meant to be substitutable, drop the declaration. +- **An explicit kind list, `from([...])`, now expands each listed kind** through its subclasses and `equivalentTo` kinds under the store default, where it always scanned exactly the listed kinds and refused an options bag. To keep the previous rows, state `from([...], alias, { expansion: "exact" })`, or set `queryDefaults: { expansion: "exact" }` on the store. The alias type widens the same way a single-kind alias does: for a listed kind the ontology affects, `kind` is `string` and its `NodeId` brand widens; `{ expansion: "exact" }` keeps the exact union. A saved pagination cursor from a single-kind list whose kind has subclasses is refused for the same reason the cursor bullet below gives; restart pagination or state `{ expansion: "exact" }`. +- **A query over a kind whose subclasses exist only in a persisted extension is refused when it states no `expansion`**, with `ConfigurationError` `QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`, where it returned the extension's subclass rows under an exact `kind` type. This covers `from()`, a list source (for an extra kind no listed kind's type admits) and `to()`. State `{ expansion: "subclasses" }` or `{ expansion: "exact" }` on the call, or use `fromDynamic()`/`toDynamic()`. The decision reads the graph's ontology values at run time, not the store's declared type, so it also fires for a store typed over a widened `GraphDef` or an erased ontology, whose aliases are already typed polymorphically; state `expansion` there too. +- **An `updateWhere({ candidates })` query over a kind with subclasses or `equivalentTo` kinds is refused** with `ConfigurationError` (`SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED`), because `from(kind)` now resolves such a kind to several kinds. Pass `{ expansion: "exact" }` to the candidate query's `from()`. A candidate kind with no subclasses or equivalents is unaffected. +- **A saved pagination cursor from a supertype query no longer matches that query.** A query whose source now resolves to several kinds orders by an added `kind` tiebreaker before `id`, so a cursor encoded before the upgrade is refused with `ValidationError` (cursor column mismatch). Restart pagination from the first page, or pass `{ expansion: "exact" }` to keep the single-kind order the cursor was encoded against. +- **The `includeSubClasses` boolean is replaced by one `expansion` option** on `from()`/`to()`/`fromDynamic()`/`toDynamic()`: `{ includeSubClasses: false }` becomes `{ expansion: "exact" }` and `{ includeSubClasses: true }` becomes `{ expansion: "subclasses" }`, which is now also what an omitted option means. `store.search()`'s `includeSubClasses: true` (the `includeSubClasses` member of `SearchScopeOptions`) becomes `expansion: "subclasses"`. `createStore(..., { queryDefaults: { expansion } })` and `createQueryBuilder`'s `defaultExpansion` set the default everywhere in one place. A value outside the accepted set is refused with `QUERY_ALIAS_EXPANSION_INVALID`, and so is a stated `includeSubClasses` from untyped code, on every one of those entry points and on `store.search()` (the message names `expansion`): the option is never read as "no option", so a JavaScript `{ includeSubClasses: false }` cannot silently become the polymorphic default. +- **The internal `SchemaIntrospector` replaces `hasSearchableField` with `kindsWithoutSearchableField`**, which answers the same question and names the kinds that leave an alias without searchable content, and gains `kindsRedeclaringEmbeddingUntagged`. The introspector is built by the library for the query builder and the search facade and is not exported from any entrypoint, so only code that reaches into a builder's internals is affected. +- **`QueryAst`'s `start.includeSubClasses: boolean` becomes `start.expansion: "exact" | "subclasses" | "narrower"`.** This affects only code inspecting a compiled AST directly. +- **Alias types widen under the polymorphic axis.** For a kind that some other kind declares itself a subclass of, or that participates in a registered-kind `equivalentTo`, the alias's `kind` is `string` and its `NodeId` brand widens. A relation endpoint declared as a UNION of kinds, spelled `typeof Audio | typeof Video` or `NodeType<"Audio" | "Video">`, widens every kind the union can hold. A graph declaring no subsumption relations is unaffected. Alias types widen whatever the store default: `queryDefaults: { expansion: "exact" }` changes the rows an alias returns, not its type, so an affected kind still types `kind` as `string` and its id is not accepted by the kind's own collection. State `{ expansion: "exact" }` on the call (`from("Person", "p", { expansion: "exact" })`) to keep the literal alias type. +- **Expanded search hits widen unconditionally.** `store.search.fulltext` / `vector` / `hybrid` called with `expansion: "subclasses"` no longer claims the exact searched kind, on any kind, as `from(kind, alias, { expansion: "subclasses" })` types its alias: `hit.node.kind` is `string` and `hit.node.id` is no longer accepted by the searched kind's own collection methods. This includes a kind no compile-time subsumption relation names, because a persisted extension can still subclass it. Narrow on `hit.node.kind` first, or search with the default `"exact"` axis. A search that states no `expansion` is unaffected. +- **`ReturnType>` (and `vector` / `hybrid`) now names the widened hit.** The facade's last overload is the one an options value of the library's own `FulltextSearchOptions` / `VectorSearchOptions` / `HybridSearchOptions` type takes, and `ReturnType` reads the last overload. Name the exact hit as `FulltextSearchHit>`, or take the type of a call that omits `expansion`. +- **Typed edge reads widen an affected endpoint.** For an endpoint whose declared kind has `subClassOf` descendants or `equivalentTo` kinds, `edge.fromKind`/`edge.toKind` is `string` and `edge.fromId`/`edge.toId` is no longer accepted by the declared kind's own collection, so `store.nodes.Person.getById(edge.toId)` stops compiling where the edge may point at an `Employee`. Resolve the endpoint with `store.getNodeByRef({ kind: edge.toKind, id: edge.toId })`, narrowing `edge.toKind` first for a specific kind's typed node. An endpoint kind no subsumption relation names is unaffected, unless the ontology's literals are erased (see the `OntologyRelation` bullet). +- **`TypedEdgeCollection`, `TypedStoreViewEdgeCollection` and `TypedRecordedStoreViewEdgeCollection` take the graph as a second type argument**, and `EdgeCollection`, `StoreViewEdgeCollection` and `RecordedStoreViewEdgeCollection` take it as their last. Named without it, they keep the declared endpoint kinds exactly, so an annotation such as `TypedEdgeCollection<(typeof graph)["edges"]["worksAt"]>` no longer equals `store.edges.worksAt` for an affected endpoint; write `TypedEdgeCollection<(typeof graph)["edges"]["worksAt"], typeof graph>`. +- **Annotating a relation as `OntologyRelation` widens every alias in that graph**, and every typed edge endpoint read with it, including endpoints between kinds no relation names. Aliases still compile with their `kind` and `NodeId` types widened to the polymorphic form, but `store.nodes.X.getById(edge.toId)` stops compiling for every edge in the graph — let the factory's return type be inferred to keep the narrow types. +- **An equivalence class mixing node and edge kinds, or holding two registered edge kinds, is refused** with `ConfigurationError` `ONTOLOGY_EQUIVALENCE_INVALID_CLASS` where it previously loaded inert; a persisted schema carrying one fails on load, so correct the stored document before upgrading. `equivalentTo`'s first parameter widens to `NodeType | AnyEdgeType` so an edge kind can be mapped to an external IRI. +- **Edge endpoints, `implies` and `inverseOf` endpoint validation, and `importGraph`'s endpoint check all accept equivalent kinds.** Some declarations previously refused at registry build now construct. +- **A `kindWithSubClasses` uniqueness constraint spans the equivalence class**, so a value that was unique per kind before can now collide. The claim axis moves to the code-point minimum of the widened component, and **no data migration is required**: rows written under the previous axis are still found and still fence. The reverse is not covered: REMOVING a `subClassOf`/`equivalentTo` can move a kind's axis away from the kinds its existing claims sit under, and those claims then stop fencing new writes of that kind. `store.verifyConstraintFences()` reports any duplicate that results (claims are read by their owner's kind, not by where they were written); until claims are re-homed on removal, run it after dropping a relation from a populated hierarchy that declares a `kindWithSubClasses` constraint. +- **The serialized schema's precomputed `ontology.closures` change for any graph declaring `equivalentTo` between registered kinds**, and with them the schema hash. It resolves as a semantic no-op on both boot paths and needs no schema write: a stored version's hash is recomputed from its document with the closures re-derived from its own relations, so it equals the hash of the unchanged graph and the store reports `status: "unchanged"` (see the removed `metaEdge()` change for the full list of comparisons that read the recomputed hash). +- **The `zod` peer range rises from `^4.0.0` to `^4.5.0`.** The registry's structural check compares a `subClassOf` / `equivalentTo` pair field by field, judging the fields JSON Schema cannot express (`z.date()`, `z.custom()`, `z.set()`, `z.map()`, `z.bigint()`, `.transform()`) one at a time and an `embedding()` as a fixed-length number array, through a `z.toJSONSchema` hook Zod added in 4.5. Zod 4.0–4.4 ignores that hook without an error, which would skip the whole pair instead, so those releases are no longer supported. Upgrade `zod` to 4.5 or later. +- If an existing `subClassOf` was modelling a taxonomy rather than a genuine subtype relationship, replace it with `broader(narrowerKind, broaderKind)` and query with `{ expansion: "narrower" }`; for a vocabulary that grows at runtime, prefer an instance-level concept kind with a `broader` edge traversed with `.recursive()`. diff --git a/.changeset/unique-constraint-defaults.md b/.changeset/unique-constraint-defaults.md new file mode 100644 index 000000000..bf8b53055 --- /dev/null +++ b/.changeset/unique-constraint-defaults.md @@ -0,0 +1,11 @@ +--- +"@nicia-ai/typegraph": minor +--- + +**An omitted unique-constraint `scope` or `collation` now has one meaning everywhere.** The typed API requires both members, but an untyped caller could leave them out. The store then enforced the constraint across the kind's subclass component (any scope but `"kind"` reads as subclass-wide), the serializer dropped the keys, and the stored-schema parser read them back as `scope: "kind"`, so a schema migration of such a graph reported "Unique constraints changed" for a constraint nobody touched. The serializer and `defineGraph` now write `scope: "kindWithSubClasses"` and `collation: "binary"`, the values the store has always enforced, into a declaration that omits them, and the parser and the stored-document hash read an omitted member the same way. A row an earlier release wrote without them hashes equal to today's serialization, so a redeploy stays unchanged and no stored row is rewritten. A `scope` or `collation` stated outside its domain is refused at `defineGraph` with `ConfigurationError` (`UNIQUE_CONSTRAINT_OPTION_INVALID`); it previously produced a stored schema the loader rejected. A graph-extension document keeps its own default, `scope: "kind"`. + +**`migrateSchema` re-keying an edge kind's match identity on a backend without `commitSchemaVersionWithPreflight` now refuses with `EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND`**, with `details.edgeKinds` naming the re-keyed kinds as initial adoption already does. It refused with `IDENTITY_REQUIRES_ATOMIC_BACKEND`, naming identity on a graph that may have none. + +### Breaking + +- A stored unique constraint that omits `scope` now parses as `"kindWithSubClasses"`, not `"kind"`: `getActiveSchema`, schema diffs and introspection of such a document report the scope the store enforced. Enforcement is unchanged. To make such a constraint kind-scoped, declare `scope: "kind"`. That redeploy reshapes the constraint, so the uniqueness gate refuses it while the kind holds rows (`unique-constraint-requires-empty-kind`, cause `constraint-reshaped`): export and delete the kind's rows, commit the schema, then import them again. diff --git a/.changeset/unregistered-kind-reads.md b/.changeset/unregistered-kind-reads.md new file mode 100644 index 000000000..6bef43ae1 --- /dev/null +++ b/.changeset/unregistered-kind-reads.md @@ -0,0 +1,9 @@ +--- +"@nicia-ai/typegraph": patch +--- + +**`subgraph()` and the graph algorithms refuse a kind the graph does not register.** `edges` and `includeKinds` on `store.subgraph()` (and on `tx.subgraph()`, `batchOnce`'s `read.subgraph()` and a view's `subgraph()`), `edges` on every algorithm read including `degree`, and the `nodeKinds` scope of `labelPropagation`, `weaklyConnectedComponents` and `pageRank` were compiled straight into a kind filter, so an unregistered name from untyped code matched nothing and the read answered with an ordinary empty or incomplete result. Each now rejects with `KindNotFoundError`, as `neighbors()` and `bulkFindEdgesFrom()` / `bulkFindEdgesTo()` already did; all of them share one lookup. + +### Breaking + +- A read that named an unregistered kind and answered with an empty or incomplete result now rejects with `KindNotFoundError`. Correct the kind name, or catch the error where a read over a possibly-dropped kind was relied on to return nothing. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 765560e5a..6ada0cfba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ permissions: # Declaration bundling traverses the full public backend and store type graph. # Keep its worker above Node's default old-space ceiling as that API grows. env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 jobs: detect-release-metadata-push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dcb5e8da3..dccf725f5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -19,7 +19,7 @@ permissions: # Declaration bundling traverses the full public backend and store type graph. # Keep its worker above Node's default old-space ceiling as that API grows. env: - NODE_OPTIONS: --max-old-space-size=6144 + NODE_OPTIONS: --max-old-space-size=8192 jobs: release: diff --git a/.gitignore b/.gitignore index cdd60e3a1..673cfbc52 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ .vinxi .wrangler dist/ +.declaration-emit/ .turbo tmp /out-tsc diff --git a/.prettierignore b/.prettierignore index 050444962..016b3d2ba 100644 --- a/.prettierignore +++ b/.prettierignore @@ -14,6 +14,7 @@ pnpm-workspace.yaml src/routeTree.gen.ts worker-configuration.d.ts dist/ +.declaration-emit/ node_modules # Vendor analytics bootstrap: minified is:inline snippet that prettier-plugin-astro cannot parse diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0b0e99e81..305f090f9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -95,6 +95,15 @@ To build the package for distribution: pnpm build ``` +The build has two ordered stages, which `packages/typegraph/scripts/build.ts` +owns: `tsc` emits the declarations once into `.declaration-emit/`, then tsup +bundles the JavaScript and rolls each entrypoint's `.d.ts` out of that emit. +Running `tsup` directly skips the first stage, so its declaration entries do not +resolve and the build fails — use `pnpm build` (or `pnpm dev`, which keeps both +stages watching). Deriving declarations from source inside the rollup instead +costs about 10 GiB of resident memory for any entrypoint; rolling the emitted +tree costs about 1.5 GiB for the whole build. + ## Making Changes 1. **Branch:** Create a new branch for your feature or fix. diff --git a/apps/docs/src/content/docs/architecture.md b/apps/docs/src/content/docs/architecture.md index 457d68369..a229151d0 100644 --- a/apps/docs/src/content/docs/architecture.md +++ b/apps/docs/src/content/docs/architecture.md @@ -177,7 +177,7 @@ const store = createStore(graph, backend); These closures are stored in the `TypeRegistry` and used during query compilation: ```typescript -.from("Media", "m", { includeSubClasses: true }) +.from("Media", "m", { expansion: "subclasses" }) // At compile time, expands to: WHERE kind IN ('Media', 'Podcast', 'Article', 'Video') ``` @@ -585,13 +585,14 @@ nearest neighbor search efficiently. - **Whole-graph algorithms**: WCC, label propagation, and PageRank iterate over every visible node by default, or over an explicit `nodeKinds` induced subgraph, and their selected edges - **Large property filtering without indexes**: JSON extraction is slower than column access -- **Cross-kind queries**: `includeSubClasses: true` increases the WHERE IN set +- **Cross-kind queries**: `expansion: "subclasses"` increases the WHERE IN set ### Optimization Strategies 1. **Filter early**: Apply predicates as close to the source as possible 2. **Limit results**: Always paginate large result sets -3. **Use specific kinds**: Avoid `includeSubClasses` unless needed +3. **Use specific kinds**: A supertype query expands its `WHERE kind IN (...)` set by + default; pass `{ expansion: "exact" }` when the wider set isn't needed 4. **Index JSON paths**: For frequently-filtered properties, add expression indexes 5. **Batch writes**: Use transactions to reduce disk syncs and round-trips diff --git a/apps/docs/src/content/docs/backend-authoring.md b/apps/docs/src/content/docs/backend-authoring.md index ab650ff0a..6ff4977da 100644 --- a/apps/docs/src/content/docs/backend-authoring.md +++ b/apps/docs/src/content/docs/backend-authoring.md @@ -92,6 +92,31 @@ consistently. and `DerivableEngineProfileOverrides`) is the exact set above, as an `as const` array. +### Base-schema adoption DDL (`baseSchemaRuntime`) + +`baseSchemaRuntime` carries the DDL each numbered base-schema adoption step +runs (see [Upgrading deployment-wide base +storage](/backend-setup/#upgrading-deployment-wide-base-storage)). Members a +release added after profiles already existed are optional, so a profile written +before them still constructs; the adoption step that needs a missing member is +where its absence is an error. Version 6 reads two of them: + +| Member | What it holds | +| --- | --- | +| `identityTransitionsTableDdl` | The identity transition log's idempotent `CREATE TABLE`, followed by its `CREATE INDEX` statements, in run order. | +| `identityTransitionRetentionTableDdl` | The retention watermark relation's idempotent `CREATE TABLE`. | + +Render both from your dialect's own DDL generators over the profile's table +bindings, as the bundled profiles do, so a custom table name reaches the +statements. A derived profile that leaves `baseSchemaRuntime` alone keeps the +bundled members; one that replaces it must carry them over. When either is +absent, version-6 adoption refuses with a `ConfigurationError` whose +`details.missingDependencies` names what is missing and +`details.adoptionVersion` is `6`; nothing is stamped, so adoption retries from +version 5 once the profile supplies them. A profile whose adoption-step registry +stops below the current version fails to construct with +`CompilerInvariantError` ("adoption registry must end at the current version"). + ### The adapter-backed carve-out `declaredCapabilities` and `resourceAudit` are otherwise freely derivable, diff --git a/apps/docs/src/content/docs/backend-setup.md b/apps/docs/src/content/docs/backend-setup.md index 1a5448e29..70fc0007e 100644 --- a/apps/docs/src/content/docs/backend-setup.md +++ b/apps/docs/src/content/docs/backend-setup.md @@ -417,8 +417,8 @@ unique constraint (a create takes a generated or a caller-supplied id) — except a node delete, which fuses even when the kind DOES carry a declared unique constraint, because the atomic delete program releases that claim in the same statement. A singleton edge create fuses when the kind's -cardinality is `"many"`, and edge update and delete fuse the same way -(`EdgeCollection` has no `upsertById`). So do +cardinality AND targetCardinality are both `"many"`, and edge update and +delete fuse the same way (`EdgeCollection` has no `upsertById`). So do `bulkInsert`/`bulkCreate`/`bulkDelete`/`bulkReplaceById`/`bulkUpsertById`, and a constrained write inside an atomic program's claim envelope. Each of these asserts the active schema version inside the statements neon-http @@ -691,9 +691,21 @@ transaction command may additionally carry a coordination token only after it has acquired the graph's advisory lock; the token is bound to that graph and transaction session and cannot authorize work on another connection. On PostgreSQL, the lock statement also observes the effective transaction -isolation and binds it to the same token. Match-key convergence therefore -accepts only read committed or serializable based on database state, not the -caller-requested option or the server's assumed default. +isolation and binds it to the same token. A writer that goes on to make a +decision the fence alone protects — match-key convergence, edge acyclicity, a +composition whole's parts closure — additionally advances the graph's row in +`typegraph_fences`, in that statement when the write names such a constraint +up front and in a second statement behind the held lock otherwise, and the +token records that too. Each such decision reads both facts from the token, +not from the caller-requested option or the server's assumed default: read +committed always passes, and repeatable read or serializable pass only when +the fence row was advanced, because that write is what fails a session whose +snapshot predates another decider's commit. A custom backend that takes the +advisory lock but names no fences relation (`tableNames.fences`) is refused at +those two levels. Every other holder of the fence — a history-capturing or +revision-tracking write, a claim-backed constraint, a read — takes the lock +alone and writes no row. See [Write Fence Row](/limitations#write-fence-row) +for the privileges the row needs and how a long-lived snapshot affects it. `GraphBackend.commands` is a required member as of the authoritative command port release. Custom backends must expose `{ session, execute }` and implement @@ -735,8 +747,10 @@ write paths use the command helper, which verifies that any coordination token belongs to the active graph and transaction session and carries a supported effective isolation before executing convergence. The portable PostgreSQL graph-lock path records that isolation automatically. A custom implementation -of `lockSchemaVersionAndGraphWrite` must return the normalized -`GraphCommandIsolation` observed by its combined lock statement. When +of `lockSchemaVersionAndGraphWrite` must advance the graph's fence row exactly +when `params.fenceRow` is `"advance"`, and return the +`GraphWriteFenceAcquisition` its combined statement observed and did: the +normalized isolation, and `fenceRow: "advanced"` only if it wrote the row. When decorating a first-party backend with `deriveBackend`, a same-session `commands` override retains the session identity. A wrapper that changes session or forwards to a different connection is a new command boundary and @@ -1167,6 +1181,70 @@ Externally managed DDL applies the same statements, then advances the marker to monotonic `INSERT ... ON CONFLICT` shown above. Until the indexes exist `listGraphIds` still returns correct pages, by reading and de-duplicating the anchor relations instead of walking them. +### Base-schema version 6: the identity transition log + +Version 6 adds two deployment-wide relations: `typegraph_identity_transitions`, the append-only log +`store.identity.transitionsOf()` and `store.identity.replay()` read, and +`typegraph_identity_transition_retention`, the per-graph watermark `pruneIdentityTransitions()` +advances. Every database gets both, whether or not any graph in it enables identity, the same way +version 2 added `fences`. `createSqlSchema` names them `identityTransitions` and +`identityTransitionRetention`; replace the default names below consistently when the adapter +overrides them. The step only creates relations, so it never rewrites existing rows and finishes in +the time two `CREATE TABLE` statements and three `CREATE INDEX` statements on empty tables take. + +The privileged open adopts it in place: `createStoreWithSchema()`, `createAdapterStoreWithSchema()` +or an explicit `backend.adoptBaseSchema()`. Until one of them has run, a DML-only or zero-DDL +runtime (`createVerifiedStore()`, graph-template attach) refuses with `BaseSchemaMigrationError`, +so finish privileged adoption before those workers start. A plain `createStore()` attaches without +reading the marker; on an identity graph opened with `history: true`, its first write that records +an identity transition then fails with `ConfigurationError` and +`details.code === "IDENTITY_TRANSITION_RELATIONS_MISSING"`, naming both relations. + +Externally managed DDL applies these statements, then advances the marker to 6 with the monotonic +`INSERT ... ON CONFLICT` shown above. For PostgreSQL: + +```sql +CREATE TABLE IF NOT EXISTS "typegraph_identity_transitions" ( + "graph_id" TEXT NOT NULL, + "transition_id" TEXT NOT NULL, + "recorded_revision" BIGINT NOT NULL, + "recorded_at" TIMESTAMPTZ NOT NULL, + "valid_at" TIMESTAMPTZ NOT NULL, + "cause" TEXT NOT NULL, + "class_kind" TEXT NOT NULL, + "class_id" TEXT NOT NULL, + "prior_class_kind" TEXT, + "prior_class_id" TEXT, + "assertion_ids" JSONB NOT NULL, + "decision" JSONB, + "tx_id" TEXT, + "restored_at" TIMESTAMPTZ, + PRIMARY KEY ("graph_id", "transition_id") +); +CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_class_idx" + ON "typegraph_identity_transitions" ("graph_id", "class_kind", "class_id", "recorded_revision"); +CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_revision_idx" + ON "typegraph_identity_transitions" ("graph_id", "recorded_revision"); +CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_prior_class_idx" + ON "typegraph_identity_transitions" ("graph_id", "prior_class_kind", "prior_class_id", "recorded_revision"); +CREATE TABLE IF NOT EXISTS "typegraph_identity_transition_retention" ( + "graph_id" TEXT NOT NULL, + "pruned_before_revision" BIGINT NOT NULL, + "pruned_at" TIMESTAMPTZ NOT NULL, + PRIMARY KEY ("graph_id") +); +``` + +SQLite uses the same statements with `INTEGER` for the two revision columns and `TEXT` for the +timestamp and JSON columns (`recorded_at`, `valid_at`, `restored_at`, `pruned_at`, `assertion_ids`, +`decision`). + +Advancing the marker to 6 is one-way: a library release that knows only version 5 or lower refuses a +database stamped 6 as newer, so roll forward rather than back once any process has adopted it. A +PostgreSQL durable working copy carries its own marker; reopening one provisioned under version 5 +adopts version 6 into the allocation in place (see +[PostgreSQL table-backed working copies](/graph-merge/#postgresql-table-backed-working-copies)). + ## Drizzle-Free Entrypoints TypeGraph keeps its public core and backend contracts independent of Drizzle: @@ -1435,8 +1513,8 @@ no declared unique constraint (a create takes a generated or a caller-supplied id) — except a node delete, which fuses even when the kind DOES carry a declared unique constraint, because the atomic delete program releases that claim in the same statement. A singleton edge create fuses -when the kind's cardinality is `"many"`, and edge update and delete fuse the -same way (`EdgeCollection` has no `upsertById`). So do +when the kind's cardinality AND targetCardinality are both `"many"`, and edge +update and delete fuse the same way (`EdgeCollection` has no `upsertById`). So do `bulkInsert`/`bulkCreate`/`bulkDelete`/`bulkReplaceById`/`bulkUpsertById`, and a constrained write inside an atomic program's claim envelope. Each of these asserts the active schema version inside the statements @@ -1480,9 +1558,10 @@ its callback. See [Limitations](/limitations) for details. For a transactional Cloudflare SQLite store, use **Durable Objects** (below) instead. -For the same reason, a write guarded by a **declared constraint** — edge -cardinality other than `many`, a `disjointWith` axiom, a shared-scope unique, or -dynamic `getOrCreateByEndpoints` convergence — is refused on D1 with +For the same reason, a write guarded by a **declared constraint** — source or +target edge cardinality other than `many`, a `disjointWith` axiom, a +shared-scope unique, or dynamic `getOrCreateByEndpoints` convergence — is +refused on D1 with `CONSTRAINT_WRITE_FENCE_UNSUPPORTED` rather than committed unfenced. See [Declared constraints require an interactive transaction](#declared-constraints-require-an-interactive-transaction). @@ -1664,6 +1743,16 @@ its conflict propagates unchanged to the outermost store-owned write, or to `store.transaction` itself. This tier therefore changes behavior only for a transaction that opens its own top-level connection. +An `"interactive"` backend whose write fence is a keyed acquisition (the +bundled PostgreSQL backends) replays the same way where the per-graph write +fence can refuse a stale snapshot, which is where a lock-only decision +advances the graph's fence row: a store-owned write that names a constraint, a +`bulkUpsertById` or `bulkGetOrCreateByConstraint` (the collection opens their +transaction), and a merge commit are each replayed whole, up to the same three +attempts. A write that names no constraint decides nothing under the fence and +takes one attempt, as before. SQLite takes no keyed acquisition and replays +nothing. + An `"optimistic-retry"` backend requires `node:async_hooks`' `AsyncLocalStorage` to detect a retried unit nested inside another one; on a runtime where it is unavailable, the first retried unit `runRetriedUnit` opens is refused with @@ -1889,8 +1978,17 @@ when `drain: "table-lock"`) plus the two composable, no-`SELECT` forms `advisory schema-write-fence discussion above) — the complete `FenceSql` bag. `resolveWriteFencePlan`'s `lock` arm derives the standalone-statement forms every ordinary lock site actually calls — `acquireKeyed`; `acquireKeyedWithIsolation` (the lock plus the session's isolation-level fact, read in the same -statement it locks in); and `isolationFact` — from those two expressions, so a backend author never -spells both forms separately. `mechanism: "row"` needs no `advisoryLockExpression` at all: TypeGraph +statement it locks in); `acquireKeyedSnapshotFence` (the per-graph write fence of a writer that makes a lock-only decision: +the lock, the isolation fact, and an advance of the key's row in the fences relation, in one +statement); and `isolationFact` — from those two expressions, so a backend author never spells both +forms separately. The fence-row advance is what lets a `REPEATABLE READ` or `SERIALIZABLE` session +make a decision the fence alone protects: a lock cannot refresh a snapshot taken before the wait, +but a session whose snapshot predates another decider's commit cannot update a row that decider +advanced, and fails on it with a serialization failure. Only deciders issue it; every other holder +of the fence issues `acquireKeyedWithIsolation` and writes no row. An `"advisory"` backend that +names no fences relation (`tableNames.fences`) has no `acquireKeyedSnapshotFence` at all, its +deciders take the lock-only statement, and TypeGraph refuses those two +isolation levels on it wherever such a decision is made. `mechanism: "row"` needs no `advisoryLockExpression` at all: TypeGraph spells its own `acquireKeyed` / `acquireKeyedWithIsolation` against the fences relation (see below), and a `fenceSql.isolationFactExpression` — when supplied — rides the SAME acquisition statement, so a `"row"` target's isolation fact is read on the exact connection that took the row. The bundled @@ -2283,7 +2381,9 @@ class needed the fence, because the way forward differs per class: | `details.constraint` | The write that needs the fence | Way forward without a transactional backend | | --- | --- | --- | -| `edgeCardinality` | Creating or resurrecting an edge whose `cardinality` is `one`, `unique`, or `oneActive` | Declare the edge `cardinality: "many"` and enforce the limit in application code | +| `edgeAcyclicity` | Creating, bulk-creating, or resurrecting an edge whose kind declares `acyclic: true` | Drop `acyclic: true` from the edge and detect cycles in application code | +| `edgeCardinality` | Creating or resurrecting an edge whose `cardinality` is `one`, `unique`, or `oneActive`, or whose `targetCardinality` is `one` or `oneActive` | Declare `cardinality: "many"` and `targetCardinality: "many"` (or omit both) and enforce the limit in application code | +| `edgeComposition` | Creating or resurrecting an edge that realizes a declared `partOf`/`hasPart` pair | Drop the `partOf`/`hasPart` declaration realized by this edge kind and enforce the single-whole rule in application code | | `edgeMatchKeyConvergence` | `getOrCreateByEndpoints` using an undeclared dynamic `matchOn` key | Declare the edge registration's durable `matchIdentity`, or use `create` with a caller-chosen id | | `nodeDisjointness` | Creating a node under a kind that participates in a `disjointWith` axiom | Drop the axiom and keep ids distinct across those kinds yourself | | `nodeUniquenessClaim` | **Updating or resurrecting** a node whose kind declares any unique constraint, of any scope — a transition reserves the new key *before* the row write it gates, and only a transaction can undo the pair together | Drop the constraint, or run updates on a transactional backend. Plain **creates** under a `scope: "kind"` unique are unaffected: their claim follows the row | @@ -2291,16 +2391,31 @@ class needed the fence, because the way forward differs per class: `importGraph` / `importGraphStream` is refused on the same backends whenever any node kind of the graph owes a claim ahead of its row — that is, declares **any** -unique constraint or has a disjoint partner — or any edge kind is non-`many`. +unique constraint or has a disjoint partner — or any edge kind's `cardinality` +or `targetCardinality` is non-`many`. The import writes both creates and updates, so the widest of those placements is -what decides it. +what decides it. A graph declaring an `acyclic: true` edge kind — or any +`partOf`/`hasPart` pair, whose composition relation is checked with the same +exhaustive reachability probe — refuses the same way even when no other +constraint applies: acyclicity has no claim row to substitute for the +per-graph lock import otherwise takes none of, so the whole import is refused +with `details.constraint: "edgeAcyclicity"` before the first chunk. (A +composition edge kind is separately refused with `edgeComposition` first, by +the ordinary claim-backed path above — the composition claim itself needs +only an interactive transaction, not the lock-only fence; it is the +*acyclicity* check over the composition relation that is lock-only.) On a +transactional backend, import instead takes the per-graph lock for the +duration of each chunk it processes — a real change to import's concurrency +posture for such a graph, so every other writer of that graph blocks for the +chunk's duration. This affects **Cloudflare D1**, **`drizzle-orm/neon-http`**, and any SQLite backend built with `transactionMode: "none"`. Durable Objects are unaffected — `do-sqlite` reports `capabilities.execution.interactiveTransactions: true` and fences normally. Unconstrained writes on those backends are untouched and keep working exactly as -before: a `cardinality: "many"` edge created, updated and deleted; any node +before: an edge with `cardinality: "many"` and `targetCardinality: "many"` +created, updated and deleted; any node delete, including one whose kind participates in a disjointness axiom (a delete re-derives no cross-kind verdict); a node whose uniques are all `scope: "kind"`; and an undeclared `getOrCreateByEndpoints` that *finds* an existing edge in the default @@ -2316,8 +2431,8 @@ the logical `"found"` outcome in one exchange but may take incumbent-row locks and produce write amplification. If any member may write, the whole batch retains that refusal on transactionless roots unless it matches the narrow native durable-convergence envelope: schema-declared -`matchIdentity`, `cardinality: "many"`, declared match fields, default -`ifExists: "return"`, and no temporal mutation. That eligible form is one +`matchIdentity`, `cardinality: "many"` and `targetCardinality: "many"`, +declared match fields, default `ifExists: "return"`, and no temporal mutation. That eligible form is one closed atomic exchange; dynamic match fields, update mode, constrained cardinality, temporal options, and all transaction-scoped or derived roots retain the refusal or fallback path required by their contracts. @@ -2331,7 +2446,10 @@ transaction-capable backend when schema-aware resurrection is required. Underneath the lock, a declared constraint is also reserved in a **claim relation** whose primary key admits one live claimant per axis: `uniques` (for uniqueness scopes and `disjointWith` pairs) and `typegraph_edge_claims` (for -`cardinality: "one" | "unique" | "oneActive"`). Both bundled backends carry them +source-side `cardinality: "one" | "unique" | "oneActive"` and, independently, +[target-side `targetCardinality: "one" | "oneActive"`](/core-concepts#target-cardinality) +— an edge kind declaring both axes reserves one row per axis, keyed apart by +the axis name so the two never collide). Both bundled backends carry them and report `capabilities.constraintClaims: true`. The claim is what makes those constraints hold for TypeGraph writers that hold no per-graph lock at all — `importGraph` is the one in the box. The protocol is application-maintained: @@ -2363,18 +2481,81 @@ Three properties of that mechanism are worth knowing before you rely on it: ```typescript for (const violation of await store.verifyConstraintFences()) { - // violation.target names the claim row two claimants contend for - console.warn(violation.family, violation.target.axis, violation.target.key); + switch (violation.family) { + case "nodeUniqueness": + case "nodeDisjointness": + case "edgeCardinality": + case "composition": { + // violation.target names the claim row two claimants contend for + console.warn(violation.family, violation.target.axis, violation.target.key); + break; + } + default: { + // These families have no claim row, so no `target` to read. + console.warn(violation.family); + } + } } ``` -It reports one entry per contended axis — `nodeUniqueness` and -`nodeDisjointness` carry the conflicting `owners` (each a `concrete_kind` / -`node_id` pair, because ids are unique only per kind), `edgeCardinality` carries -the conflicting `edgeIds`. It reads the nodes, edges and `uniques` relations, so -it finds violations that predate the claim tables; it writes nothing, and it -repairs nothing — choosing which claimant keeps the axis is a data-loss decision -that stays with you. +`target` exists only on the families that are a contended claim row. Narrow on +`violation.family` before reading it: four of the eight members carry none. +The audit reports these families: + +| `family` | Reports | Carries | +| --- | --- | --- | +| `nodeUniqueness` | a uniqueness axis held by more than one live node | `target`, `owners` (each a `concrete_kind` / `node_id` pair, because ids are unique only per kind) | +| `nodeDisjointness` | a `disjointWith` axis held by more than one live node | `target`, `owners` | +| `edgeCardinality` | a source- or target-side cardinality axis held by more than one live edge | `target`, `edgeIds` | +| `composition` | a part holding more than one live whole across every declared composition relation | `target`, `edgeIds` | +| `compositionExistence` | live nodes of an `existence: "required"` part kind with no live whole | `partKind`, `parts` (no `target`) | +| `compositionFutureWindow` | non-deleted rows of a composition realizing edge kind whose `validFrom` or `validTo` is later than now | `edgeKind`, `edgeIds` (no `target`) | +| `edgeEndpointAssignability` | edge kinds whose live rows sit outside every endpoint pair the current ontology admits | `edgeKind`, `allowedPairs`, `edges` (no `target`) | +| `edgeAcyclicity` | live edges of an `acyclic: true` relation whose `to` reaches their `from` | `relation`, `edgeIds` (no `target`) | + +The audit reads the nodes, edges and `uniques` relations, so it finds +violations that predate the claim tables; it writes nothing, and it repairs +nothing — choosing which claimant keeps the axis is a data-loss decision that +stays with you. `edgeEndpointAssignability` is the same shrink an +[ontology tightening](/schema-evolution#ontology-tightenings-are-checked-against-your-data) +checks against, but reported for a whole graph rather than just the delta one +schema commit proposes. + +`compositionExistence` reports every LIVE node of an `existence: "required"` +composition part kind with no live whole. No live whole means either of two +states: the part has no current composition edge at all, or the whole its edge +names is not a live row. The second is why a part left hanging from a +TOMBSTONED whole is reported — a state the delete cascade cannot produce, but a +direct backend write, a custom port, or a bypassed import can. The write path's +own incumbent decision is deliberately narrower: a tombstoned whole still holds +its part's attachment there, so a second whole cannot quietly take it. Unlike +the families a backend answers, this one is a portable scan (`findNodesByKind` paged, +each page's attachments resolved through the same owner the write-path detach +refusal reads, and each page's wholes read once per kind through the batch +point read) rather than a `readConstraintFenceViolations` backend member — this runs only at an +explicit diagnostic call and at schema-tightening-commit time, never on a +write's hot path, so there is no per-dialect SQL for a custom backend to +implement for this family. + +`compositionFutureWindow` reports every non-deleted row of a realizing edge +kind whose window starts or ends later than the audit's clock. No composition +edge write may state such a window, and every composition membership decision +relies on that: an open-ended row is a member and an ended one is history. +A row reported here was written before its edge kind realized a pair, or +beneath the store. It is the same kind of portable scan as +`compositionExistence` (`findEdgesByKind` paged, each row judged by the +comparison the write path uses), so a custom backend has nothing to implement +for it either. + +A custom backend implementing `readConstraintFenceViolations` may receive an +`edgeEndpointAllowances` declaration (one entry per edge kind, each the +concrete `(fromKind, toKind)` pairs the ontology still admits) and must answer +with a `misassignedEdgeEndpointRows` array — present (even if empty) whenever +`edgeEndpointAllowances` was non-empty. Leaving the key `undefined` when the +family was requested is refused with `CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED` +rather than tolerated as a clean result: an empty report there would be +indistinguishable from a database with no violations, which is the one answer +this diagnostic must never fabricate. ### Edge endpoints and concurrent node deletes @@ -2425,7 +2606,9 @@ Because both sides lock in one order, a single-edge write and a bulk delete of one kind that covers both its endpoints wait on each other instead of deadlocking. A deadlock is still possible when a transaction deletes both endpoints of an edge being created concurrently through several statements -in another order (one endpoint, then the other), when `store.clear()` deletes +in another order (one endpoint, then the other), when a composition whole's +delete locks the whole and then its parts while an edge write locks the same +nodes in `(kind, id)` order, when `store.clear()` deletes a graph's nodes while an edge write holds one endpoint and waits on the other, or when the edge write locks many endpoints at once: a transaction that creates several edges, or one closed batch edge program, whose rows lock endpoints row @@ -2433,6 +2616,8 @@ by row. PostgreSQL then aborts one of the two. Inside `store.transaction()` the abort surfaces as `TransactionConflictError`, retryable with `store.transaction(fn, { retry })`; a direct store call surfaces the driver's deadlock error (SQLSTATE `40P01`) unchanged, as it does any other conflict. +See [Limitations](/limitations#deadlocks-between-deletes-and-edge-writes-postgresql) +for the full list and how to retry. Two writes skip the check by design: `importGraph` with `validateReferences: false` inserts edges without reading their endpoints, @@ -2465,18 +2650,20 @@ TypeGraph choosing separate query semantics per backend: | Per-query fulltext `language` override | ✗ | ✓ | Throws on SQLite — FTS5's tokenizer is fixed at table-create time; `tsvector` accepts a regconfig per query | | HNSW `efSearch` query tuning | ✗ | ✓ transactional HNSW drivers | Refused, never ignored: `UnsupportedBackendCapabilityError` with `details.capability` `vector.searchFrontierTuning` on **any** SQLite backend (vector and hybrid alike — neither `sqlite-vec`'s `vec0` KNN nor `libsql-native`'s DiskANN has a per-search frontier), and on transaction-less Postgres or a non-HNSW slot | | Bounded planner-statistics sampling | ✓ standard connections / ✗ D1 and Durable Objects | Native `ANALYZE` sampling | Restricted SQLite skips `analysis_limit` but still attempts scoped `ANALYZE`. Performance only — same results | -| TypeGraph Identity Profile | ✓ transactional drivers | ✓ transactional drivers | Enabled graphs fail fast on non-atomic drivers; identity-disabled graphs retain their ordinary path | -| Constraint claim relations (`capabilities.constraintClaims`) | ✓ | ✓ | Identical relations and identical statements on both dialects. A third-party backend that omits them declares `constraintClaims` absent and keeps the per-graph lock as its only fence | +| TypeGraph Identity Profile | ✓ transactional drivers | ✓ transactional drivers | Enabled graphs fail fast on non-atomic drivers; identity-disabled graphs retain their ordinary path. Replay (`store.identity.replay` / `transitionsOf`) additionally requires `history: true` — refuses with `IDENTITY_REPLAY_REQUIRES_HISTORY` otherwise, on both dialects | +| Constraint claim relations (`capabilities.constraintClaims`) | ✓ | ✓ | Identical relations and identical statements on both dialects. A third-party backend that omits them declares `constraintClaims` absent and keeps the per-graph lock as its only fence; under that lock every constrained write is probed before it lands, including "one whole per part" across every realizing edge kind | +| Composition claim (`partOf`/`hasPart`) | ✓ | ✓ | One reserved axis in `typegraph_edge_claims`, identical on both dialects — no new relation or statement shape. Needs an interactive transaction the same way any other declared-cardinality write does (`CONSTRAINT_WRITE_FENCE_UNSUPPORTED`, `edgeComposition`), and needs `typegraph_edge_claims` to already exist: a deployment initialized before it did raises `EDGE_CLAIM_RELATION_MISSING` on the first `partOf`/`hasPart` write and must be migrated under owner credentials first | | Durable edge match identity (`capabilities.durableEdgeMatchIdentity`) | ✓ bundled adapters | ✓ bundled adapters | Both dialects persist the same canonical key and use a unique database arbiter. A custom backend must satisfy the full capability contract above or leave the capability absent | | Managed node projection fusion | ✓ registered atomic bulk programs; singleton fallback | ✓ registered atomic bulk programs; singleton create fusion | Eligible node bulk creates and resolved updates group fulltext/vector transitions into the same atomic program as their row mutations on both dialects. PostgreSQL additionally fuses an eligible singleton generated-ID create into one SQL statement when every active strategy supplies an inserted-node builder | | Managed node claim fusion (`capabilities.atomicNodeInsertClaims`) | ✗ portable transactional fallback | ✓ PostgreSQL/PGlite | SQLite keeps claim acquisition and insertion in the portable transaction. PostgreSQL transaction receivers fuse supported claim plans; a root non-transactional receiver is limited to exactly one generated-id, same-kind uniqueness claim with no other side effects | -| Managed edge cardinality fusion | ✗ portable transactional fallback | ✓ PostgreSQL/PGlite transaction receivers | SQLite keeps its guarded claim and edge insert in the portable transaction. PostgreSQL can combine endpoint liveness, one cardinality claim, and the insert in one statement after any required graph lock | +| Managed edge cardinality fusion | ✗ portable transactional fallback | ✓ PostgreSQL/PGlite transaction receivers | SQLite keeps its guarded claim and edge insert in the portable transaction. PostgreSQL can combine endpoint liveness, exactly one cardinality claim (source *or* target), and the insert in one statement after any required graph lock. An edge kind constraining **both** axes always takes the portable guarded-claim path on every engine — the fused command carries at most one claim per write in this version | | Atomic SQL transport (`capabilities.execution.atomicBatch`) | ✓ on certified D1/libSQL roots; otherwise `none` | ✓ on bundled recognized PostgreSQL drivers, including neon-http | `root` means the exact backend owns the atomic boundary; `session` means the exact object is already bound to an open transaction and the outer transaction owns commit/rollback. Both require identity-keyed executor registration. Neon HTTP uses its native transaction batch; session-capable `pg`, postgres-js, neon-serverless, and PGlite drivers can execute programs on one pinned Drizzle transaction. Unrecognized drivers remain `none`. A custom backend must pass the framework-agnostic conformance runner before opting in; omitted support keeps the portable path | | Eligible registered managed writes | ✓ bundled SQLite roots, including D1 and libSQL | ✓ bundled PostgreSQL roots, including neon-http | Eligible singleton generated-ID nodes and `cardinality: "many"` edges use one authoritative create statement. Eligible node updates may carry fulltext/vector replacements; unconstrained non-durable-identity edge updates, direct edge deletes, and plain restricted node deletes use one authoritative read/gate plus one registered atomic mutation. Generated-, caller-, or mixed-ID node `bulkInsert`/`bulkCreate` batches compose supported multi-claim/cross-scope claim sets with projections in one schema-fenced native program; direct edge programs also maintain durable match identity and cardinality claims. Direct edge `bulkDelete` and plain restricted node `bulkDelete` use the same mutation profile. Eligible mixed `bulkUpsertById` sets, including node projections, use the profile on serverless roots and on exact bundled PostgreSQL transaction sessions; a generic derived backend still loses the evidence. A custom backend may opt in per family only after registering its exact transport and semantic executor. Unregistered or otherwise ineligible families, projected/identity-enabled node deletes, over-budget claimed members, cascade/disconnect deletes, and other managed writes retain the existing path | | Edge write racing a delete of its endpoint | ✓ writers serialize on the writer slot | ✓ for READ COMMITTED deletes; ✗ for a REPEATABLE READ / SERIALIZABLE delete whose snapshot predates the edge's commit, unless both writers run at SERIALIZABLE | The open case commits a tombstone under the edge — see [Edge endpoints and concurrent node deletes](#edge-endpoints-and-concurrent-node-deletes) | | Typed constraint error above READ COMMITTED | n/a (no such isolation mode) | ✗ at `REPEATABLE READ` / `SERIALIZABLE` | PostgreSQL raises `40001` from the claim's upsert instead of resolving the conflict, so the loser retries a serialization failure rather than reading `UniquenessError` | | Claim row lock released before end of transaction | ✗ | ✗ | Held to commit/rollback on both dialects, refusal included — a caller that catches a constraint error blocks other writers of that axis for the rest of its transaction | | Recursive traversal (`capabilities.recursiveTraversal`) | ✓ | ✓ | Identical on both bundled backends. A third-party backend declaring `{ supported: false, reason }` refuses the five recursion-dependent operations with `ConfigurationError` code `RECURSIVE_TRAVERSAL_UNSUPPORTED`; `weightedShortestPath` degrades to a predecessor walk instead — see above. Unweighted `shortestPath` is unaffected — it never emits a recursive CTE | +| Edge acyclicity probe (`acyclic: true`) | ✓ index-only scan of `typegraph_edges_from_idx` | ✓ index-only scan of `typegraph_edges_from_idx` | Both engines enforce the check identically — the same recursive-reachability builder, `UNION` set semantics, and no depth bound. Both refuse without transactions (`CONSTRAINT_WRITE_FENCE_UNSUPPORTED`, `edgeAcyclicity`) and both raise `RECURSIVE_TRAVERSAL_UNSUPPORTED` if `recursiveTraversal` is declared unsupported. No new system index is required | | Write fence (`capabilities.writeFence`) | ✓ `engine-serialized` (single writer slot) | ✓ `lock` (advisory + table locks) | Identical guarantee, different mechanism. A custom backend that declares no `writeFence` resolves `unfenced` and is refused at construction for Operational Identity or TypeGraph-owned recorded-clock allocation | | Capability bundles (`CAPABILITY_BUNDLES`) | Identical | Identical | Both bundled backends implement every pilot bundle's core/extra members on both dialects it scopes to. A third-party backend with a port gap refuses (gated core, or a `refuse`-disposition extra) or degrades (a `fallback`-disposition extra) per that bundle's own registry row | | Engine-native lineage (`backend.lineage`) | ✗ (recorded-relations lineage via `history`) | ✗ (recorded-relations lineage via `history`) | Neither bundled profile declares its own `lineage`. `resolveLineage` derives it from the store's recorded relations whenever `history: true` is on, identically on both dialects, so a graph-merge diff against such a store is pruned the same way regardless of backend. Without `history`, no lineage source resolves and the diff is full; the anchor is the durable revision anchor when `revisionTracking: true`, otherwise the compatibility content fingerprint | diff --git a/apps/docs/src/content/docs/blog/store-analysis-and-guarded-writes/index.mdx b/apps/docs/src/content/docs/blog/store-analysis-and-guarded-writes/index.mdx index 3a5e3bba7..e30ed769a 100644 --- a/apps/docs/src/content/docs/blog/store-analysis-and-guarded-writes/index.mdx +++ b/apps/docs/src/content/docs/blog/store-analysis-and-guarded-writes/index.mdx @@ -207,7 +207,7 @@ and edges, stages it on a throwaway copy, and hands back an ordinary merge plan without ever writing to the target. ```typescript -const plan = unwrap( +const { plan } = unwrap( await planCandidateWriteSet({ target: store, makeBackend, diff --git a/apps/docs/src/content/docs/core-concepts.md b/apps/docs/src/content/docs/core-concepts.md index a8f27a921..8c6c9d551 100644 --- a/apps/docs/src/content/docs/core-concepts.md +++ b/apps/docs/src/content/docs/core-concepts.md @@ -17,7 +17,7 @@ TypeGraph's power comes from its type system. Define your schema once with Zod, - [Node Operations](#node-operations) - [Edges](#edges) — Relationships between nodes - [Defining Edge Types](#defining-edge-types) (domain/range constraints) - - [Edge Constraints](#edge-constraints) (cardinality) + - [Edge Constraints](#edge-constraints) (cardinality, acyclicity) - [Edge Operations](#edge-operations) - [Graph Definition](#graph-definition) — Combining nodes, edges, and ontology - [Delete Behaviors](#delete-behaviors) — Restrict, cascade, disconnect @@ -355,7 +355,7 @@ Typed collection writes preserve the source/target relationship; dynamic writes and imports enforce it at runtime. Bulk writes reject invalid pairs atomically. Import pair validation remains active even when reference validation is disabled; imports retain their own documented error-handling and partial-success behavior. -See [collection types](/types#typededgecollectionr) for inference limits, +See [collection types](/types#typededgecollectionr-g) for inference limits, [graph extensions](/graph-extensions#edges) for runtime declarations, and [schema management](/schema-management#endpoint-pair-changes) for schema changes. @@ -413,6 +413,184 @@ await store.edges.currentEmployer.create(alice, other, {}); // Throws Cardinalit The check queries existing edges and throws `CardinalityError` if violated. For `oneActive`, only edges with `validTo` unset count toward the limit. +#### Target cardinality + +`targetCardinality` bounds the edges that point **at** one target node, +independently of `cardinality`, which bounds the edges leaving one source: + +```typescript +const graph = defineGraph({ + edges: { + // Many-to-one ownership: a source may point at many targets, but each + // target may be pointed at by at most one live edge. + assignedTo: { + type: assignedTo, + from: [Person], + to: [Ticket], + targetCardinality: "one", + }, + + // At most one ACTIVE edge (valid_to IS NULL) into any target. + currentOwner: { + type: currentOwner, + from: [Person], + to: [Asset], + targetCardinality: "oneActive", + }, + }, +}); +``` + +| Target cardinality | Description | +|---------------------|-------------| +| `"many"` | No limit (default) | +| `"one"` | At most one edge of this type into any target node | +| `"oneActive"` | At most one edge with `valid_to IS NULL` into any target | + +There is no `"unique"` target cardinality: pair uniqueness is a property of +the `(source, target)` pair, and `cardinality: "unique"` already declares it +from the source side — a second pair declaration on the target side would be +the same axis stated twice. + +`cardinality` and `targetCardinality` compose freely. All twelve +combinations are accepted; one is *redundant* (`unique` already implies the +pair bound that `targetCardinality: "one"` would separately declare) and is +still honored, because refusing an author's explicit, true statement would be +surprising: + +| `cardinality` \ `targetCardinality` | `many` (default) | `one` | `oneActive` | +| --- | --- | --- | --- | +| `many` (default) | unconstrained multigraph | many-to-one: a target has at most one incoming edge ever | many-to-one over open-ended edges only | +| `one` | one outgoing per source (as above) | strict 1:1 over the live population | 1:1 where the target bound counts open-ended edges only | +| `unique` | at most one edge per `(from, to)` pair (as above) | *redundant*: target `"one"` already implies pair uniqueness | pair uniqueness plus an active-only target bound | +| `oneActive` | one open-ended outgoing per source (as above) | one open-ended out, at most one incoming ever | strict 1:1 over the open-ended population | + +Both axes are limits on **edge count**, not on distinct neighbours: a second +distinct edge from the same source to an already-`targetCardinality: "one"` +target is refused exactly like a second edge from a different source would +be. The reservation is scoped to `(graph, edge kind, target kind, target +id)` — the source kind is deliberately not part of the key, so two different +source kinds contend for one target allowance, and two nodes with the same +id under different target kinds or edge kinds never collide. + +The temporal rules mirror the source-side ones, read from the target end: +`"one"` counts every non-deleted edge at the target (including edges with a +stated validity end); `"oneActive"` counts only those whose `validTo` is +unset. Soft-deleting an edge frees the slot it held on both axes; ending an +edge (setting `validTo`) frees only an active-only (`"oneActive"`) slot, +never a `"one"` slot; a resurrection or a reopened validity window +reacquires every applicable reservation, on both axes. + +`CardinalityErrorDetails` names which endpoint's population was overrun via +`direction: "source" | "target"`, alongside `toKind` / `toId` for the target +endpoint (`fromKind` / `fromId` keep their existing meaning). + +#### Acyclicity + +Declaring `acyclic: true` makes an edge kind's live relation a DAG (directed +acyclic graph): no write can create a path from an edge's `to` endpoint back +to its `from` endpoint, and a self-loop is a cycle of length one. + +```typescript +const graph = defineGraph({ + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + }, +}); + +await store.edges.dependsOn.create(a, b, {}); // OK +await store.edges.dependsOn.create(b, c, {}); // OK +await store.edges.dependsOn.create(c, a, {}); // Throws EdgeAcyclicityError +``` + +`acyclic` is orthogonal to `cardinality`: `cardinality: "many", acyclic: true` +(a dependency graph, where any number of edges may point at or from a task) is +the common case. Every write path that can put an edge into the relation +enforces it: `create`, `bulkCreate`, `getOrCreateByEndpoints`, resurrecting a +soft-deleted edge, validating import, and merge apply after canonicalization. + +**Population.** Every non-deleted edge counts, regardless of its validity +window. Soft-deleting an edge frees the relation — `a -> b` then `b -> a` is +accepted once the first edge is deleted. Ending an edge's validity window does +**not** free it: `a -> b` with its window closed still blocks `b -> a`. This +is the one place the two temporal axes disagree: acyclicity is a claim about +the relation's *shape*, not about an instant, so a future-dated edge could +otherwise close a cycle no write ever probed. + +**The check is exhaustive**, not a bounded traversal: it is a set-semantics +recursive reachability query with no depth bound, so a cycle of any length is +found — the query builder's `MAX_EXPLICIT_RECURSIVE_DEPTH` does not apply +here. An engine that cuts the search short (a statement timeout, a resource +limit) raises `EdgeAcyclicityIndeterminateError` rather than reporting "no +cycle". + +**Concurrency.** The check and the write it guards commit under the same +per-graph write fence edge cardinality uses, so two concurrent writers of +`a -> b` and `b -> a` serialize and exactly one commits. A backend with no +transactions refuses the write (`CONSTRAINT_WRITE_FENCE_UNSUPPORTED`) rather +than enforcing the rule only when nothing races. + +On PostgreSQL the check reads the relation after taking the fence, so the +session must see what committed while it waited. A `READ COMMITTED` session +does: every statement takes a new snapshot. A `REPEATABLE READ` or +`SERIALIZABLE` session keeps the snapshot of its first statement however long +it then waits, and PostgreSQL arbitrates `SERIALIZABLE` transactions only +against each other, so a lock alone would let such a session and a +`READ COMMITTED` writer close a cycle between them. A write that checks an +acyclic relation therefore also advances the graph's row in +`typegraph_fences` behind the lock, at every isolation level: a session whose +snapshot predates another such writer's commit fails on that row with a +serialization failure (SQLSTATE `40001`) instead of checking a relation it can +no longer see. The guarantee above holds at every isolation level, and between +writers at different levels. + +Who retries depends on who owns the transaction. A collection write, an import +or a merge commit that TypeGraph opened the transaction for is replayed whole, +in a new transaction, and then sees the winner. Inside +`store.transaction(...)` or an adopted transaction, the failure surfaces as +`TransactionConflictError`; re-run the whole transaction, or pass +`store.transaction(fn, { retry: { attempts } })`. + +Only the writes that make such a decision touch that row: a write into an +acyclic or composition relation, a delete of a composition whole, and a +match-key convergence. Every other write, on any graph, leaves it alone, so a +graph that declares neither kind of relation and never converges on a match +key never writes it. Where it is written it is one row per graph and behaves +like any hot row under MVCC: see +[Write Fence Row](/limitations#write-fence-row) for how a long-lived snapshot +affects it. + +A custom backend whose write fence is an advisory lock with no fences relation +(`tableNames.fences`) cannot detect a stale snapshot. There a +`REPEATABLE READ` or `SERIALIZABLE` write is refused with `ConfigurationError` +(`EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT`) in every frame of the transaction, +batches and concurrent sibling writes included; `READ COMMITTED` is the level +it supports. + +Use `store.verifyConstraintFences()` to find edges already on a cycle (the +`edgeAcyclicity` family), and see [Errors](/errors) for +`EdgeAcyclicityError` / `EdgeAcyclicityIndeterminateError` and +[Limitations](/limitations) for what a transactionless backend and a fused +write program cannot do with an acyclic edge kind. + +Cardinality and target cardinality constrain one edge kind's own population; +they say nothing about what happens to the nodes on either end when the +relation is a real whole/part relationship. That's a separate declaration, +[**composition**](/ontology#composition) (`partOf` / `hasPart`): it names +which edge kind *realizes* containment and requires that edge to declare +`cardinality`/`targetCardinality: "one"` or `"oneActive"` on the whole side. +Ownership (one whole per part, enforced cross-relation) and cascade (deleting +a whole deletes its parts) are the guarantees this declaration is *for*, and +both are enforced — see the +[containment tiers](/ontology#choosing-a-containment-tier) table for the +complete list. + ### Edge Operations ```typescript @@ -474,6 +652,15 @@ const graph = defineGraph({ }); ``` +`subClassOf(Company, Organization)` here requires `Company`'s schema to +structurally extend `Organization`'s — checked at compile time and at +registry build (see [Ontology](/ontology#subsumption-type-inheritance)). +Because of that guarantee, querying `Organization` returns `Company` rows +by default: `store.query().from("Organization", "o")` is polymorphic, with +`o`'s `kind` widened to `string` and only `Organization`'s own properties +statically typed on the alias. Pass `{ expansion: "exact" }` to get +back the exact-kind reading. + ## Delete Behaviors Control what happens when nodes are deleted: @@ -567,6 +754,11 @@ const graph = defineGraph({ - `"kind"`: Unique within this exact type only - `"kindWithSubClasses"`: Unique across this type and all subclasses +TypeScript requires both `scope` and `collation`. An untyped caller that omits +`scope` gets `"kindWithSubClasses"`, and one that omits `collation` gets +`"binary"`. A [graph extension](/graph-extensions) that omits `scope` gets +`"kind"`. + ### Collation Options - `"binary"`: Case-sensitive comparison diff --git a/apps/docs/src/content/docs/data-sync.md b/apps/docs/src/content/docs/data-sync.md index f0379ee41..33e2096a6 100644 --- a/apps/docs/src/content/docs/data-sync.md +++ b/apps/docs/src/content/docs/data-sync.md @@ -47,6 +47,14 @@ await store.nodes.Document.bulkUpsertById(items); await store.nodes.Document.bulkDelete(ids); ``` +Called on the store, a bulk method owns its transaction: a refused item rolls +the whole call back. Called on `tx` inside `store.transaction(...)` it runs on +your transaction, which has no savepoint, so it decides every item before its +first write and a refusal you catch there leaves none of the batch written — +unless a concurrent writer raced the batch between its reads and its writes. +See +[A bulk refusal caught inside `store.transaction`](/limitations#a-bulk-refusal-caught-inside-storetransaction-can-keep-earlier-rows). + ### upsertById Creates a node if it doesn't exist, or updates it if it does. This includes diff --git a/apps/docs/src/content/docs/ejecting.md b/apps/docs/src/content/docs/ejecting.md index f33a8d038..58e158b6a 100644 --- a/apps/docs/src/content/docs/ejecting.md +++ b/apps/docs/src/content/docs/ejecting.md @@ -169,7 +169,7 @@ code. The database stores raw data without semantic constraints. After ejecting: -- You lose automatic subclass queries (`includeSubClasses`) +- You lose automatic subclass queries (the `expansion` option) - You lose edge validation (ensuring valid from/to kinds) - You keep all your data exactly as stored diff --git a/apps/docs/src/content/docs/errors.md b/apps/docs/src/content/docs/errors.md index c293781b2..d9cdbcbb7 100644 --- a/apps/docs/src/content/docs/errors.md +++ b/apps/docs/src/content/docs/errors.md @@ -316,18 +316,83 @@ deletion bounds. Future or inverted identity windows are user-category input errors. A second non-identical open window and an endpoint-window conflict are constraint-category errors. Both classes are package-root exports. +### `IdentityReplayError` + +Thrown by `store.identity.replay` / `transitionsOf` (and by +`pruneIdentityTransitions`'s own history precondition) when the transition log +cannot answer a request. Its code names the reason: + +- `IDENTITY_REPLAY_REQUIRES_HISTORY` — the store was opened without + `history: true`; there is no transition log to annotate reads with. Open + with `createStore(graph, backend, { history: true })`. Also thrown by + `importGraph`/`importGraphStream` when an archival document's `transitions` + section targets a history-off store: without `history: true` those rows + could never be read back through `transitionsOf` / `replay` either, so the + import refuses rather than writing them write-only. +- `IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED` — the backend owns recorded + time itself (it declares `recordedTime`), so TypeGraph performs no recorded + capture and keeps no transition log for it, with or without + `history: true`. Raised by the same operations as + `IDENTITY_REPLAY_REQUIRES_HISTORY`, including an archival-transitions + import. +- `IDENTITY_REPLAY_HISTORY_TRUNCATED` — the requested range lies entirely + below the graph's retention watermark (see + [`pruneIdentityTransitions`](/identity/#retention)); `details.prunedBefore` + names the watermark. +- `IDENTITY_REPLAY_WALK_INCOMPLETE` — the lineage walk keyset-pages through + every matching row with no per-round limit, so ordinary lineages — however + many rows they hold — never hit this refusal; it fires only when a single + class lineage's transition rows exceed an internal total safety ceiling + (`details.ceiling`), which is a backstop against a pathologically large or + corrupted log, not a cap on legitimate history. Its only remedy is + destructive: prune older history with `pruneIdentityTransitions`. + Narrowing `fromRecorded`/`toRecorded` does not help: lineage discovery + reads the whole log deliberately, so that a requested window can never + hide the notes that name a class. +- `IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK` — `pruneIdentityTransitions` was + given a `beforeRecorded` beyond every revision the graph holds: its next + commit's revision, or one past the highest retained transition when an + archival restore carried in higher revisions + (`details.highestPrunableRevision`). A prune there would claim history that + does not exist yet as already pruned, so it is refused and nothing is + deleted. Pass an instant + the graph has reached, such as `store.recordedNow()`. + +A range with more boundaries than the requested `limit` is not an error — +`replay` and `transitionsOf` page, returning a `nextCursor` on the +result (see [Replay and identity history](/identity/#replay-and-identity-history)). + +```typescript +import { IdentityReplayError } from "@nicia-ai/typegraph"; + +try { + await store.identity.replay(alice, { limit: 50 }); +} catch (error) { + if (error instanceof IdentityReplayError) { + console.log(error.details.code); + } +} +``` + ### `IdentityMergeConflictError` Detected at merge **plan time** when the branches being merged carry opposing or otherwise contradictory identity truth: one branch asserts a pair `same` while another asserts it `different` (directly, or transitively through a chain of `same` assertions no single branch ever wrote), a branch retracts an -assertion that a different branch reasserts under a new id (a retract/reassert -race — a branch that reasserts a pair it *also* retracted itself is -convergent, not a conflict, and merges cleanly), or a branch asserts an +assertion that a different branch reasserts under a new id, or retracts one +that a different branch both retracts and reasserts (a retract/reassert race — +a retraction and a reassertion are convergent, and merge cleanly, only when +every branch that retracts the pair also reasserts it), or a branch asserts an identity relation over a node another branch deleted. Extends `MergeError`, so an `instanceof MergeError` catch covers it alongside the other merge failures. +The same error class also covers the identity separation veto (see the +[graph merge guide](/graph-merge/#identity-separation-veto)): a definitional +match, or a transitive cluster, crossing a class-lifted `different` assertion +(`GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT`). Check `error.code` to +distinguish it from the default `GRAPH_MERGE_IDENTITY_CONFLICT`. + `merge()` and `IdentityMergeConflictError` are both exported from `@nicia-ai/typegraph/graph-merge`, not the package root. `merge()` takes an array of branches and never throws a `MergeError` — it **returns** a @@ -339,7 +404,7 @@ import { merge, IdentityMergeConflictError, isErr } from "@nicia-ai/typegraph/gr const result = await merge(store, [branch]); if (isErr(result)) { if (result.error instanceof IdentityMergeConflictError) { - console.log(result.error.code); // "GRAPH_MERGE_IDENTITY_CONFLICT" + console.log(result.error.code); // "GRAPH_MERGE_IDENTITY_CONFLICT" or "GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT" console.log(result.error.details); } throw result.error; @@ -380,6 +445,84 @@ environment, and stale-plan failures retain their existing system errors. Constraint failure is atomic: neither graph writes nor merge provenance records survive. +### `AcyclicityMergeConflictError` + +Detected at merge **plan time**, alongside `IdentityMergeConflictError`, when +the resolved plan's edge writes — after canonicalization and repointing, +layered onto the target's current live edges — would close a cycle in a +declared-`acyclic: true` relation. This includes a cycle formed entirely from +edges the plan itself proposes, with nothing live on the target yet. + +```typescript +import { + AcyclicityMergeConflictError, + isErr, + merge, +} from "@nicia-ai/typegraph/graph-merge"; + +const result = await merge(store, branches); +if (isErr(result) && result.error instanceof AcyclicityMergeConflictError) { + console.log(result.error.code); // "GRAPH_MERGE_ACYCLICITY_CONFLICT" + console.log(result.error.details.relation); // the declared-acyclic relation + console.log(result.error.details.edges); // every offending edge on the cycle +} +``` + +A cycle that only arises from a write racing the plan-time check (which holds +no per-graph lock, since planning does no write) is not caught here — the +unchanged apply-time write path still refuses it as +`MergeConstraintConflictError` wrapping `EdgeAcyclicityError`. + +### `MergeCompositionOrphanError` + +Thrown from inside `applyMergePlan`'s transaction when applying the plan would +delete a composition whole while one of its live parts is not itself among +the plan's node deletions — the target gained that part after the branch +point (or independently of it), and the branch's diff carries no deletion for +it. Applying the plan as trusted would leave the part's composition edge +pointing at a whole that no longer exists. The mirror case is refused the same +way: the plan deletes a part (directly, or through the branch's cascade from +its old whole) that the target has meanwhile moved under another live whole +the plan leaves standing. + +```typescript +import { + applyMergePlan, + isErr, + MergeCompositionOrphanError, +} from "@nicia-ai/typegraph/graph-merge"; + +const applied = await applyMergePlan(store, plan); +if (isErr(applied) && applied.error instanceof MergeCompositionOrphanError) { + console.log(applied.error.code); // "GRAPH_MERGE_COMPOSITION_ORPHAN" + console.log(applied.error.details.part); // { kind, id } + console.log(applied.error.details.cause); // "deleted" | "moved" | "unattached" + console.log(applied.error.details.whole); // { kind, id } — absent for cause: "unattached" + console.log(applied.error.details.viaEdgeKind); +} +``` + +`planMerge` and `planMergeIncremental` surface the same finding, best-effort, +in `MergePlanReview.compositionOrphans` — a dry-run report computed against +the target's state at plan time. This error is the authoritative, +apply-time re-verification of that same check, run under the per-graph write +lock so it cannot miss an orphan the plan-time report's unlocked read raced +past. `cause: "deleted"` names the whole a branch deletes while a part +survives; `cause: "moved"` names the live whole the target holds a part under +that the plan would delete; `cause: "unattached"` (a part that requires a +whole) has no whole to name — recompute the merge plan against the target's +current state, or delete, attach or reconcile the part in the branch before +merging. + +Every planning entry point (`merge`, `mergeIncremental`, `planMerge`, +`planMergeIncremental`) also returns this error when two branches conflict +over one composition attachment: one branch moves, attaches or re-attaches +(reopens an ended `oneActive` row) a part while another deletes the part (`cause: "moved"`, `whole` is the surviving whole +the part was moved under) or the whole (`cause: "deleted"`, `whole` is the +deleted whole). `details.branchId` names the attaching branch; it is absent on +every finding about the target's own state. See +[Composition orphans](/graph-merge#composition-orphans). + ### Merge plan and evidence errors The reviewable merge lifecycle also returns errors in its `Result` arm. It does @@ -495,13 +638,221 @@ try { if (error instanceof CardinalityError) { console.log(error.category); // "constraint" console.log(error.details); - // { edgeKind: "worksAt", fromKind: "Person", fromId: "", cardinality: "one", existingCount: 1 } + // { edgeKind: "worksAt", direction: "source", fromKind: "Person", fromId: "", toKind: "Company", toId: "", cardinality: "one", existingCount: 1 } console.log(error.suggestion); // "Remove the existing edge before creating a new one, or update the existing edge..." } } ``` +`details.direction` names which endpoint's population was overrun — +`"source"` for a `cardinality` violation, `"target"` for a +`targetCardinality` one. `fromKind` / `fromId` / `toKind` / `toId` always +name both endpoints, regardless of direction. + +### `EdgeAcyclicityError` + +Thrown when a write would give a declared `acyclic: true` edge relation a +cycle. + +```typescript +// If dependsOn declares acyclic: true: +await store.edges.dependsOn.create(taskA, taskB, {}); + +try { + await store.edges.dependsOn.create(taskB, taskA, {}); +} catch (error) { + if (error instanceof EdgeAcyclicityError) { + console.log(error.category); // "constraint" + console.log(error.details); + // { relation: "dependsOn", edgeKind: "dependsOn", edgeId: "", + // fromKind: "Task", fromId: "", toKind: "Task", toId: "", + // selfLoop: false } + } +} +``` + +Carries no witness path — reconstructing one requires path tracking, which +the underlying set-semantics reachability check gives up in exchange for +terminating without a depth bound. Run `store.verifyConstraintFences()` to +list every edge already on a cycle in the relation, or a `.recursive()` +traversal from the endpoints for a human to inspect. + +### `EdgeAcyclicityIndeterminateError` + +Thrown when the engine cuts an acyclicity search short — a statement +timeout, a resource limit — before it can prove or refute a cycle. An +incomplete search is never reported as "no cycle". + +```typescript +console.log(error.details); +// { relation: "dependsOn", operation: "edges.create", graphId: "..." } +``` + +The suggestion names the two ways out: raise the statement budget for the +operation, or drop `acyclic: true` from the edge and enforce it in +application code. Retrying is not suggested — a relation too large for the +budget will not shrink. + +### `CompositionError` + +Thrown when a `partOf`/`hasPart` write breaks one of the rules that keep a +part's attachment a fact of its row. `details.situation` names which one, and +`code` says the same thing: + +| `situation` | `code` | Meaning | +| --- | --- | --- | +| `"occupied"` | `COMPOSITION_WHOLE_OCCUPIED` | The write would give a part a second whole | +| `"futureStart"` | `COMPOSITION_VALIDITY_FUTURE_START` | A composition edge write states a `validFrom` later than the operation clock | +| `"futureEnd"` | `COMPOSITION_VALIDITY_FUTURE_END` | A composition edge write states a `validTo` later than the operation clock | +| `"futureMove"` | `COMPOSITION_MOVE_FUTURE` | `reparent` / `bulkReparent` states an `at` later than the operation clock | +| `"moveBeforeIncumbent"` | `COMPOSITION_MOVE_BEFORE_INCUMBENT` | `reparent` / `bulkReparent` states an `at` earlier than the `validFrom` of the `oneActive` attachment it would end | + +A part holds exactly one whole across **every** declared composition pair, +enforced by a single reserved claim axis — two different realizing edge +kinds (or two orientations of one part kind) contend for the same row. + +```typescript +// If Chapter partOf Book (via chapterOf) and Chapter partOf Anthology +// (via includedIn) are both declared: +await store.edges.chapterOf.create(chapter, book, {}); + +try { + await store.edges.includedIn.create(chapter, anthology, {}); +} catch (error) { + if (error instanceof CompositionError) { + console.log(error.category); // "constraint" + console.log(error.details); + // { situation: "occupied", partKind: "Chapter", partId: "", + // wholeKind: "Anthology", wholeId: "", + // edgeKind: "includedIn", incumbentEdgeId: "" } + console.log(error.suggestion); + // "Call `store.nodes..reparent(partId, { whole, via? })` to + // move the part — it retires the incumbent attachment and creates the + // new one in one transaction. ..." + } +} +``` + +Moving a part is [`reparent`](/ontology#reparent-moving-a-part-to-a-new-whole)'s +job: it retires the incumbent attachment and creates the new one in one +transaction, which is the only order in which both the one-whole-per-part rule and +`existence: "required"` hold. + +`details.incumbentEdgeId` names the edge that already holds the axis — the +one fact the claim statement's own result reports. It never names the +incumbent whole's kind or id: reading that would be a second query this +refusal path does not make. The refusal is the same class with the same +detail wherever it is decided: by the claim row, or by the pre-write probe a +batch, a custom command port's fallback, or a backend without claim +relations runs instead. That probe counts the part's attachments across +every realizing edge kind, and reports composition ahead of the edge kind's +own `CardinalityError`. `incumbentEdgeId` is absent only when the incumbent +is an earlier row of the same batch, which has no stored edge to name. Run `store.verifyConstraintFences()` (the +`family: "composition"` entries) to find every part already holding more +than one whole. + +**A composition window never starts or ends later than its write.** A row +with no `validTo` is the part's current attachment and an ended row is +history, for good — which is what lets the claim, the delete cascade, the +acyclicity check and every current-time read agree on which whole a part +holds. A window scheduled to open or close later would make the same row a +membership at one read instant and history at another, so every write of a +realizing edge refuses one: `partOf` on every create and get-or-create +entry, `store.edges.` creates, updates, upserts and their batch forms, +import (as a per-row error carrying the code) and a graph merge's apply. The +`"futureStart"` / `"futureEnd"` details carry the stated `bound` and the +`operationInstant` it was judged against, plus `edgeId` when the edge +exists already; `partId` is absent while a `partOf` create has not assigned +the part its id yet. `"futureMove"` carries `at` instead. Record a change +when it happens: a past `validTo` writes history, and `reparent` moves a part +now (or at a past `at`). + +A move ends a `oneActive` incumbent's window at its `at`, so an `at` earlier +than that window's `validFrom` would close it before it opened: +`"moveBeforeIncumbent"` refuses it before any write, carrying `at` and the +incumbent's `incumbentEdgeKind`, `incumbentEdgeId` and `incumbentValidFrom`. +An omitted `at` is never refused for this; see +[reparent](/ontology#reparent-moving-a-part-to-a-new-whole). + +The composition claim rides `typegraph_edge_claims`, the same relation +`CardinalityError`'s claims do. A deployment initialized before that +relation existed raises `ConfigurationError` (`EDGE_CLAIM_RELATION_MISSING`) +on the first `partOf`/`hasPart` write and must be migrated under owner +credentials before declaring one. + +### `CompositionExistenceError` + +Thrown when a write would leave an `existence: "required"` composition part +with no live whole: a bare create with no `partOf`, a detach (ending, +soft-deleting, or hard-deleting the composition edge) of a live part, or a +`getOrCreateByConstraint` call stating `partOf` against a node that already +exists. + +```typescript +// Segment partOf Episode (via segmentOf, existence: "required"): +try { + await store.nodes.Segment.create({ text: "..." }); +} catch (error) { + if (error instanceof CompositionExistenceError) { + console.log(error.details); + // { partKind: "Segment", situation: "create" } + } +} +``` + +`details.situation` distinguishes the four shapes: `"create"` (no `partId` +yet — deciding the refusal is what keeps the row from ever being written), +`"detach"` (carries `edgeKind`/`edgeId`, the composition edge the caller +tried to end), `"existing"` (a `getOrCreateByConstraint` call whose `partOf` +postcondition the already-existing node contradicts — carries +`currentWhole`/`currentVia` and `requestedWhole`/`requestedVia`, so the move +the caller would have to make is visible in the error), and `"props"` (a +`getOrCreateByConstraint` or `reparent` call resolving to an attachment that +already holds — same whole, same realizing edge — whose stated `props` are +schema-valid but canonically different from the edge's live stored props — +carries `edgeKind`/`edgeId` and `currentProps`/`requestedProps`). `requestedVia` +is the RESOLVED realizing edge of the pair the call's `partOf` names, so it is +present even when the call omitted `via`. + +The error's `code` names the same four shapes, so a handler that routes on +`code` alone can tell them apart: + +| `details.situation` | `code` | +| --- | --- | +| `"create"` | `COMPOSITION_WHOLE_REQUIRED` | +| `"detach"` | `COMPOSITION_DETACH_REFUSED` | +| `"existing"` | `COMPOSITION_WHOLE_CONFLICT` | +| `"props"` | `COMPOSITION_PROPS_CONFLICT` | + +Pass `partOf: { whole, via? }` naming a live, declared whole to fix a +create refusal; soft-delete or hard-delete the part itself (which frees its +edge — a retired part is not orphaned by losing it) to fix a detach refusal; +call [`store.nodes..reparent(id, attachment)`](/ontology#reparent-moving-a-part-to-a-new-whole) +to fix an `"existing"` refusal, which is the operation that actually moves a +part; call `store.edges..update(edgeId, props)` to fix a `"props"` +refusal, which changes the realizing edge's own properties directly. + +An `"existing"` or `"props"` refusal fires only for a CONTRADICTION. A +`getOrCreateByConstraint` (or `reparent`) call whose `partOf` matches the +node's live whole, realizing edge, and (when stated) props succeeds and is +idempotent, and one whose node has no live whole writes the attachment — see +[`partOf` is a postcondition](/ontology#existence-a-part-that-cannot-exist-without-a-whole). + +A `partOf` the graph cannot resolve raises `ConfigurationError` rather than +`CompositionExistenceError` — the option is accepted-shaped but names no +declared pair, regardless of the part kind's `existence` and regardless of +whether the call created the node or found it (the attachment is resolved +before the match is even read, so the refusal cannot depend on what the +constraint matched): + +| `details.code` | when | +| --- | --- | +| `COMPOSITION_WHOLE_NOT_DECLARED` | no composition pair is declared between the part's kind and `partOf.whole.kind` | +| `COMPOSITION_VIA_NOT_DECLARED` | `via` names an edge kind that realizes no declared pair between the two kinds | +| `COMPOSITION_VIA_AMBIGUOUS` | `via` was omitted while more than one pair is declared between the two kinds | +| `COMPOSITION_NOT_A_PART` | `reparent` was called on a kind that declares no `partOf`/`hasPart` pair at all | + ### `UniquenessError` Thrown when a uniqueness constraint is violated. @@ -625,6 +976,73 @@ try { } ``` +### `CompositionCycleError` + +Thrown when the composition parts closure of a whole revisits a node already +in the walk — an instance-level cycle among composition edges (for example a +`Section partOf Section` ring). Reflexive composition is permitted at the +kind level, and the store refuses the corresponding **instance** cycle at +write time: every realizing edge kind belongs to the oriented composition +union, probed for acyclicity on each composition edge write +([`EdgeAcyclicityError`](#edgeacyclicityerror)). A ring therefore only +reaches this walk through rows that bypassed that fence — written before the +`partOf`/`hasPart` pair was declared, by trusted import, or by direct SQL. +Deleting any node in the cycle throws this error instead of looping or +silently truncating the closure. + +```typescript +try { + await store.nodes.Section.delete(sectionA.id); +} catch (error) { + if (error instanceof CompositionCycleError) { + console.log(error.category); // "constraint" + console.log(error.details); + // { wholeKind: "Section", wholeId: "", revisitedKind: "Section", revisitedId: "" } + console.log(error.suggestion); + // "Delete or reassign one of the composition edges that closes this cycle..." + } +} +``` + +The affected nodes stay undeletable through the ordinary delete path until +one of the composition edges that closes the cycle is removed or reassigned +by hand. + +### `NodeDeletePlanMismatchError` + +Thrown by `nodes..delete(id, { expect: plan })` when the plan the delete +computes for itself no longer matches the `plan` from `planDelete(id)`, or +when the node is absent or already deleted. It is raised before any write. +The code is `NODE_DELETE_PLAN_MISMATCH` and the category is `"constraint"`, +like `RestrictedDeleteError`: the graph's current state refuses the delete the +caller confirmed, and the remedy is a fresh preview the user confirms again. +See +[Previewing a delete](/ontology#previewing-a-delete). + +| `details.situation` | Meaning | +| --- | --- | +| `"changed"` | The delete's plan differs from `expect` | +| `"notLive"` | The node is absent or already deleted | + +On `"changed"`, `addedParts` and `addedEdgeIds` are in the delete's plan but +not in `expect`, and `droppedParts` and `droppedEdgeIds` are in `expect` but +no longer in the delete's plan. Parts compare by `(kind, id, whole, +viaEdgeKind)`, so a part moved to another whole appears in both part lists. + +```typescript +try { + await store.nodes.Podcast.delete(podcast.id, { expect: confirmedPlan }); +} catch (error) { + if (error instanceof NodeDeletePlanMismatchError) { + console.log(error.details); + // { kind: "Podcast", id: "", situation: "changed", + // addedParts: [{ kind: "Episode", id: "", whole: { kind: "Podcast", id: "" }, viaEdgeKind: "episodeOf" }], + // droppedParts: [], addedEdgeIds: [""], droppedEdgeIds: [] } + // Preview again and ask the user to confirm the new plan. + } +} +``` + ## Configuration Errors ### `ConfigurationError` @@ -682,6 +1100,24 @@ check applies to node kinds whose schema exposes an object shape; edge `unique` constraints are not validated here. Statically typed callers were already unable to name an undeclared field, so this bites untyped or generated definitions. +#### Provenance fact kinds and required composition parts + +`createRetractionCapability` refuses a configuration whose fact kinds own +required composition parts it cannot reach, with +`PROVENANCE_REQUIRED_PART_NOT_A_FACT`. A required part's belief status follows +its whole's, and the close that enforces that runs over facts, so a fact kind +that is the whole of an `existence: "required"` pair whose part kind is missing +from `fact.kinds` is a hole no transition can fill: closing the whole would +leave a live required part hanging from a closed whole. `details` names the +`wholeKind` and the `partKind`, and `requiredParts` lists every such pair. + +Both sides are read through subsumption, so a fact kind that is a subclass of +the kind a pair declared its whole against counts, and so does a subclass of a +declared required part kind. Fix it by adding the part kind to `fact.kinds` +(with a `derives` endpoint for it) or by declaring the pair +`existence: "optional"`. See [Composition and +retraction](/provenance#composition-and-retraction). + #### Definition-time `__proto__` property refusal `defineNode()` / `defineEdge()` refuse a schema that declares a property named @@ -718,7 +1154,9 @@ cannot fence constrained writes" is unusable advice while "your | `details.constraint` | The write it describes | | --- | --- | -| `edgeCardinality` | Creating or resurrecting an edge whose `cardinality` is `one`, `unique`, or `oneActive`. | +| `edgeAcyclicity` | Creating, bulk-creating, or resurrecting an edge whose kind declares `acyclic: true`. No claim row backs this axis — a cycle spans a whole reachable subgraph, not a tuple — so it is fenced by the per-graph lock alone. | +| `edgeCardinality` | Creating or resurrecting an edge whose `cardinality` (`one`, `unique`, `oneActive`) or `targetCardinality` (`one`, `oneActive`) constrains it, on either endpoint. | +| `edgeComposition` | Creating or resurrecting an edge that realizes a declared `partOf`/`hasPart` pair. Backed by the same `typegraph_edge_claims` relation as `edgeCardinality` — reported in preference to it, so the refusal names the composition declaration rather than a generic cardinality one. Also deleting a composition whole, restoring a soft-deleted part of an `existence: "required"` kind, and soft-deleting, hard-deleting or ending the window of a composition edge whose part kind is required: each decides from reads no claim row backs. | | `edgeMatchKeyConvergence` | Endpoint convergence that requires the portable transaction-scoped path: an undeclared dynamic `matchOn`, constrained cardinality, update or temporal options, derived/custom backends, or schema-aware resurrection of a tombstoned winner. A schema-declared durable `matchIdentity` removes this fence from eligible live single-item and bulk create/found paths. | | `nodeDisjointness` | Creating a node under a kind that participates in a `disjointWith` axiom. Probed only where a node comes into existence, so deletes and in-place updates are not refused. | | `nodeUniquenessScope` | Creating **or updating** a node under a `scope: "kindWithSubClasses"` unique that actually expands past the node's own kind. A `scope: "kind"` unique is backed by the uniques primary key and needs no fence. | @@ -872,6 +1310,53 @@ and `store.search.hybrid` refuse every mismatched override on their own broader rule. See [Approximate retrieval](/semantic-search#approximate-retrieval-for-similarto-opt-in). +#### Edge integrity option codes + +`defineGraph` refuses an edge registration whose integrity options are stated +outside their domain. TypeScript rejects these at compile time; the runtime +check is for untyped callers: + +| `details.code` | Raised when | +| --- | --- | +| `EDGE_INTEGRITY_OPTION_INVALID` | `cardinality` is not `"many"`, `"one"`, `"unique"` or `"oneActive"`; `targetCardinality` is not `"many"`, `"one"` or `"oneActive"`; `endpointExistence` is not `"notDeleted"`, `"currentlyValid"` or `"ever"`; or `acyclic` is not a boolean. `details.option` names the option, `details.value` is what was stated and `details.edgeKind` is the registration. | + +#### Structural subsumption codes + +Registry build refuses a pair of node kinds a subsumption relation joins when +the child's schema does not extend the parent's; see +[Subsumption](/ontology#subsumption-type-inheritance). `details.relation` names the relation the +pair comes from (`subClassOf`, `equivalentTo`, or a `sameAs` a persisted +document still carries), and `details.declared` says whether that relation +declared the pair or the hierarchy implies it: + +| `details.code` | Raised when | +| --- | --- | +| `ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE` | A `subClassOf` child's schema does not extend its parent's. | +| `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` | The projected JSON Schema cannot judge a `subClassOf` pair. | +| `ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE` | A pair an equivalence class relates (`equivalentTo` or `sameAs`) fails in one direction. | +| `ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE` | The projected JSON Schema cannot judge a pair an equivalence class relates. | + +#### Unique constraint option codes + +`defineGraph` refuses a unique constraint whose `scope` or `collation` is stated +outside its domain. TypeScript rejects these at compile time; the runtime check +is for untyped callers. An untyped caller that omits either gets +`scope: "kindWithSubClasses"` and `collation: "binary"` written into the +registration, the values the store has always enforced for an omitted member: + +| `details.code` | Raised when | +| --- | --- | +| `UNIQUE_CONSTRAINT_OPTION_INVALID` | `scope` is not `"kind"` or `"kindWithSubClasses"`, or `collation` is not `"binary"` or `"caseInsensitive"`. `details.option` names the option, `details.value` is what was stated, `details.kind` is the node kind and `details.constraintName` the constraint. | + +#### Export temporal-field codes + +`exportGraph` / `exportGraphStream` refuse to drop valid-time fields a graph's +rows cannot be read without: + +| `details.code` | Raised when | +| --- | --- | +| `ACTIVE_CARDINALITY_EXPORT_REQUIRES_TEMPORAL_FIELDS` | An export explicitly set `includeTemporal: false` on a graph with an edge kind whose `cardinality` or `targetCardinality` is `"oneActive"` (a `oneActive` composition pair included). Those kinds count a row only while its window is open, so an ended row exported without its `validTo` would re-import as current. `details.edgeKinds` lists them. Remove `includeTemporal` (such exports default it to `true`) or set it to `true`. | + #### Durable edge match identity guard codes Durable edge match identity uses stable `ConfigurationError` detail codes: @@ -881,7 +1366,7 @@ Durable edge match identity uses stable `ConfigurationError` detail codes: | `EDGE_MATCH_IDENTITY_VALUE_NOT_SCALAR` | A declared identity field cannot be represented as a portable JSON scalar, or an untyped runtime value violated that declaration. | | `EDGE_MATCH_IDENTITY_KEY_TOO_LARGE` | One complete durable identity tuple exceeds the portable 2,000-byte index budget. Normal import records this against the individual edge; trusted import is atomic and refuses the whole stream. | | `EDGE_MATCH_IDENTITY_STORAGE_UNAVAILABLE` | The adapter declares durable identity support, but the database is missing its columns or unique arbiter. Initialize or migrate the schema before serving writes. | -| `EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND` | Initial adoption needs an atomic empty-kind fence or materialization preflight that the custom backend does not implement. | +| `EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND` | Activating or changing an edge kind's match identity needs the edge kind verified empty atomically with the schema commit. Initial adoption needs an atomic empty-kind fence or materialization preflight; a later `migrateSchema` or `rollbackSchema` that re-keys an edge kind needs `commitSchemaVersionWithPreflight` or `setActiveVersionWithPreflight`. The custom backend does not implement the one the commit needs. | | `DURABLE_EDGE_MATCH_IDENTITY_COMMAND_UNSUPPORTED` | A custom backend declares durable identity support but refuses the authoritative convergence command. TypeGraph fails closed because the portable read-then-write fallback has no equivalent database arbiter. | | `IMPORT_EDGE_BATCH_RETRY_REQUIRES_SAVEPOINT` | A durable import batch was refused without savepoint rollback protection, either because the backend is non-transactional or because its root/transaction statement-execution contract cannot serve savepoints. TypeGraph will not retry rows individually because that could double-attribute an already-written prefix. | @@ -900,6 +1385,15 @@ guessing how incident and exact-pair rows should be interpreted: | `EDGE_HETEROGENEOUS_READ_MIXED_ENDPOINT_MODES` | The request contains both incident-endpoint rows (without an opposite endpoint) and exact directed-pair rows. Supply an opposite endpoint for every row to request exact-pair matching. | | `EDGE_HETEROGENEOUS_READ_BIND_BUDGET_EXCEEDED` | The endpoint set cannot fit within the backend's bind-parameter budget. Split the request into smaller calls. | +#### Pagination guard codes + +`paginate()`, `page()` and `stream()` refuse a query whose cursor could not +position itself, instead of returning pages that silently lose rows: + +| `details.code` | Meaning | +| --- | --- | +| `RECURSIVE_PAGINATION_UNSUPPORTED` | The query contains a recursive traversal (`recursive()`, `parts()` or `wholes()`). A recursive row has no single edge to extend the start node's keyset cursor with, so the rows of one start node that straddle a page boundary would be dropped. `details.operation` names the refused surface. Read the walk with `execute()` and an explicit `limit()` or `recursive({ maxHops })`, or page the start nodes in a query without the recursive traversal and run the walk for each page. See [Recursive traversals are not paginated](/queries/execute#recursive-traversals-are-not-paginated). | + #### Operational Identity guard codes Operational Identity lifecycle failures use stable `details.code` values on @@ -950,6 +1444,25 @@ assertion's id structurally in `details.issues[].assertionId`, and | `IDENTITY_IMPORT_ENDED_BY_NOT_ENDPOINT` | An assertion's `endedBy` names a node that is not one of its own endpoints; a deletion cascade only ends assertions that touch the deleted node. | | `IDENTITY_SELF_ASSERTION` | An assertion's `a` and `b` name the same node. | +Archival transitions carry four `ValidationError` codes of their own, each a +precondition failure on the identity section as a whole rather than a per-row +outcome — unlike the table above, these THROW uncaught and are never recorded +in `result.errors`: + +| Issue `code` | Meaning | +| --- | --- | +| `IDENTITY_STATE_IMPORT_TRANSITIONS` | A `state`-mode document names a `transitions` section; only `identityMode: "archival"` exports carry one. Refused before any write. | +| `IDENTITY_STATE_IMPORT_RETENTION` | A `state`-mode document carries a `retention` watermark; only archival exports carry one. Refused before any write. | +| `IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE` | An archival document's `transitions` are not ordered by non-decreasing `recordedRevision` — within the array, or, on `importGraphStream`, across its `identity-transitions` chunks. | +| `IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING` | An `importGraphStream` header announced a transition restore (`hasTransitions`, or a non-zero `retention`), but the stream ended without an `identity-transitions` chunk. Raised at the end of the stream, after its earlier chunks committed. | + +A write that records an identity transition on a database whose transition-log +relations were never created (base-schema version 6 not yet adopted; see +[Base-schema version 6](/backend-setup/#base-schema-version-6-the-identity-transition-log)) +throws a `ConfigurationError` whose `details.code` is +`IDENTITY_TRANSITION_RELATIONS_MISSING` and whose `details.relations` names +both relations. + #### Merge provenance sidecar codes `persistProvenance: true` writes to a *sidecar* graph beside the merge target, @@ -1183,6 +1696,109 @@ try { } ``` +The `details.reason` value `"ontology-tightening-violated"` means an ontology +change — adding `disjointWith`, `subClassOf`, `equivalentTo`, or +`partOf`/`hasPart`, or removing `subClassOf` or `equivalentTo` — is false +against rows that already exist. `details.changes` carries only the ontology +changes in this diff that required a data check (a `safe` or `breaking` +change in the same commit is never included, even one alongside the change +that was refused); `details.violations` carries the offending rows in +exactly the shape `store.verifyConstraintFences()` returns — including +`family: "composition"` entries when a newly-declared `partOf`/`hasPart` +pair finds a part already holding two live wholes, or a cycle in the +proposed composition relation, and `family: "compositionFutureWindow"` +entries when the pair's realizing edge kind already holds a row whose +window starts or ends later than the commit. Resolve those rows (delete them, change +their kind, or narrow the ontology change) and retry. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data) +for what each meta-edge checks. + +```typescript +try { + const [store] = await createStoreWithSchema(graph, backend); +} catch (error) { + if (error instanceof MigrationError && error.details.reason === "ontology-tightening-violated") { + console.log(error.details.violations); + // [{ family: "nodeDisjointness", target: {...}, owners: [...] }, ...] + } +} +``` + +The `details.reason` value `"unique-constraint-requires-empty-kind"` means the +commit would leave a uniqueness constraint unenforced over rows that already +exist: it adds or reshapes a unique constraint on a kind that holds rows +(including a kind the commit re-adds over rows a discarded drop left behind), +or drops a member that sorts before the kind from the subclass component of a +populated kind's `kindWithSubClasses` constraint (a removed `subClassOf` / +`equivalentTo`, or a removed kind). A schema +commit neither validates existing rows against a unique constraint nor +rebuilds its claims, so the kind must be empty. `details.constraints` names +each blocked constraint — `kind`, `constraintName`, `cause` +(`"constraint-added"`, `"constraint-reshaped"` or `"claim-axis-moved"`) and +the claim `axis`, plus `lostKinds` for a component that dropped members. Declare the +constraint before the kind is populated, or empty the kind, commit, and write +the rows back. See +[Unique constraints need an empty kind](/schema-evolution#unique-constraints-need-an-empty-kind). + +```typescript +try { + const [store] = await createStoreWithSchema(graph, backend); +} catch (error) { + if (error instanceof MigrationError && error.details.reason === "unique-constraint-requires-empty-kind") { + console.log(error.details.constraints); + // [{ cause: "constraint-added", kind: "Person", constraintName: "person_email", axis: "Person" }] + } +} +``` + +The `details.reason` value `"edge-cardinality-tightening-violated"` is the +same shape for edge cardinality: newly declaring or tightening `cardinality` +or `targetCardinality` on an edge kind is false against rows that already +exist. `details.axes` names the axes this commit newly constrains (each an +`{direction, cardinality, edgeKind}` triple); `details.violations` carries +the offending rows in exactly the shape `store.verifyConstraintFences()` +returns. An axis is probed whenever the proposed value is constrained and +differs from the stored one — including a genuine loosening, which costs one +read of an already-clean population rather than risk missing a tightening. + +```typescript +try { + const [store] = await createStoreWithSchema(graph, backend); +} catch (error) { + if (error instanceof MigrationError && error.details.reason === "edge-cardinality-tightening-violated") { + console.log(error.details.axes); + // [{ direction: "target", cardinality: "one", edgeKind: "assignedTo" }] + console.log(error.details.violations); + } +} +``` + +The `details.reason` value `"custom-relation-removal"` means +`store.evolve()`, `store.planEvolution()` or `store.removeKinds()` would drop a +stored relation that names a custom meta-edge from the removed `metaEdge()` +factory. The stored schema holds the last copy of that relation, so these verbs +refuse before writing anything. `details.relations` names each relation as +written, such as `governs(Team, Project)`. Move them into +`defineGraph({ annotations })`, commit once with `migrateSchema()`, then retry. +See +[Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis). + +#### Schema-tightening and constraint-fence audit guard codes + +Committing a schema tightening — ontology, edge cardinality, or a gated +unique-constraint change — uses stable +`ConfigurationError` detail codes when the backend cannot run the required +data check atomically: + +| `details.code` | Meaning | +| --- | --- | +| `ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND` | The backend cannot commit the ontology-tightening data check atomically with the schema-version compare-and-swap. A commit that adds an edge kind owes this check too: rows a previous removal left under that name (`discardDroppedKindRows`) must sit on endpoint pairs the new declaration admits, and must not form a cycle if it declares `acyclic: true`. Run the migration through a backend built by `createSqliteBackend` or `createPostgresBackend`, or implement `commitSchemaVersionWithPreflight`. | +| `EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND` | The backend cannot commit the edge-cardinality-tightening data check atomically with the schema-version compare-and-swap. Any commit that newly declares a constrained `cardinality` or `targetCardinality` on an edge kind — including declaring one on a brand-new kind — owes this same atomic check. Run the migration through a backend built by `createSqliteBackend` or `createPostgresBackend`, or implement `commitSchemaVersionWithPreflight`. | +| `UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND` | The backend cannot verify that a kind is empty atomically with the schema-version compare-and-swap. A commit that adds or reshapes a unique constraint on a kind — a kind the commit adds included, since it may own rows a previous removal left behind — or moves a `kindWithSubClasses` claim, owes that check even when the kind is empty. Run the migration through a backend built by `createSqliteBackend` or `createPostgresBackend`, or implement `commitSchemaVersionWithPreflight`. | +| `CONSTRAINT_FENCE_AUDIT_UNSUPPORTED` | The backend does not implement `readConstraintFenceViolations` at all, so neither `store.verifyConstraintFences()` nor a schema-tightening preflight can run. | +| `CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED` | The backend ran the audit but did not answer the `edgeEndpointAssignability` family it was asked for (`misassignedEdgeEndpointRows` was left `undefined`). An empty report there would be indistinguishable from a clean database, so the audit refuses rather than reporting one. | +| `SCHEMA_WRITE_REQUIRES_READ_COMMITTED` | A PostgreSQL schema write took the advisory schema fence on a session that reports an isolation other than `read_committed` (`details.isolation`). Managed writers hold the schema row `FOR SHARE` without changing it, so only a fresh snapshot per statement lets the data check see what they committed while the schema write waited. TypeGraph opens its own schema-write transactions at `read_committed`, so this is raised for a caller-owned `REPEATABLE READ` or `SERIALIZABLE` transaction it cannot move: one handed to `adoptSchemaWriteTransaction`, or one under a transaction-scoped backend (`createPostgresTransactionBackend(tx)`), where every schema write — a commit, an activation, an identity schema transition, the provenance sidecar claim, removal materialization, a contribution rebuild — runs in a savepoint of the caller's transaction. Run the schema write in a `READ COMMITTED` transaction. The caller's transaction is left as it was: no lock held, no setting changed. | + ### `BaseSchemaMigrationError` Thrown by zero-DDL verified and graph-template entry points when the @@ -1462,11 +2078,31 @@ try { | `IDENTITY_VALIDITY_INVERTED` | `IdentityValidityWindowError` | user | Identity assertion ends before it starts | | `IDENTITY_VALIDITY_OPEN_WINDOW_CONFLICT` | `IdentityValidityWindowError` | constraint | A different open window already represents the current semantic pair | | `IDENTITY_ENDPOINT_VALIDITY` | `IdentityEndpointValidityError` | constraint | An endpoint does not cover the explicit assertion window | +| `IDENTITY_REPLAY_REQUIRES_HISTORY` | `IdentityReplayError` | constraint | `replay` / `transitionsOf` called on a store opened without `history: true` | +| `IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED` | `IdentityReplayError` | constraint | `replay` / `transitionsOf` called on a store whose backend owns recorded time (engine-native) | +| `IDENTITY_REPLAY_HISTORY_TRUNCATED` | `IdentityReplayError` | constraint | The requested range lies entirely below the retention watermark | +| `IDENTITY_REPLAY_WALK_INCOMPLETE` | `IdentityReplayError` | constraint | A single lineage's transition rows exceeded the walk's internal total safety ceiling | +| `IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK` | `IdentityReplayError` | constraint | A prune named a recorded instant beyond every revision the graph holds | | `GRAPH_MERGE_IDENTITY_CONFLICT` | `IdentityMergeConflictError` | system | Branches carry opposing identity truth | +| `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT` | `IdentityMergeConflictError` | system | A definitional match or a transitive cluster crosses a class-lifted `different` assertion | +| `GRAPH_MERGE_ACYCLICITY_CONFLICT` | `AcyclicityMergeConflictError` | system | The resolved plan's edge writes would close a cycle in a declared-acyclic relation | | `GRAPH_MERGE_CONSTRAINT_CONFLICT` | `MergeConstraintConflictError` | constraint | The resolved merge would violate a store constraint | +| `GRAPH_MERGE_COMPOSITION_ORPHAN` | `MergeCompositionOrphanError` | constraint | Applying the plan would delete a whole while a live part of it is not among the plan's deletions, or delete a part the target holds under a live whole the plan keeps; or, at planning, one branch's composition attachment loses its part or whole to another branch's deletion | +| `GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE` | `CandidatePlanningScopeError` | user | `planCandidateWriteSet` was called with `planningScope: "candidate"`, but the target or candidate must be planned against the whole target; `details.planningScope.reason` names why | | `ENDPOINT_ERROR` | `EndpointError` | constraint | Invalid edge endpoint types | | `ENDPOINT_PAIR_ERROR` | `EndpointPairError` | constraint | Undeclared source/target combination | | `CARDINALITY_ERROR` | `CardinalityError` | constraint | Cardinality constraint violated | +| `EDGE_ACYCLICITY_ERROR` | `EdgeAcyclicityError` | constraint | A write would give a declared-acyclic edge relation a cycle | +| `EDGE_ACYCLICITY_INDETERMINATE` | `EdgeAcyclicityIndeterminateError` | system | The engine cut an acyclicity search short before it could prove or refute a cycle | +| `COMPOSITION_WHOLE_OCCUPIED` | `CompositionError` | constraint | A `partOf`/`hasPart` write would give a part a second whole | +| `COMPOSITION_VALIDITY_FUTURE_START` | `CompositionError` | constraint | A composition edge write states a `validFrom` later than the operation clock | +| `COMPOSITION_VALIDITY_FUTURE_END` | `CompositionError` | constraint | A composition edge write states a `validTo` later than the operation clock | +| `COMPOSITION_MOVE_FUTURE` | `CompositionError` | constraint | A `reparent` / `bulkReparent` states an `at` later than the operation clock | +| `COMPOSITION_MOVE_BEFORE_INCUMBENT` | `CompositionError` | constraint | A `reparent` / `bulkReparent` states an `at` earlier than the `validFrom` of the `oneActive` attachment it would end | +| `COMPOSITION_WHOLE_REQUIRED` | `CompositionExistenceError` | constraint | A bare create would leave an `existence: "required"` composition part with no live whole | +| `COMPOSITION_DETACH_REFUSED` | `CompositionExistenceError` | constraint | A detach would leave a live `existence: "required"` part with no whole | +| `COMPOSITION_WHOLE_CONFLICT` | `CompositionExistenceError` | constraint | A stated `partOf` contradicts the whole (or realizing edge) the existing node already holds | +| `COMPOSITION_PROPS_CONFLICT` | `CompositionExistenceError` | constraint | A stated `partOf.props` differs from the already-satisfied attachment's stored props | | `UNIQUENESS_VIOLATION` | `UniquenessError` | constraint | Uniqueness constraint violated | | `EDGE_MATCH_IDENTITY_CONFLICT` | `EdgeMatchIdentityConflictError` | constraint | A direct edge write collided with its declared endpoint/property identity | | `NODE_NOT_FOUND` | `NodeNotFoundError` | user | Referenced node doesn't exist | @@ -1474,7 +2110,9 @@ try { | `KIND_NOT_FOUND` | `KindNotFoundError` | user | Unknown node/edge type | | `ENDPOINT_NOT_FOUND` | `EndpointNotFoundError` | user | Edge endpoint node doesn't exist | | `RESTRICTED_DELETE` | `RestrictedDeleteError` | constraint | Delete blocked by existing edges | +| `COMPOSITION_CYCLE_DETECTED` | `CompositionCycleError` | constraint | An instance-level composition cycle survives from rows that bypassed the write-time acyclicity fence | | `CONFIGURATION_ERROR` | `ConfigurationError` | system | Invalid configuration | +| `PROVENANCE_REQUIRED_PART_NOT_A_FACT` | `ConfigurationError` | system | A `createRetractionCapability` fact kind is the whole of an `existence: "required"` composition pair whose part kind is not itself a fact kind | | `SCHEMA_MISMATCH` | `SchemaMismatchError` | system | Database schema mismatch | | `MIGRATION_ERROR` | `MigrationError` | system | Migration failed | | `BASE_SCHEMA_MIGRATION_REQUIRED` | `BaseSchemaMigrationError` | system | Deployment-wide base storage requires privileged adoption | diff --git a/apps/docs/src/content/docs/examples/document-management.md b/apps/docs/src/content/docs/examples/document-management.md index 6cbb6106d..5e1a0cb71 100644 --- a/apps/docs/src/content/docs/examples/document-management.md +++ b/apps/docs/src/content/docs/examples/document-management.md @@ -22,8 +22,6 @@ import { embedding, searchable, subClassOf, - partOf, - hasPart, } from "@nicia-ai/typegraph"; // Base content type (abstract) @@ -118,7 +116,13 @@ const graph = defineGraph({ Permission: { type: Permission }, }, edges: { - contains: { type: contains, from: [Folder], to: [Folder, Document] }, + contains: { + type: contains, + from: [Folder], + to: [Folder, Document], + // A Folder or Document belongs to exactly one parent Folder. + targetCardinality: "one", + }, relatedTo: { type: relatedTo, from: [Document], to: [Document] }, hasPermission: { type: hasPermission, from: [User], to: [Content] }, createdBy: { type: createdBy, from: [Content], to: [User] }, @@ -128,9 +132,17 @@ const graph = defineGraph({ subClassOf(Folder, Content), subClassOf(Document, Content), - // Compositional relationships - partOf(Document, Folder), - hasPart(Folder, Document), + // `contains` is deliberately NOT declared as a `partOf`/`hasPart` + // composition relation. Composition means single ownership plus + // cascade-delete: deleting the whole deletes every part. A folder tree + // is the opposite case — folders and documents are shared/re-homeable + // containment, so deleting a Folder should move its children up to the + // deleted folder's parent, not delete them. `targetCardinality: "one"` + // above already gives "at most one parent folder"; that single-parent + // constraint is all this relationship needs. Reach for `partOf` only + // when a part's lifecycle is genuinely bound to its whole (a document's + // sections dying with the document, not a folder's contents surviving + // the folder). ], }); ``` @@ -551,7 +563,7 @@ async function canRead(userId: string, contentId: string): Promise { .from("User", "u") .whereNode("u", (u) => u.id.eq(userId)) .traverse("hasPermission", "p") - .to("Content", "c", { includeSubClasses: true }) + .to("Content", "c", { expansion: "subclasses" }) .whereNode("c", (c) => c.id.eq(contentId)) .first(); @@ -567,7 +579,7 @@ async function canRead(userId: string, contentId: string): Promise { .from("Folder", "f") .traverse("contains", "e") .recursive() - .to("Content", "c", { includeSubClasses: true }) + .to("Content", "c", { expansion: "subclasses" }) .whereNode("c", (c) => c.id.eq(contentId)) .select((ctx) => ctx.f.id) .execute(); diff --git a/apps/docs/src/content/docs/fulltext-search.md b/apps/docs/src/content/docs/fulltext-search.md index 708aa2fbb..40f0b1450 100644 --- a/apps/docs/src/content/docs/fulltext-search.md +++ b/apps/docs/src/content/docs/fulltext-search.md @@ -413,7 +413,7 @@ round trip instead of three. The saving scales with per-statement cost: decisive on serverless HTTP drivers, Cloudflare D1 / Durable Objects, and remote databases; on a local low-latency connection the two paths are within a few milliseconds of each other. (Kind expansions via -`includeSubClasses`, and custom backends without the composed statement, +`expansion`, and custom backends without the composed statement, transparently use a multi-statement path with identical results.) ```typescript @@ -481,16 +481,70 @@ for (const hit of hits) { | `includeSnippets` | `boolean` | `false` | Return a highlighted `…` snippet per hit. Noticeably slower than plain search — request only for final-page results. | | `where` | `(accessor) => Predicate` | *(none)* | Property predicate compiled into the search statement's candidate set — the engine ranks only matching rows, so a filter never shrinks results below `limit` when enough matches exist (libSQL DiskANN: bounded by its 4× over-fetch). Same accessor and semantics as `store.nodes..find({ where })`. | | `offset` | `number` | `0` | Rank-relative pagination: skip the first `offset` ranked hits. | -| `includeSubClasses` | `boolean` | `false` | Also search `subClassOf` descendant kinds and merge their scores into one ranking. | +| `expansion` | `"exact" \| "subclasses"` | `"exact"` | `"subclasses"` also searches `subClassOf` descendant kinds and merges their scores into one ranking. | + +`search()`'s `expansion` stays **opt-in** and defaults to `"exact"` — +this is a deliberate asymmetry with the query builder's `from()`/`to()`, +which default to `expansion: "subclasses"`. `search()` is a facade with its +own documented option rather than a `from()`/`to()` call, and flipping its +default in the same release would also change which vector-metric union +applies (search across multiple kinds must agree on one declared metric; see +[Semantic Search](/semantic-search)) with no migration knob of its own. If +this asymmetry surprises you, that's the intended signal that it's worth +double-checking whether your search call should opt in explicitly. +[Scope at a glance](/queries/source#scope-at-a-glance) lists the default +scope of every query, collection and search surface side by side. A subclass +only a persisted [graph extension](/graph-extensions) declares is searched like +any other under `expansion: "subclasses"`, because the hit type is widened +whatever the compile-time graph declares (see below). + +An expansion that would silently miss rows is refused rather than +under-returned, exactly where `$fulltext.matches()` refuses the same alias: if +a subclass redeclares a `searchable()` field without `searchable()`, its text +is not in the index, so `expansion: "subclasses"` throws `ConfigurationError` +(`SEARCH_EXPANSION_KIND_UNINDEXED`) naming the kind. The same holds for an +exact search of a kind that declares no `searchable()` field at all: it is +refused rather than returning nothing, as `$fulltext.matches()` refuses the +same alias. A subclass that merely omits the field holds no text there and is +searched as usual. Passing the +removed `includeSubClasses` option from untyped code is refused +(`QUERY_ALIAS_EXPANSION_INVALID`) rather than ignored. + +A hit's type follows the stated axis. With `"exact"` (stated or omitted), +`hit.node` is the searched kind exactly. With `expansion: "subclasses"`, +`hit.node` keeps the searched kind's properties while its `kind` widens to +`string` and its `NodeId` brand widens, exactly as a +`from(kind, alias, { expansion: "subclasses" })` alias does: the row may be a +subclass, so its id is not accepted by the searched kind's own `update()`. +This holds even for a kind no compile-time subsumption relation names, since +a persisted extension can still subclass it. Narrow on `hit.node.kind` before +treating the row as one concrete kind. An `expansion` value that is not a +literal at the call site (a variable typed `"exact" | "subclasses"`, or an +options value typed `FulltextSearchOptions` that a wrapper forwards) also +types the hit in the widened form. + +```typescript +const hits = await store.search.fulltext("Content", { + query: "climate", + limit: 10, + expansion: "subclasses", +}); + +for (const hit of hits) { + hit.node.title; // typed: every subclass satisfies Content's properties + hit.node.kind; // string — "Content", or any of its subclasses +} +``` The same three options are available on `store.search.vector` and -`store.search.hybrid` (where `where` and `includeSubClasses` apply to both +`store.search.hybrid` (where `where` and `expansion` apply to both halves). Search always follows current-read semantics: tombstoned nodes and nodes outside their validity window never rank. Returned hits are `FulltextSearchHit>` with `node`, `score` (higher = more relevant), `rank` (1-based), and `snippet` (when -requested). +requested). Under `expansion: "subclasses"`, `Node` is the widened form +described above. ## Reciprocal Rank Fusion diff --git a/apps/docs/src/content/docs/graph-extensions.md b/apps/docs/src/content/docs/graph-extensions.md index e1833836c..6ccaeda2e 100644 --- a/apps/docs/src/content/docs/graph-extensions.md +++ b/apps/docs/src/content/docs/graph-extensions.md @@ -306,12 +306,74 @@ Adding allowed pairs broadens an extension edge. Removing a pair tightens it, even if the overall source and target kind sets stay the same. Tightening currently requires the **entire edge kind** to be empty, not just the removed pair. +A runtime-authored edge may declare `acyclic: true`, exactly like a +compile-time one — its live relation becomes a DAG, enforced under the same +per-graph write fence: + +```ts +const proposal = defineGraphExtension({ + edges: { + dependsOn: { + from: ["Task"], + to: ["Task"], + properties: {}, + acyclic: true, + }, + }, +}); +``` + +It may also declare `cardinality` and `targetCardinality`, bounding the edges +leaving one source and the edges pointing at one target exactly as a +compile-time registration does. The same atomic claims enforce them on every +write path, so a second edge at a `targetCardinality: "one"` target is refused +with `CardinalityError` (`details.direction: "target"`). A platform that accepts +extension documents from untrusted authors should therefore expect an extension +to introduce edge-count constraints, not only additive kinds. + ### Ontology Pass `ontology: [{ metaEdge, from, to }, ...]` to declare ontology relations between kinds (subClassOf, partOf, etc.). The meta-edge name must match a meta-edge known to the merged graph. +A `subClassOf` (or `equivalentTo`/`sameAs`) relation declared this way is +checked against the same structural contract a compile-time declaration +gets — the child's schema must extend the parent's — at `evolve()`, before +any write. Because extension relations are authored as plain data (`{ +metaEdge: "subClassOf", from: "Child", to: "Parent" }`) rather than through +the typed `subClassOf()` function, there is no compile-time check to catch +the mismatch first; `evolve()` throws a `ConfigurationError` (the same codes +`/ontology` documents) and no kind from the extension becomes reachable. +This also covers **redeclaring** an existing kind through a later +`evolve()`: the registry is rebuilt from the merged graph on every call, so +a redeclaration that breaks a hierarchy it already participates in — as a +parent, a child, or an equivalent — is re-checked and refused just as a +first declaration would be. + +**A query that leaves the axis to the default refuses a base-graph kind an +extension subclasses.** `evolve()` returns `Store` with the same +compile-time `G` it was called on, and a later `createStoreWithSchema(graph, +backend)` folds the persisted extension back in under the same `G`, so the +compile-time alias type of a base-graph kind that only becomes polymorphic +through an extension's `subClassOf` is still the exact kind. Rather than +return the extension's subclass rows under that type, `from(kind, alias)`, a +list source and `to(kind, alias)` with no `expansion` throw +`ConfigurationError` (`QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`), on the +evolved store and after every restart. A list source is refused only for an +extra kind none of its listed kinds' types admits. `store.search()` with +`expansion: "subclasses"` is not refused: its hit is typed polymorphically +whatever the compile-time graph declares. State `{ expansion: "subclasses" }` +or `{ expansion: "exact" }`, or use `fromDynamic()`; see [Query Source ▸ +Subclass Expansion](/queries/source#subclass-expansion). + +The refusal reads the graph's ontology **values** at run time, not the type +the store was declared with, so it also fires for a store typed over a wide +graph type (`Store`) or an ontology whose literals an +`OntologyRelation` annotation erased, although such a store already types the +alias polymorphically. Code like that should state `expansion` or use +`fromDynamic()`. + ## `store.evolve(extension, options?)` ```ts @@ -350,7 +412,10 @@ opens its write transaction. It returns an immutable `"noop"` or `"change"` plan with `graphId`, `baseline: { version, hash }`, and `result: { version, hash }`. Change plans expose an ordered `requirements` array whose entries name new-kind additions, empty-kind checks, vector slots, -and identity work. A `new-kind` entry describes a graph delta; it does not +ontology tightening, and identity work. An `ontology-tightening` entry lists +the ontology changes and newly constrained edge cardinalities that existing +rows must satisfy; apply checks them on the fenced session and refuses with +the same `MigrationError` `evolve()` raises. A `new-kind` entry describes a graph delta; it does not indicate that a removal is queued or direct callers to run `materializeRemovals()`. The plan is opaque and bound to the loaded TypeGraph module: it cannot be serialized, cloned, or reconstructed. It can be passed between @@ -711,7 +776,9 @@ The `store.search` facade — `fulltext`, `vector`, `hybrid`, and `rebuildFulltext` — accepts any registered kind, compile-time or runtime, with no type cast. The hit's `node` type narrows to the concrete typed node only when the kind literal is statically known -in `Store`; extension kinds widen to the base `Node`. Misspelled +in `Store` (and the call does not state `expansion: "subclasses"` on a +kind the ontology can affect, which widens `kind` and the `NodeId` brand); +extension kinds widen to the base `Node`. Misspelled kind names throw `KindNotFoundError` at the call site instead of returning empty results. @@ -1073,6 +1140,16 @@ removed. The edge kind survives while another valid pair remains; it is cascaded only when no pairs remain. For example, removing `Course` from the `assignedTo` extension above preserves `Employee → Department`. +Removing a kind from the middle of a subclass chain cuts its surviving +subclasses off from every edge kind that admitted them through it. When a live +edge still relies on that admission, `removeKinds()` refuses with +`MigrationError` (`reason: "ontology-tightening-violated"`) and lists the +edges in `details.violations`; see +[Ontology changes are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). +Edges touching rows of the removed kind itself never block the removal; the +rows are reclaimed by `materializeRemovals()` or an `eager` removal, not at +commit. + Removal only applies to graph-extension-declared kinds. Removing a compile-time kind throws `RemoveCompileTimeKindError`; deploy new TypeScript code for compile-time schema removal. Removing a graph-extension kind that is still diff --git a/apps/docs/src/content/docs/graph-merge.md b/apps/docs/src/content/docs/graph-merge.md index 54400f88a..a380c511d 100644 --- a/apps/docs/src/content/docs/graph-merge.md +++ b/apps/docs/src/content/docs/graph-merge.md @@ -312,8 +312,11 @@ bounded candidate batch. First use `captureCandidateWriteSetTargetForEvolution(target, evolutionPlan)` when authoring the JSON document; it records the evolution plan's resulting schema identity rather than the currently active one. The planner stages the candidate -against that resulting graph and returns the same resulting-schema merge -artifact accepted by `withEvolvedTransaction()`. +against that resulting graph and returns, as `plan`, the same resulting-schema +merge artifact accepted by `withEvolvedTransaction()`. It always stages the +whole resulting graph, so its `planningScope` is +`{ scope: "whole-target", reason: "planned-evolution" }` and it takes no +`planningScope` option. Candidate resolution still includes the committed target as an accepted source. Existing unique matches and property conflicts are therefore visible in the @@ -334,7 +337,7 @@ const writeSet = { }], edges: [], }; -const mergePlan = unwrap(await planCandidateWriteSetForEvolution({ +const { plan: mergePlan } = unwrap(await planCandidateWriteSetForEvolution({ target, evolutionPlan, makeBackend: makeIsolatedBackend, @@ -431,12 +434,16 @@ identity/context, and target baseline. You can persist this artifact and later approval records in the target before calling `revalidateCandidateWriteSetReview()` to compute a fresh execution plan. -Both review versions support candidate write sets only. They do not rebase arbitrary artifacts +Both review formats support candidate write sets only. They do not rebase arbitrary artifacts from `planMerge()` or `planMergeIncremental()`. -Candidate planning on revision-tracked graphs reads existing candidate ids and -edge endpoints by key, then seeds only those rows in the transient working -copy. On identity-enabled graphs, it also follows live same-id peers and +On an eligible revision-tracked graph, candidate planning reads existing +candidate ids and edge endpoints by key, then seeds only those rows in the +transient working copy. A graph that declares a target-side cardinality, an +`acyclic` edge or a composition pair is not eligible: those constraints depend +on rows the sparse base never reads, so planning there uses the +complete clone, whole-target path and reports why in `planningScope` +(see [Planning scope](#planning-scope)). On identity-enabled graphs, it also follows live same-id peers and current identity assertions from those references to a fixed point. The planner reads peers of a candidate edge with `one` cardinality by source, peers of a `unique` edge by its endpoint pair, and the active peer of a @@ -452,8 +459,10 @@ still fences the target revision before and after these reads. With edge match-identity constraints, a backend offering `findEdgesByMatchIdentity` seeds the exact durable owners named by the candidate. A missing keyed read, an owner excluded from the clone projection, or a target without revision -tracking uses the complete clone path. A custom backend lacking the optional -`findActiveEdgesBySourceV1` read also uses that path for `oneActive` graphs. +tracking uses the complete clone path, as does a graph declaring a +target-side cardinality, an `acyclic` edge or a composition pair. A custom +backend lacking the optional `findActiveEdgesBySourceV1` read also uses that +path for `oneActive` graphs. On the complete clone path, when the copy and target really share one serialized connection, clone export is materialized before import, but its snapshot still holds the connection's @@ -524,7 +533,11 @@ if (checked.reviewDigest.value !== decision.reviewDigest) { } // Keep this fresh execution plan ephemeral: another target write makes it stale. -const applied = unwrap(await applyMergePlan(store, checked.plan)); +// Passing the revalidation records its review digest on the identity +// transitions the apply causes; it is refused unless it names this plan. +const applied = unwrap( + await applyMergePlan(store, checked.plan, { review: checked }), +); // A separate commit AFTER successful apply; see the recovery boundary below. await store.nodes.Artifact.create({ @@ -560,7 +573,7 @@ evidence return `MergeReviewError` (`GRAPH_MERGE_REVIEW`). Existing typed planni and constraint errors remain errors rather than compatibility statuses. A target change during evidence capture/planning returns `MergePlanningStaleError`. -The V1 baseline is deliberately conservative: +The whole-target baseline (format 3) is deliberately conservative: - Every original node and edge row, including tombstones and validity metadata, must remain unchanged. Editing an old audit record requires a new review even @@ -574,32 +587,74 @@ The V1 baseline is deliberately conservative: regenerated. There is no exemption for an “audit” kind. For an eligible revision-tracked graph, pass -`reviewScope: "candidate"` to `planCandidateWriteSetReview()` to emit V2 -candidate-scoped evidence. V2 fingerprints the candidate's node and edge ids, +`reviewScope: "candidate"` to `planCandidateWriteSetReview()` to emit +candidate-scoped evidence (`formatVersion` +`MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED`, 4). It fingerprints the +candidate's node and edge ids, edge endpoints, resolved writes, and plan guards, including expected absences across kinds. On Operational Identity graphs it also records the reachable identity assertion and same-id peer closure, plus assertion-ID collision evidence. Revalidation expands that retained identity scope, rereads the referenced rows, and replans the candidate under a new target fence. An unrelated original row may change -without invalidating V2 when it cannot affect the fresh resolved plan; V1 -would report that row change. Applications whose approval policy needs the -V1 whole-graph rule should omit `reviewScope`. The review artifact records -its version and scope, so revalidation applies the rule originally reviewed. -Candidate-scoped review refuses graphs outside those eligibility rules. +without invalidating a candidate-scoped review when it cannot affect the +fresh resolved plan; the default whole-target review (`formatVersion` +`MERGE_REVIEW_FORMAT_VERSION`, 3) would report that row change. Applications +whose approval policy needs the whole-graph rule should omit `reviewScope`. +The review artifact records its format and scope, so revalidation applies the +rule originally reviewed. A review stored under format 1 or 2 embeds a +version-1 plan and is refused with `MergeReviewError` +`details.reason: "unsupported-version"`; plan and review the candidate write +set again. + +An explicitly requested candidate review scope (`reviewScope: "candidate"`) is +refused with `details.reason: "candidate-scope-ineligible"` on a graph outside +the eligibility rules, and `details.planningScope` carries the +[planning scope](#planning-scope) decision that refused it. Candidate +write-set planning is not refused for ineligibility unless it is called with +`planningScope: "candidate"`: `planCandidateWriteSet`, and a review planned +without `reviewScope`, use the complete-clone, whole-target planner there, +yield the plan and conflicts that planner yields, and report the reason in +`planningScope`. Revalidating a +stored candidate-scoped review is refused with the same reason when the target +can no longer be read sparsely, for example after a custom backend loses +`findActiveEdgesBySourceV1` on a `oneActive` graph. On a `oneActive` graph, a custom backend must expose -`findActiveEdgesBySourceV1` for candidate-scoped review; the complete-clone -candidate planner and V1 review remain available when it does not. +`findActiveEdgesBySourceV1` for a candidate-scoped review; without it the +fallback and the whole-target review remain available. +A graph that declares a target-side cardinality, an `acyclic` edge or a +composition pair is not eligible either: those constraints depend on rows +outside the candidate's own scope. On an Operational Identity graph, a custom Store runtime must also expose endpoint-scoped and assertion-ID-scoped identity reads. Without both reads, -ordinary candidate planning uses the complete working-copy clone and V1 review -remains available; an explicit V2 candidate-scoped review request is refused. +ordinary candidate planning uses the complete working-copy clone and +whole-target review remains available; an explicit candidate-scoped review +request is refused. Applicable store constraints still run during atomic application. Compatibility does not promise that apply will succeed: new rows may introduce constraint conflicts, and any write between revalidation and apply causes `StaleMergePlanError`. A failed application commits no partial candidate node, edge, or identity writes. Revalidate again after a stale refusal; require reapproval if -the result changes. +the result changes. This includes an `acyclic: true` edge kind: canonicalization +and repointing can close a cycle out of edges that were individually fine in +every branch. Every entry point (`merge()`, `mergeAgainstBase()`, `planMerge()`, +`planMergeIncremental()`, `mergeIncremental()`) checks the resolved plan's +projected edge writes for such a cycle at PLAN time, before anything is +written — including a cycle formed entirely from edges the plan itself +proposes, with nothing live on the target yet. Edges the same plan deletes +are left out, so a branch that reverses an edge (`a → b` removed, `b → a` +added) or re-roots a chain is judged by the DAG it produces and merges. A +violation surfaces as the +typed `AcyclicityMergeConflictError` (`code: "GRAPH_MERGE_ACYCLICITY_CONFLICT"`), +naming the relation and every offending edge in `details`, so a `planMerge()` +review sees it before deciding whether to apply. Only a cycle that arises from +a write racing the plan-time check (which holds no per-graph lock, since +planning does no write) escapes it, and is still caught by the unchanged +apply-time write path: apply refuses with `MergeConstraintConflictError` +wrapping the underlying `EdgeAcyclicityError` — the same generic +declared-constraint translation cardinality, disjointness, and uniqueness +conflicts already take, because apply writes every edge through the store's +own collection API, which already enforces it. `policy.id` identifies your policy implementation; `policy.context` explicitly records every opaque dependency that can change its decision. Include callback @@ -617,10 +672,10 @@ reuse approval. Enforce artifact immutability and access control in your storage or application. The review contains candidate data and an entire reviewed plan, so protect it with the same care as graph data. -V1 review capture and revalidation read and fingerprint the complete target +Whole-target review capture and revalidation read and fingerprint the complete target graph and archival identity ledger. The artifact stores one fingerprint per original row plus expected absences. Budget graph-sized reads and artifact -storage for V1. V2 candidate-scoped review uses bounded point and identity +storage for it. Candidate-scoped review uses bounded point and identity closure reads for its baseline on eligible graphs. The execution receipt above is a separate commit. If its write fails or the @@ -667,10 +722,25 @@ Import `MergePlanApplyOptions`, `MergePlanReadContext`, and `MergePlanApplied` from `@nicia-ai/typegraph/graph-merge` to type reusable helpers. Callbacks are execution options: they are not stored in the artifact or covered by its digest. +Beside the callbacks, `applyMergePlan()` accepts the provenance it records on +every identity transition the apply causes (`MergePlanDecisionOptions`): +`review`, the `compatible` result of `revalidateCandidateWriteSetReview()` the +plan was approved under, and `sourceId`, the candidate write set's source. A +`review` is evidence about one plan: the apply records its `reviewDigest` only +when the revalidation's fresh `plan` is the plan being applied, and refuses +with `InvalidMergeOptionsError` (`details.reason` `"plan-mismatch"` or +`"not-compatible"`) before writing anything otherwise. +`applyMergePlanInTransaction(target, tx, artifact, { review, sourceId })` +accepts the same two. An apply option neither path declares is refused with +`InvalidMergeOptionsError` naming it in `details.option`. + The transaction acquires the schema fence before the graph write lock, validates schema, revision origin, and revision, and then calls `beforeApply`. This context exposes node and edge collection reads and, for identity-enabled graphs, identity -reads. Write methods, native SQL, and a root Store are absent. Application writes +reads. Its `planDelete` is the transactional form: it writes no graph row, but it +takes the delete's lock, which apply already holds (see +[Previewing a delete](/ontology#previewing-a-delete)). Write methods, native +SQL, and a root Store are absent. Application writes before plan application are deliberately unsupported: an uncommitted write can change the reviewed state without changing its durable revision yet. @@ -746,9 +816,12 @@ fallback is acceptable. Turning revision tracking off does **not** turn off all serialization. *Constrained* writes now take the same per-graph mutual exclusion regardless of `revisionTracking` or `history`, because their check-then-write is only sound if -nothing else writes the graph in between: edge cardinality (`one`, `unique`, -`oneActive`, and the `getOrCreateByEndpoints` create and resurrect legs), -node-kind disjointness on create, and a `kindWithSubClasses` uniqueness +nothing else writes the graph in between: edge cardinality — both the +source-side axis (`one`, `unique`, `oneActive`) and the independent +[target-side axis](/core-concepts#target-cardinality) (`targetCardinality: +"one" | "oneActive"`), including the `getOrCreateByEndpoints` create and +resurrect legs on either axis — node-kind disjointness on create, and a +`kindWithSubClasses` uniqueness constraint that actually expands to more than one kind — a scope covering a single kind probes exactly the row the uniques table's primary key then reserves, so that key is already its fence. Everything else — an unconstrained @@ -917,6 +990,39 @@ const result = await merge(base, branches, { A `vector`/`hybrid` strategy with no embedder configured fails with a typed `SimilarityUnavailableError`, never a silent no-op. +### Identity separation veto + +On a graph with `identity` enabled, a current `different` assertion between +two nodes' identity classes vetoes a match **at plan time**, whichever source +proposed it. There is no option to state: a `different` assertion is an +integrity fact, so the veto runs for every identity-enabled merge. It honors +an assertion the target already holds and one a branch being merged adds +alike: a branch can record `assertDifferent(a, b)` to keep entity resolution +from fusing two entities it stages. Of the assertions a branch adds, only +one that still holds counts: a `different` the branch asserted and then +retracted, or ended by deleting an endpoint, vetoes nothing, and an ended +`same` joins nothing. If the match then fuses its two entities, the ended +`different` has no pair left to describe and is reported on `dropped` with +reason `identity:ended-different-collapsed`. The target's own ledger is judged +as it stands before the merge: a target-held assertion that a branch retracts +still separates (or joins) for the veto, because which retractions the merge +applies is settled later in planning. Merge again once the retraction has +landed. + +| Proposed match | Outcome | +| --- | --- | +| A **scored** candidate pair | Dropped. The merge continues, both entities land as staged, and the vetoed pair is reported on [`MergeReport.identityConflicts`](#identity-conflicts) | +| A **definitional** match (a shared unique value, a `blockIndex` hit, an ontology retype) that survives the base and diameter guards | The plan fails with `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT`, naming both entities, the separating assertion and the sources that proposed the match | +| A **transitive** fusion — `a`–`b` and `b`–`c` each pass the threshold while `a` and `c` are held apart | The plan fails with `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT`, naming the separated pair and the whole cluster | + +This converts what would otherwise be a commit-time database constraint +violation into an upfront, attributed outcome. + +A `same` assertion is not a candidate source. Asserting `same` between two +nodes records identity truth — a class, honored by every identity-aware read — +and the merge carries that assertion across, but it never rewrites the two rows +into one. Rows are fused only by [entity resolution](#entity-resolution). + ## Conflicts When merged contributors disagree on a property value, Graph Merge **resolves by @@ -951,6 +1057,46 @@ const result = await merge(base, branches, { }); ``` +### Identity conflicts + +A merge carries each branch's identity assertions and retractions to the +target, classifying every pair three ways against the target's current truth. +The rules are fixed; there is no policy to choose. + +| Staged shape | Outcome | +| --- | --- | +| Branches assert the same pair under different assertion ids | One survivor is written: an id the target already holds wins, then the earliest `validFrom`, then the code-point-smallest id. Every other id is reported on `dropped` with reason `identity:duplicate-assertion` | +| Branches end the same assertion at different instants | One retraction is written, at the **earliest** staged `validTo` — independent of branch order | +| One branch retracts a pair and re-asserts it itself | Both are written: the base assertion ends at the staged instant and the replacement lands | +| One branch retracts a pair while a **different** branch re-asserts it without retracting | The merge fails with `GRAPH_MERGE_IDENTITY_CONFLICT`, naming both assertions and both branches | +| A branch re-asserts a pair and then ends that re-assertion, or records a bounded historical window for it | Not a re-assertion: only an assertion that still holds re-asserts a pair. The ended row lands as history beside the other branch's retraction | +| A branch's assertion names a node a branch deleted | The merge fails with `GRAPH_MERGE_IDENTITY_CONFLICT` while the assertion still holds. One that ended — retracted, or ended by that very deletion — lands as history | +| A branch's assertion names a node the incremental target deleted after the fork | The merge fails with `GRAPH_MERGE_IDENTITY_CONFLICT` at plan time, whether the assertion still holds or ended: the target's earlier deletion leaves no endpoint covering the assertion's window | +| Entity resolution fuses the two endpoints of an assertion a branch adds | A `same` is dropped as `identity:endpoints-collapsed` and an ended `different` as `identity:ended-different-collapsed`; a current `different` fails the merge with `GRAPH_MERGE_IDENTITY_CONFLICT` | +| Entity resolution fuses the endpoints of an assertion the target already holds | Nothing is written for it: the row stays on the target as it is and is not reported on `dropped` | +| Branches assert `same` and `different` for one pair over overlapping validity windows | The merge fails with `GRAPH_MERGE_IDENTITY_CONFLICT`, naming both assertions | +| A branch's assertion contradicts the target's history — a row the target had ended (which a branch does not carry), a base row another branch retracts, or a row over a node the merge deletes, over the time it still held | The merge fails at plan time with `GRAPH_MERGE_IDENTITY_CONFLICT`, naming the `different` assertion, the class it falls inside and the overlapping `window`. On an incremental merge, such a row the target commits and ends between planning and the commit fails it with `BaseVersionMismatchError`: re-plan | +| A scored candidate match spans two identity classes held apart by a `different` assertion | The match is dropped and reported (see below); the merge continues | + +Every staged assertion and retraction therefore ends as a write, a `dropped` +entry with a reason, or a refusal that names it — none disappears silently. A +refusal writes nothing: reconcile the branches (retract one side, or re-assert +on the branch that retracted) and merge again. + +`MergeReport.identityConflicts` — and `review.identityConflicts` inside the +durable plan artifact — lists each scored match the +[separation veto](#identity-separation-veto) dropped: + +| Field | Meaning | +| --- | --- | +| `kind` | `"separation"` | +| `a`, `b` | The two entities the candidate source proposed as one | +| `assertionIds` | The `different` assertion holding their classes apart | +| `source` | The recall path that proposed the match | + +A plan carrying an `identityConflicts` entry is still applicable: the vetoed +match is simply not made. + ### Delete / modify conflicts An inherited node or edge that one branch **deletes** while another **modifies** @@ -969,6 +1115,99 @@ that change with no conflict; only fields multiple branches changed to differing values become conflicts. This holds for node *and* edge properties, so disjoint edits compose instead of clobbering each other. +### Composition orphans + +Three independent ways a merge can separate a composition part from its +whole behind the target's back, all surfaced through the same +`MergePlanReview.compositionOrphans` finding and the same +`MergeCompositionOrphanError`: + +- **`cause: "deleted"`** — a branch deletes a composition whole (see + [Composition](/ontology#composition)) while a part attached to it on the + target *after* the branch point, or independently of it, survives — the + plan neither deletes that part nor moves it to another whole, so applying + the plan as trusted would leave it pointing at a whole that no longer + exists. +- **`cause: "moved"`** — the mirror case. The plan deletes a part — because + the branch deleted it, or because the branch deleted its old whole and the + cascade took the part with it — that the target has meanwhile moved under, + or attached to, another live whole the plan neither deletes nor detaches it + from. Applying the plan would take the part away from a whole the branch + never touched; `whole` names that surviving whole. +- **`cause: "unattached"`** — a required-existence part (`existence: + "required"`, see [Composition existence](/ontology#existence-a-part-that-cannot-exist-without-a-whole)) + this merge writes, or whose composition edge this merge explicitly deletes + or ends, resolves to no live whole at all after canonicalization. There is + no whole to name for this cause, so `whole` is absent. + +`planMerge` and `planMergeIncremental` judge all three against the state the +plan would leave — the target's current rows with the plan's own writes laid +over them — and report every finding in `MergePlanReview.compositionOrphans`. +A part the plan deletes or moves to another whole (a merged `reparent`) is not +a `"deleted"` orphan, and a required part the plan creates together with its +composition edge is not an `"unattached"` one. A part the plan deletes is +still reported as `"moved"` when the target holds it under a live whole the +plan keeps: + +```typescript +type MergePlanCompositionOrphan = { + part: { kind: string; id: string }; + whole?: { kind: string; id: string }; // absent for cause: "unattached" + viaEdgeKind: string; // the realizing composition edge + cause: "deleted" | "moved" | "unattached"; + branchId?: string; // the attaching branch, for a cross-branch refusal +}; +``` + +Two branches can also conflict with each other over one attachment: one +branch moves a part (`reparent`), attaches a new part, or re-attaches a part +by reopening an ended `oneActive` row (`clearValidTo`), while another +branch's deletion removes one end of that attachment. Merged as-is, the +attaching branch's composition edge would be dropped as +`edge:endpoint-deleted` (a reopened row is absorbed by its deletion without +even that) and its move would be lost. Every planning entry +point (`merge`, `mergeIncremental`, `planMerge`, `planMergeIncremental`) +therefore refuses at plan time with `MergeCompositionOrphanError`, and +`details.branchId` names the attaching branch: + +- `cause: "moved"`: the part is deleted (directly, or by its old whole's + cascade) while the attaching branch put it under a whole that survives. + `whole` names that surviving whole. +- `cause: "deleted"`: the whole is deleted while the attaching branch put a + surviving part under it. `whole` names the deleted whole. + +Because no target write can resolve this conflict, it is refused while the +plan is built rather than reported in `compositionOrphans` for apply to +re-check. The refusal comes before the plan-time report, so a required-existence part caught here is not also +reported as `"unattached"`. If the plan deletes both the part and the whole, +or the edge does not realize a composition pair, the edge is an ordinary +`edge:endpoint-deleted` drop and the merge proceeds. A finding without +`branchId` is about the target's own state, including the incremental +target's own moves. + +This is a best-effort, unlocked dry-run read, surfaced for an operator to act +on before approving the plan. `applyMergePlan` re-verifies the same finding +inside the apply transaction, under the per-graph write lock, and refuses with +`MergeCompositionOrphanError` (see [Errors](/errors#mergecompositionorphanerror)) +if it still recurs there — so a plan-time report that comes back empty is not +a guarantee against a concurrent attach racing the eventual apply. A +composition edge canonicalization drops as `edge:endpoint-deleted` issues no +write, and none is needed to catch it. If a branch made that attachment, +planning refuses as described above. If the edge already attached its part at +the branch point, the plan deletes one of its endpoints. A deleted part leaves +nothing to orphan. For a deleted whole, the `"deleted"` check reads the +target's live parts of every whole the plan deletes: a surviving part is +reported at plan time and refused inside the apply transaction. Running +`store.verifyConstraintFences()` after a merge is a generic audit, not a step +the merge relies on. + +A branch's composition writes merge as the unit they were written as. Apply +replays a plan one row at a time, so it postpones the per-write +required-existence rule for exactly the parts the plan touches and asserts it +for those same parts once the plan's last edge has landed, inside the same +transaction: a required part created with `partOf`, and a `reparent` under +either cardinality, merge like any other edit. + ## Edges follow their entities When nodes collapse, their edges must too. After clustering, Graph Merge: @@ -1015,17 +1254,24 @@ contributes no claim and raises no conflict, whatever its branch's rank. ## Ontology type reconciliation With `reconcileTypes: "ontology"`, two staged nodes that share an id but carry -subtype-compatible kinds (via the graph's `subClassOf` closure) are collapsed to -the **most-specific** common type, recorded as a `TypeReconciliation`. A base -`Doctor` and a branch `SpecialistDoctor` reconcile to `SpecialistDoctor` instead -of being dropped as incompatible. The default `"off"` keeps identity strictly -`(kind, id)`. +subtype-compatible kinds (via the store's own validated `KindRegistry` — the +same registry a query runs against, not a private closure the merge recomputes) +are collapsed to the **most-specific** common type, recorded as a +`TypeReconciliation`. A base `Doctor` and a branch `SpecialistDoctor` reconcile +to `SpecialistDoctor` instead of being dropped as incompatible. The default +`"off"` keeps identity strictly `(kind, id)`. ```typescript const graph = defineGraph({ /* ... */ ontology: [subClassOf(SpecialistDoctor, Doctor)] }); const result = await merge(base, branches, { reconcileTypes: "ontology" }); ``` +`equivalentTo` participates in "most specific" too, since it is mutual +subsumption (see [Ontology & Reasoning](/ontology#equivalence)): a base +`Doctor` and a branch `Physician` declared `equivalentTo` reconcile to whichever +of the two sorts first in code-point order, deterministically, rather than +being flagged incompatible. + ## Choosing the survivor By default a cluster's canonical survivor is the member with the @@ -1083,6 +1329,7 @@ type MergeReport = { typeReconciliations: TypeReconciliation[]; // ontology kind collapses // Node drops (deleted endpoints, incompatible members), edge drops, identity // drops (identity:duplicate-assertion, identity:endpoints-collapsed, + // identity:ended-different-collapsed, identity:empty-remapped-window, // identity:retraction-target-mismatch, identity:deletion-overruled), and // lower-bound deltas the commit cannot apply (window-not-applicable) dropped: DroppedItem[]; @@ -1094,6 +1341,7 @@ type MergeReport = { warnings: string[]; // non-fatal advisories (ceiling skips, provenance-persist failures) candidateDiagnostics?: CandidateDiagnostics; // bounded, opt-in scored comparisons provenancePersisted?: { graphId: string; count: number }; // when persistProvenance ran + identityConflicts: IdentityUnresolvedConflict[]; // scored matches the separation veto dropped }; ``` @@ -1112,6 +1360,10 @@ reconciliation. A same-id ontology retype remains a `TypeReconciliation`, rather than creating an id-merge resolution. Its optional `decisiveEdges` carries the accepted retype witness without changing the meaning of the existing resolution collection. +When a retype cluster also spans several ids, the id-merge resolution it does +produce names the **reconciled** kind in `EntityResolution.kind` — the kind the +canonical row is written under, the same value `TypeReconciliation.toType` +records — never the staged survivor's pre-retype kind. Each edge records every candidate source that proposed the pair in stable order. Definitional evidence names the trusted rule, such as a unique constraint, and @@ -1139,8 +1391,8 @@ type MatchEvidence = ``` Built-in source metadata distinguishes block, unique, base-unique, base-index, -keyless, and ontology-retype proposals. Several sources proposing the same pair -are all retained after deduplication. Strategy metadata describes `fulltext`, +keyless, and ontology-retype proposals. Several +sources proposing the same pair are all retained after deduplication. Strategy metadata describes `fulltext`, `vector`, `hybrid`, or `custom` configuration, never custom function source. Default evidence excludes the raw compared values and rejected pairs because those may contain PII and can make reports enormous. @@ -1352,6 +1604,12 @@ Node and edge inserts, updates, and deletes are recorded by database triggers. Identity-only revisions and revisions whose write provenance is incomplete produce `{ kind: "unbounded" }` rather than an incomplete key list. Custom backends must provide their own lineage capability to get bounded results. +`store.changesSince(anchor)` holds to the same rule on a history-capturing +store: the keys name nodes and edges only, so a span in which an identity +assertion was created, retracted or ended answers `unbounded` rather than a +key list that omits it. The lineage source `resolveLineage(store)` returns is +unchanged — a merge reads identity through its own path, and its pruned diff +keeps a bounded node and edge delta across an identity write. Each trigger is attached to a whole physical node, edge, or identity table; it records every write to that table and uses `graph_id` to identify the affected graph. On shared tables this captures writes from every graph, not only graphs @@ -1553,7 +1811,12 @@ custom status table names; pass `reopenOptions` to restore process-local hooks or query options on a later process. An external `recordedRead` binding is refused because its relation is outside the owned table inventory. Reopen options cannot replace the allocation's schema, recorded-read binding, -history mode, or revision-tracking mode. Pass `operations` to let +history mode, or revision-tracking mode. An allocation keeps the base-schema +version of the release that provisioned it; reopening one under a later release +adopts the base-schema steps it is missing in place, through the `connect` +session (the role that created the allocation's relations), before the Store +opens. An allocation stamped by a newer release is refused, with a +`BaseSchemaMigrationError` as the reopen error's cause. Pass `operations` to let `durable.operations` commit host mutations atomically with immutable evidence; see [Atomic operations and immutable evidence](#atomic-operations-and-immutable-evidence). Each durable allocation also owns an evidence table (`op_evidence`) in @@ -1694,7 +1957,9 @@ tables that removal never finds. no schema. They resolve through the session that removes them and reopen without binding, and follow the same removal rule: relations found in a schema other than the removing session's refuse removal and name that schema. `control` adds the column to an - existing ledger the first time it runs. + existing ledger the first time it runs. Reopening such an allocation also + adopts any missing base-schema steps unqualified, under the pooled + connection's `search_path`, exactly as its other statements resolve. The connection must still be able to *resolve* the allocation's tables, so its `search_path` must include the schema, typically `public`. A per-role `"$user"` @@ -1854,9 +2119,12 @@ read once through `Store.workingCopyOptions`, plus `history`/ `revisionTrackingEnabled`. This is safe precisely because a fork is the SAME physical database as the base: a custom `schema` names relations the fork carries too, and an external `recordedRead` binding points at one. The clone -strategy inherits only `revisionTracking` — its fresh backend is a distinct, +strategy inherits `revisionTracking` and `queryDefaults` only — a query answers +alike on the base and on its clone, but the clone's fresh backend is a distinct, empty database, so a schema naming the base's tables or a `recordedRead` -binding populated nowhere on the clone would misdirect it. +binding populated nowhere on the clone would misdirect it. Hooks, upsert +coalescing, and the auto-refresh-statistics threshold are not carried to a +clone. Because the fork's store reads and writes through the base's table names, `connect()`'s backend must bind those SAME names. `create()` compares the @@ -2065,7 +2333,8 @@ the target Store transaction. The former native-merge callback was removed: it could commit outside the transaction that checked the target revision. A future native merge capability needs a host-native compare-and-swap on the actual target, plus proof that the full physical diff equals the approved -TypeGraph writes, including schema, history, identity, and sidecars. +TypeGraph writes, including schema, history, identity, composition, and +sidecars. For a strategy on a version-controlled SQL engine, pin each Store connection to the intended database branch using the engine's connection-level revision @@ -2325,8 +2594,10 @@ For a bounded candidate batch, `planCandidateWriteSet()` hides the transient branch lifecycle completely. It accepts a validated, versioned JSON document, stages it through the same constraint-aware ingestion implementation, delegates to incremental merge planning, and closes the working copy on every outcome. -The result is the ordinary `MergePlanArtifact`, so review and application use -the same APIs as every other merge plan. +The result's `plan` is the ordinary `MergePlanArtifact`, so review and +application use the same APIs as every other merge plan; beside it, +`planningScope` reports whether staging was bounded to the candidate (see +[Planning scope](#planning-scope)). On eligible revision-tracked graphs, planning seeds existing candidate rows, edge @@ -2364,7 +2635,7 @@ const writeSet = { edges: [], } as const; -const plan = unwrap( +const { plan, planningScope } = unwrap( await planCandidateWriteSet({ target: store, makeBackend, @@ -2401,7 +2672,7 @@ keeps the complete graph schema and rejects the duplicate during staging, before entity resolution can review and collapse it. An ingestion branch materializes an honest working-copy schema with only node uniqueness deferred; schema validation, edge endpoint checks, disjointness, and edge cardinality -still apply immediately. +(both the source and target axis) still apply immediately. ```typescript import { asNodeId } from "@nicia-ai/typegraph"; @@ -2491,6 +2762,44 @@ not disable database constraints ad hoc. Ingestion branches require a backend with the batch uniqueness operations needed for atomic final validation. Unsupported backends are refused rather than falling back to sequential checks. +#### Planning scope + +`planCandidateWriteSet()` returns `{ plan, planningScope }`. `plan` is the +ordinary `MergePlanArtifact`; `planningScope` records which rows seeded the +transient working copy. It is a planning-cost fact rather than plan content: +both scopes produce the same correct plan, so the scope is not part of the +artifact or its digest. + +```typescript +type CandidatePlanningScope = + | { scope: "candidate" } + | { scope: "whole-target"; reason: CandidatePlanningScopeReason }; +``` + +| `reason` | Cause | +| --- | --- | +| `composition` | The graph declares a `partOf`/`hasPart` pair | +| `acyclic-relation` | An edge is declared `acyclic: true` | +| `target-side-cardinality` | An edge declares `targetCardinality` | +| `missing-active-source-read` | A `oneActive` edge on a backend without `findActiveEdgesBySourceV1` | +| `missing-match-identity-read` | A `matchIdentity` edge on a backend without `findEdgesByMatchIdentity` | +| `missing-scoped-identity-reads` | An identity graph whose Store runtime lacks the endpoint- and assertion-ID-scoped identity reads | +| `match-identity-owner-not-clone-visible` | A candidate edge names a durable owner the clone projection excludes | +| `planned-evolution` | `planCandidateWriteSetForEvolution()` always stages the whole resulting graph | + +Pass `planningScope: "candidate"` to make the candidate scope a requirement +rather than a preference, for example to bound planning cost on a large +target. When the target or candidate needs the whole target, planning is +refused with `CandidatePlanningScopeError` +(`GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE`) before any staging backend is +created, and `details.planningScope.reason` names the cause. + +Neither scope changes what is refused for other causes. A target that cannot +carry a public merge plan (no revision tracking, or no interactive +transactions) is refused with `MergePlanCapabilityError` before any staging +backend is created, and a candidate the target's schema refuses reports its own +`CandidateWriteSetError`, with or without `planningScope: "candidate"`. + ## Valid-time windows **A new row's window travels with the merge.** An explicitly open-left row stays @@ -2657,7 +2966,9 @@ TypeGraph does not publish it. `abort()` atomically removes the copied graph and operation marker while preserving unrelated namespaces, and refuses if the target has changed. A retry with the same operation key returns the same proof after checking the target digest and base token; a different key cannot reuse -the populated target. +the populated target. The proof survives a library upgrade: a graph relation a +later release adds enters the digest only once it holds rows for the graph, so +a fork taken before the upgrade stays retryable and abortable after it. This first-party copy supports the bundled PostgreSQL table layout, bundled `pgvector` embedding storage, and default `tsvector` fulltext storage. @@ -2705,8 +3016,9 @@ commit after a partially applied failure: | ---------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `BranchError` | `branch()` or `ingestionBranch()` could not materialize a working copy. | | `BaseVersionMismatchError` | A branch forked from a different `base@V` than the target now has (snapshot `merge()`). Also the typed replan error `mergeIncremental()`'s in-transaction guards raise, and the by-ID freshness check both commit modes run, when the target moved in the plan→commit window. | -| `IdentityMergeConflictError` | Code `GRAPH_MERGE_IDENTITY_CONFLICT`. Thrown by both `merge()` and `mergeIncremental()` for identity contradictions, assertion-ID collisions, and retract/reassert races. See the [identity guide](/identity/#interchange-and-branch-merge). | -| `MergeConstraintConflictError` | Code `GRAPH_MERGE_CONSTRAINT_CONFLICT`. The resolved plan would violate a deterministic store constraint, such as edge cardinality or node uniqueness. Its category is `constraint`, its `cause` is the original typed store error, and its details expose the original constraint fields. No graph or provenance writes commit. | +| `IdentityMergeConflictError` | Code `GRAPH_MERGE_IDENTITY_CONFLICT` by default. Thrown by both `merge()` and `mergeIncremental()` for identity contradictions, assertion-ID collisions, opposing relations, and retract/reassert races — see [Identity conflicts](#identity-conflicts) and the [identity guide](/identity/#interchange-and-branch-merge). One related code on the same error class names a more specific cause: `GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT` (a definitional match or a transitive cluster crosses a class-lifted `different` assertion — see [Identity separation veto](#identity-separation-veto)). | +| `AcyclicityMergeConflictError` | Code `GRAPH_MERGE_ACYCLICITY_CONFLICT`. Thrown at plan time by every entry point (`merge()`, `mergeAgainstBase()`, `planMerge()`, `planMergeIncremental()`, `mergeIncremental()`) when the resolved plan's edge writes — after canonicalization and repointing — would close a cycle in a declared `acyclic: true` relation. Its `details` name the relation and every offending edge. | +| `MergeConstraintConflictError` | Code `GRAPH_MERGE_CONSTRAINT_CONFLICT`. The resolved plan would violate a deterministic store constraint, such as source- or target-side edge cardinality or node uniqueness. Its category is `constraint`, its `cause` is the original typed store error (a `CardinalityError` with `details.direction` for a cardinality conflict), and its details expose the original constraint fields. No graph or provenance writes commit. | | `InvalidMergeOptionsError` | Code `GRAPH_MERGE_INVALID_OPTIONS`. The supplied option combination is invalid, `mergeIncremental()` was given the snapshot-only `target` option instead of silently ignoring it, or `mergeIncremental()`'s `onBasePropertyConflict` is not `"flag"`. | | `SimilarityUnavailableError` | A `vector`/`hybrid` strategy was requested with no `embedder`. | | `MergeConflictError` | A conflict could not be resolved under the configured policy. | diff --git a/apps/docs/src/content/docs/identity.md b/apps/docs/src/content/docs/identity.md index 083c88ae6..58e22a975 100644 --- a/apps/docs/src/content/docs/identity.md +++ b/apps/docs/src/content/docs/identity.md @@ -308,6 +308,345 @@ materialized closure used by current reads and by `asOf(now)` reads is identical — a fixed-point reconstruction of "current" never needs to special-case valid-time skew on the folding edge itself. +## Replay and identity history + +A store opened with `history: true` retains an explanation, not just a +current answer, for every event that changed a class's membership: + +```typescript +const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, +}); +``` + +`store.identity.transitionsOf(ref)` returns a **page** of the transitions +that changed `ref`'s own identity class, oldest first — an assertion, a +retraction, a same-ID fold, a delete or restore, a validity-window end, a kind +drop, a schema transition, or an identity change a graph merge applied. A +transition is `ref`'s when `ref` was a member of the class it names just before +or at the revision it was recorded at. A class `ref` has left keeps a history of +its own, and none of that later history is reported for `ref`. Membership is +judged against the kinds the log names and, once a schema transition was +recorded, under both same-ID settings, so the history that put `ref` in a class +survives a later kind drop or a change to `sameIdAcrossKinds`. A page covers +at most `limit` recorded boundaries (default 200), and one boundary can hold +several transitions, so a page's `transitions` can be longer than `limit`; +when the lineage has more boundaries, the page carries `nextCursor`, and +passing it back as `cursor` reads the next one. Reading the whole lineage is +therefore a loop, not a call: + +```typescript +let cursor: TransitionPageCursor | undefined; +do { + const page = await store.identity.transitionsOf(alice, { + ...(cursor === undefined ? {} : { cursor }), + }); + for (const transition of page.transitions) { + console.log(transition.cause, transition.recorded, transition.assertionIds); + } + cursor = page.nextCursor; +} while (cursor !== undefined); +``` + +:::note[What replay and transition history can and cannot tell you] + +- **Membership is reconstructed under the current schema.** A `replay` step + recorded before a kind drop or a `sameIdAcrossKinds` change shows that + revision as today's schema reads it, not as the schema then in force did + ([Replay steps](#replay-steps)). +- **Restored transitions are explanations, not replay steps.** + `transitionsOf` returns the transitions an archival restore brought in; + `replay` never pairs one with a before/after ([Archival transitions and the + retention watermark](#archival-transitions-and-the-retention-watermark)). +- **Transaction reads omit pending notes.** On `tx.identity`, both read the + committed log, so a change the same transaction made is missing until it + commits ([Inside a transaction](#inside-a-transaction)). +- **Pruned history is gone.** Nothing below the retention watermark is + retained ([Retention](#retention)). +- **Only captured changes are logged.** Capture sees only writes made after + `history: true` was enabled ([no + backfill](/limitations#recorded--system-time-history-true)), so a class + whose members joined, or were created, before then is not explained by the + log, and transitions that involved those members can be missing from the + page. + +Every page carries `completeness`, the one place to check whether it is the +whole answer the log can give: it reports the last four, more pages, and +restored transitions discovery could not attribute +([Completeness](#completeness)). Current-schema reconstruction applies to +every replay, so it is not reported per page. + +::: + +A single call without the loop reads only the first page; a lineage short +enough to fit in one page returns no `nextCursor`, which is what ends the loop. +"Oldest first" is by this graph's recorded revision. A transition an +archival restore brought in keeps the *source* graph's revision, so it can +list before or after this graph's own rows regardless of when it happened; +an audit timeline that mixes the two should order by `recorded` and +`restored.at` rather than by list position. + +A lineage is discovered from the reference itself, not only from the class +it belongs to now. A member that has left its class — deleted, retracted +apart, or folded out — is a singleton in current state, and its departure is +noted against the class that survives it, so `transitionsOf` and `replay` +also follow the recorded evidence that once tied the reference to a class: +every `same` assertion that ever named it and, on a folding graph, every node +that ever shared its id. Asking for the history of a deleted node therefore +returns the assertions, folds and the detach that explain it, and `replay` +pairs them with the membership it had at each boundary. Of what that search +finds, only the transitions recorded while the reference was in the class they +name are returned: each candidate boundary is checked against the reference's +class reconstructed just before and at it, the same reconstruction `replay` +pairs each step with. A page therefore reads, beyond the boundaries it returns, +the ones of classes the reference had already left that sit between them. + +That evidence is this graph's own recorded history. An archival restore +brings transitions in without the source graph's history behind them, so a +restored transition that discovery reaches from neither side can be neither +tied to the reference nor ruled out. When the graph holds such transitions, +the result's `completeness` says so with an `unattributed-restored` reason +carrying their count, instead of returning a short page that looks complete +(see [Completeness](#completeness)). The reason is absent whenever every +restored transition in the graph belongs to the lineage that was read, and +always absent on a graph that never restored an archive. + +A transition that an archival restore brought into this graph — rather than +this graph's own history capture recording it — carries `restored`, whose +`at` is the destination's wall clock at restore time. That is the marker +`replay` itself uses to leave the row out of `steps`, made public so an audit +view can label an imported explanation instead of inferring it from a +revision comparison (see [Archival transitions and the retention +watermark](#archival-transitions-and-the-retention-watermark)). A restored +transition's `recorded` is the *source* graph's instant, so it is typed +`RestoredRecordedInstant` rather than `RecordedInstant`, and +`store.asOfRecorded` does not accept it: an anchor on this graph at a +revision the source allocated would read an unrelated state. Narrow on +`restored` (the `RecordedIdentityTransition` and `RestoredIdentityTransition` +variants) to tell the two apart. Its class membership on the source graph +cannot be reconstructed here either, so a restored transition is returned on +lineage discovery alone, and counted as `unattributed-restored` in +`completeness` when discovery cannot vouch for it. + +| Cause | Fires when | +| --- | --- | +| `assert` | An explicit `same` assertion (or a merge's own union) fused two classes | +| `retract` | A retraction split a class back apart — one row per resulting class whose new canonical the lineage touches, so one `retract` call on a class of three or more members can record more than one transition | +| `fold` | A same-ID fold conducted a newly created or resurrected node into a class | +| `detach` | A class member was deleted (soft or hard), whether it was held by a same-ID fold or by explicit `same` assertions. It therefore also appears on a graph with `sameIdAcrossKinds: "ignore"`, where nothing folds | +| `restore` | A soft-deleted node's undelete brought it back into visibility | +| `window-end` | A node's or assertion's validity window closed, ending its contribution | +| `kind-drop` | A schema change removed a kind, hard-deleting the identity assertions it touched | +| `schema-transition` | A schema evolution reinterpreted membership under new `sameIdAcrossKinds` or ontology rules | +| `reconcile` | A graph merge applied the assertion or retraction; the transition's `decision` names the branch, the branch ancestry, and the plan and review digests when a reviewed plan was applied (see [Graph merge](/graph-merge/#identity-conflicts)) | + +`cause` names the mechanism that moved the membership; `decision` names what +governed it. A graph merge therefore stamps its `decision` on **every** +transition it causes, not only the `reconcile` ones: a class member the merge +deleted is a `detach` and a same-ID peer it created is a `fold`, each carrying +the same branch, ancestry and digests as the merge's `reconcile` rows. A +transition with no `decision` came from an ordinary API write. + +### Replay steps + +`store.identity.replay(ref, options?)` pairs every transition with the class +membership immediately before and after it, reconstructed through the exact +same historical reader, at the same coordinate, as `store.asOfRecorded` +(`historicalIdentityReconstructionCtes`). A step's `after` is what +`store.asOfRecorded(step.transition.recorded).identity.membersOf(ref)` +answers, and its `before` is the same read one revision earlier — replay is +not a second copy of membership. The transition log carries no members of its +own; it is an explanation layer over the one reconstruction path every +historical read already goes through. + +Two consequences of sharing that coordinate: + +- **Each step is read from the valid instant its own commit was recorded + at.** A validity window that was still open then stays open in that step, + however long after it lapses you ask for the replay, so the same step + always returns the same `before` and `after`. A `window-end` transition + records that a window was given an end; the member leaves in valid time, + which `store.asOf(...)` shows, not in the step. +- **Each step is read under the graph's current schema.** A step recorded + before a `sameIdAcrossKinds` flip or a kind removal shows that revision as + today's profile and kinds read it, so a `kind-drop` or `schema-transition` + step can show the same membership before and after. + +On a bulk write, each transition's `assertionIds` name only the `same` +assertions with an endpoint in that transition's class. + +```typescript +const { steps, completeness } = await store.identity.replay(alice, { + limit: 100, +}); +for (const step of steps) { + console.log(step.transition.cause, step.before, "→", step.after); +} +``` + +`options.fromRecorded` / `toRecorded` bound the range by recorded instant. +Bounding the ANSWER never bounds the LINEAGE SEARCH: discovery always walks +the whole log, because the note that names an earlier class canonical +routinely sits above the requested window (the walk starts at the node's +current canonical and hops backwards). A window that returned nothing would +otherwise be indistinguishable from a lineage that genuinely had no +transitions in it. + +`options.limit` (default 200, an integer from 1 to 2000 — anything else is a +`ValidationError`) caps the number of BOUNDARIES one page returns. Both +`replay` and `transitionsOf` page rather than refuse: a capped result carries +`nextCursor`, naming the first boundary it stopped short of, and passing that +back as `cursor` reads the next page. They page on +identical boundaries, so a `replay` page and a `transitionsOf` page taken +with the same options always stop at the same boundary — though `steps` can +be shorter than `transitions` on that page (see below): `nextCursor` names +where the page stopped, not how many revisions it covered. + +`nextCursor` addresses the transition log only. When the boundary it names +holds a restored row (see [Archival transitions and the retention +watermark](#archival-transitions-and-the-retention-watermark)), the revision +it names was minted by the *source* graph's clock, not this graph's. It is a +`TransitionPageCursor`, not a recorded instant: pass it only as the next +call's `cursor`. `store.asOfRecorded` does not accept it (a type error), and +`fromRecorded` / `toRecorded` refuse one at runtime with a `ValidationError` +rather than reading it as a window bound. A `cursor` that is not a page +cursor is refused the same way. A cursor is bound to the reference whose +lineage it pages: passing one returned for `alice` to a read of `bob` is +refused with a `ValidationError` rather than silently skipping every boundary of +`bob`'s below the revision it names. + +A cursor is a position within the windowed lineage, so it composes with +`fromRecorded` / `toRecorded` by intersection: the page starts at the later of +`fromRecorded` and the cursor and ends at `toRecorded`. Re-issuing a cursor +with a different window is therefore well defined, not an error. + +The same loop drives `replay`: pass `page.nextCursor` back as `cursor` +until it comes back `undefined`, choosing `limit` per page as the consumer +needs (the loop above takes the default). + +Both `replay` and `transitionsOf` throw `IDENTITY_REPLAY_REQUIRES_HISTORY` on +a store opened without `history: true` — there is nothing for them to +annotate. On a backend that owns recorded time itself (engine-native), they +throw `IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED` even with `history: true`: +TypeGraph captures no recorded history there, so it keeps no transition log. + +`replay` and `transitionsOf` live on `store.identity` and `tx.identity` +only, never on a coordinate-pinned read lens (`store.asOf(t).identity`, +`store.snapshot().identity`): both answer **across** every recorded +coordinate, which a lens pinned to one coordinate cannot honor. + +Restored transitions are never `steps`, but they are always +`transitions` — see [Archival transitions and the retention +watermark](#archival-transitions-and-the-retention-watermark) for the marker +that tells them apart. A `replay` page counts the ones it left out as +`restored-not-reconstructed` in its `completeness`. + +### Inside a transaction + +On `tx.identity` specifically, both are the one read on the transaction +facade that is **not** read-your-writes: they read the transition log table +directly, while a note for a write made earlier in the same transaction sits +buffered in the capture session until the transaction commits. `tx.identity +.assertionsOf(ref)` sees a pending `assertSame` immediately; `tx.identity +.transitionsOf(ref)` does not see the transition it noted until after +commit, when `store.identity.transitionsOf(ref)` does. While the transaction +holds such changes for the graph, every `tx.identity.transitionsOf` and +`tx.identity.replay` page reports `pending-transaction-notes` in its +`completeness`. The flag is graph-wide: it says the transaction changed some +class in this graph, not that the change touched this reference's lineage. A +write that changes no class, such as creating a node with no same-ID peer, +does not raise it. + +```typescript +await store.transaction(async (tx) => { + await tx.identity.assertSame(alice, bob); + await tx.identity.assertionsOf(alice); // includes the pending assertion + await tx.identity.transitionsOf(alice); // does NOT yet include its note; + // its completeness reports "pending-transaction-notes" +}); +await store.identity.transitionsOf(alice); // now includes it, post-commit +``` + +### Completeness + +Every `transitionsOf` and `replay` page carries `completeness`, which is +either `{ status: "complete" }` or `{ status: "partial", reasons }`. A +complete page is the whole answer the transition log can give for that +reference and range; a partial one lists every reason it is not, in this +order: + +| `reason` | Carries | Holds when | +| --- | --- | --- | +| `more-pages` | `nextCursor` | `limit` capped the page; the same cursor is on the page as `nextCursor` | +| `retention-truncated` | `before` (a `RecordedInstant`) | The range starts below the [retention watermark](#retention), so history below `before` is gone | +| `unattributed-restored` | `count` | The graph holds restored transitions lineage discovery could neither tie to the reference nor rule out; graph-wide, so the same on every page | +| `uncaptured-members` | `count` | Members of the reference's current class arrived through writes capture never saw (made before `history: true` was enabled, or bypassing capture): the member's node has no recorded version, or no recorded evidence ties it to the reference. Transitions that involved them can be missing from the page. Not reported alongside `pending-transaction-notes` | +| `restored-not-reconstructed` | `count` | `replay` only: this page's restored transitions, which are in `transitionsOf` but not in `steps` | +| `pending-transaction-notes` | — | `tx.identity` only: the transaction has made identity changes to this graph that the log does not hold yet (see [Inside a transaction](#inside-a-transaction)) | + +```typescript +const page = await store.identity.transitionsOf(alice); +if (page.completeness.status === "partial") { + for (const entry of page.completeness.reasons) { + console.log(entry.reason); + } +} +``` + +`completeness` does not report that a step was read under the current schema +(see [Replay steps](#replay-steps)): that holds for every replay. + +`uncaptured-members` is read from the current class, so it cannot see a +change made before capture was enabled that the class no longer shows (a +pre-capture merge that has since been undone), or a captured change that +involved a node before capture first recorded the node. On a graph with data +written before `history: true` was enabled, `complete` covers only what +capture recorded. + +### Retention + +Retained transitions are pruned only on explicit operator action — there is +no automatic retention policy: + +```typescript +import { pruneIdentityTransitions } from "@nicia-ai/typegraph"; + +await pruneIdentityTransitions(store, { beforeRecorded: someEarlierInstant }); +``` + +A prune deletes every transition strictly before the given recorded instant +and advances the graph's retention watermark monotonically — pruning at an +earlier point than the current watermark is a successful no-op, never a +rollback. Per the same rule `rebuildIdentityClosure` follows, **a prune does +not advance the content revision**: it destroys retained explanation, never +truth, so branch staleness tracks truth, not explanation. + +The watermark states that history below it is gone, so it can never sit above +history the graph has yet to record. A prune accepts a `beforeRecorded` up to +the revision the graph's next commit will take (`store.recordedNow()` plus +one, which prunes everything this graph has recorded), or one past the highest +retained transition when an archival restore brought in rows carrying higher +revisions from another graph. A `beforeRecorded` beyond that is refused with +`IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK` and changes nothing. + +Restored rows keep the revision their source graph minted, so a prune removes +them by that raw revision number alongside native rows. The returned +`prunedBeforeRevision` and the watermark itself never rise above the +revision the graph's next commit takes, even when the prune deleted restored +rows with higher revisions: the watermark describes this graph's own history, +and installing a higher one would report its later commits as already pruned. + +Once anything has been pruned, both `replay` and `transitionsOf` report the +gap rather than silently answering from an incomplete log: a page whose range +starts below the watermark carries a `retention-truncated` reason in its +`completeness`, naming the watermark as `before`. A page whose range starts +at or above it (a `fromRecorded` there, or a later page's `cursor`) does not. +`replay` additionally refuses a range that lies entirely below the watermark +with `IDENTITY_REPLAY_HISTORY_TRUNCATED`, since it has no step to pair; +`transitionsOf` answers that range with a page holding no transitions of this +graph's own (restored rows keep their source revisions, so some can still +fall in the range) that reports the cut. + ## Identity-expanded traversal Traversal expansion is per hop and defaults off: @@ -392,7 +731,7 @@ cheaper question by a wide margin. ## Interchange and branch merge -Interchange format `2.0` optionally carries an identity section. State export +Interchange format `3.0` optionally carries an identity section. State export (the default) includes current assertions. Import into a populated target is target-oriented: an existing current semantic pair keeps its target assertion ID and `validFrom`. Working-copy branch cloning imports into an empty target and @@ -430,6 +769,116 @@ deleted. Weigh that trade-off deliberately for a backup: without are silently absent; with it, those nodes come back alive. Recorded side tables are not part of interchange. +### Archival transitions and the retention watermark + +On a `history: true` graph, an archival export additionally carries every +retained transition (see [Replay and identity history](#replay-and-identity-history) +above) and, when the source has ever pruned, the source's own retention +watermark: + +```typescript +const archive = await exportGraph(store, { + identityMode: "archival", + includeDeleted: true, +}); +archive.identity?.transitions; // every retained transition, verbatim +archive.identity?.retention; // { prunedBeforeRevision, prunedAt } — absent if nothing was ever pruned +``` + +State export and working-copy branch cloning carry **no** transitions — +a clone's own history starts at its clone revision, and current-truth state +export is not a backup of explanation. `state`-mode identity import refuses +a document naming a `transitions` section (`IDENTITY_STATE_IMPORT_TRANSITIONS`) +or a `retention` watermark (`IDENTITY_STATE_IMPORT_RETENTION`) with a +`ValidationError`, before the import writes anything; a stream is refused at +its header. + +Restoring a `transitions` section validates **shape only**: a known cause, a +well-formed `{ kind, id }` reference on every `class` / `priorClass`, and a +non-decreasing `recordedRevision` sequence across the array +(`IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE`). `importGraph` checks it before +writing anything. `importGraphStream` checks each `identity-transitions` chunk +when it arrives, against the chunks before it as well as within itself — after +the stream's node, edge and assertion chunks have committed, since by protocol +the transitions come last. It never +touches the target's closure and never re-derives membership — the rows are +inserted verbatim, carrying the source's own revision numbers and +timestamps, because a restore records history, it does not relive it. + +A document (or stream) naming a `transitions` section, or carrying a +non-zero `retention` watermark, into a target opened without `history: +true` is refused with `IDENTITY_REPLAY_REQUIRES_HISTORY` — there is nowhere +for `transitionsOf` / `replay` to ever read those rows back from. Both +`importGraph` and `importGraphStream` refuse this **before writing any +node, edge, or identity assertion** — `importGraphStream` reads the +streaming header's `hasTransitions` announcement to know this before the +`identity-transitions` chunk itself, which by protocol always arrives last, +ever reaches the target. This is a **breaking change**: an archival export +of a `history: true` source that carries retained transitions or a +retention watermark now requires the restore target to also be opened with +`history: true` — previously the transitions section did not exist at all, +so nothing was silently dropped, but it also could not throw. Open the +restore target with `history: true` if it needs to accept archival exports +from a history-enabled source. A zero `retention` watermark records that the +source never pruned, restores nothing, and is accepted by any target. + +A stream whose header announces a restore — `hasTransitions`, or a non-zero +`retention` watermark — but that ends without an `identity-transitions` chunk +(a truncated stream, or a writer that left the trailing chunk out) is refused at +its end with a `ValidationError` (`IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING`). +Its earlier chunks have committed by then, but accepting it would report success +with neither the transitions nor the watermark the header promised. + +Restoring an archive that carries a live identity assertion also performs +that assertion as a real write on the target, so `history: true` records one +native (unmarked) transition for it beside the restored rows — on an empty +target as much as on one that already held the nodes. Every restored row is +marked as such, regardless of what the source graph thought of it: a restore +always inserts rows this graph did not record itself. The marker is public — `transitionsOf` returns it as +`transition.restored.at`, the destination's wall clock at restore time — so +an audit view can label an imported explanation. It is a wall clock and not a +`RecordedInstant` because a restore records history without reliving it: it +never advances the destination's own recorded revision counter, so there is +no revision on this graph's axis to pair the timestamp with. `replay` uses +that same marker — never a comparison against +`recordedRevision` — to decide whether a row may be paired with a +reconstructed before/after snapshot, because a restored row's revision is +minted by the *source* graph's own clock and interleaves arbitrarily with +this graph's; no floor on this graph's axis could tell "restored" from +"native" by number alone. `replay` over a restored archive therefore answers +`transitionsOf` fully but excludes every restored transition from `steps` — +an honest omission rather than a replay that pairs a restored transition +with a fabricated before/after reconstructed from the destination's own, +unrelated state. + +The restore also sets the destination's retention watermark to the +**destination's own recorded revision + 1, as it stood before the import +began**, but only when the graph had recorded no identity transitions of its +own by then. Both are read once, ahead of the import's first write — at the +start of `importGraph`'s transaction, and before the first chunk of +`importGraphStream` — so the two entry points restore one archive to the same +watermark, and the transitions the import's own node and assertion writes +record do not make the destination look as if it already had a history. A +fresh graph has +nothing of its own for that floor to misclassify, so setting it there is +safe, and `transitionsOf` and `replay` report it as the +`retention-truncated` completeness reason — the point below which +this graph's own timeline carries no retained explanation. What the restore +itself records sits at or above the floor and stays replayable. A graph that +already has its own retained transitions keeps its existing watermark +untouched: advancing it from a restore-time floor would otherwise +misclassify this graph's own, fully-retained history for classes the +restore never touched as pruned. Either way, the watermark is a coarser, +separate signal from the per-row marker above — it is never what decides +whether one row's transition may appear in `steps`. + +Archival transitions export is always **whole-graph**: unlike assertions, +`exportGraph`'s `nodeKinds` filter does not scope the transitions section. +Pairing a `nodeKinds`-filtered archival export with a target that never +receives the excluded kinds' nodes can restore transitions naming class +references the target does not have — shape validation accepts them, since +it checks reference shape, not referential presence. + Graph merge includes identity truth in staleness fingerprints and diffs. Duplicate current assertions use the earliest `validFrom`, then the code-point-smallest assertion ID — unless one candidate is already committed @@ -450,14 +899,22 @@ them and leaving nothing to separate cause from intent, so those endings count as cascades. `merge()` detects identity conflicts at plan time and returns them as a typed `IdentityMergeConflictError` — direct opposing relations on one endpoint pair, transitive contradictions reached -through a chain of `same` assertions no single branch wrote, retract/reassert -races, and an assertion over a node another branch deleted. A branch that -retracts a pair and also reasserts it itself (convergent, not racing) merges -cleanly. This is mechanical truth propagation, not semantic entity +through a chain of `same` assertions no single branch wrote, contradictions +with the target's history (rows it had ended, and rows the merge retracts, over +the time they held), retract/reassert races, and a still-current assertion over +a node another branch deleted. A +retraction and a reassertion of one pair are convergent, and merge cleanly, +only when every branch that retracts the pair also reasserts it; a branch that +only retracts it while another retracts and reasserts it is a race. Only an +assertion that still holds counts as a reassertion or conflicts with a +branch's deletion: one a branch retracted, or ended by deleting an endpoint, +lands as history. A node an incremental target deleted after the fork refuses +every branch assertion over it, ended or not. This is mechanical truth propagation, not semantic entity reconciliation. Plan time is the early surface, not the only one: any identity refusal that still escapes to the applier inside the commit transaction is translated into the same typed `IdentityMergeConflictError`, -with the original error preserved as its cause (identity environment and +with the original error preserved as its cause and no retry suggestion, since +re-planning against an unchanged target refuses again (identity environment and storage-corruption codes pass through untranslated — they are not statements about merge truth). See [`IdentityMergeConflictError`](/errors/#identitymergeconflicterror) @@ -528,8 +985,8 @@ flip rewrites the materialized identity closure and changes every `areSame`/`membersOf`/`includeIdentityMembers` answer against existing data — so it requires the same explicit `migrateSchema()` opt-in as any other breaking change; it never auto-migrates silently. Identity-relevant ontology -changes (`disjointWith`, `equivalentTo`/deprecated `sameAs`, or `subClassOf`) -are likewise persisted semantic migrations, not a local runtime toggle. +changes (`disjointWith`, `equivalentTo`, or `subClassOf`) are likewise +persisted semantic migrations, not a local runtime toggle. `createStoreWithSchema` and explicit `migrateSchema()` both rebuild and validate the closure atomically with the schema commit that carries the change. While the flip is unapplied, store construction refuses with @@ -547,9 +1004,12 @@ initialization — an empty database just makes them cheap no-ops. ## Migrating from type-level factories -The ontology factories `sameAs(A, B)` and `differentFrom(A, B)` are deprecated: -they relate **types**, not individual rows, and `differentFrom` never enforced -instance identity. To migrate: +The ontology factories `sameAs(A, B)` and `differentFrom(A, B)` were removed +(see +[Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis)): +they related **types**, not individual rows, and `differentFrom` never +enforced instance identity. To migrate code that used them for identity +purposes: 1. Add `identity: { sameIdAcrossKinds: "fold" }` to the graph. 2. Open it with `createStoreWithSchema` so the capability is persisted and @@ -557,7 +1017,7 @@ instance identity. To migrate: 3. Replace type-level facts with `store.identity` assertions between concrete node references. 4. Use `equivalentTo` or `disjointWith` when the intended relation is genuinely - between kinds. + between kinds — `equivalentTo` is a drop-in replacement for `sameAs`. On PostgreSQL, first-time enablement waits for in-flight node writes before it builds the initial identity closure. Quiesce or restart any store instances diff --git a/apps/docs/src/content/docs/interchange.md b/apps/docs/src/content/docs/interchange.md index 8c133fa56..1cdeecb29 100644 --- a/apps/docs/src/content/docs/interchange.md +++ b/apps/docs/src/content/docs/interchange.md @@ -48,7 +48,7 @@ const jsonSchema = toJSONSchema(GraphDataSchema); ```typescript interface GraphData { - formatVersion: "2.0"; + formatVersion: "3.0"; exportedAt: string; // ISO datetime source: { type: "typegraph-export" | "external"; @@ -90,6 +90,35 @@ interface GraphData { validFrom: string; validTo?: string; }>; + // `archival` mode only — see "Archival identity transitions" below. + transitions?: Array<{ + transitionId: string; + cause: + | "assert" + | "retract" + | "fold" + | "detach" + | "restore" + | "window-end" + | "kind-drop" + | "schema-transition" + | "reconcile"; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: { kind: string; id: string }; + priorClass?: { kind: string; id: string }; + assertionIds: string[]; + decision?: { + branchId?: string; + branchAncestry?: string[]; + mergePlanDigest?: string; + reviewDigest?: string; + sourceId?: string; + }; + }>; + // `archival` mode only, and only when the source has ever pruned. + retention?: { prunedBeforeRevision: number; prunedAt: string }; }; } ``` @@ -105,12 +134,74 @@ at or before that instant, in which case it is imported with no lower bound ### Format Version Compatibility -Exports always write `formatVersion: "2.0"`. The read side — both +Exports always write `formatVersion: "3.0"`. The read side — both `importGraph`/`importGraphStream` and `GraphDataSchema.parse` — additionally -accepts `"1.0"`. A 1.0 document is structurally a valid 2.0 document: the only -2.0 change is the additive optional `identity` section, so pre-existing 1.0 -exports validate and import unchanged. You never need to rewrite the version -field of an older backup; validation and import handle both. +accepts `"1.0"` and `"2.0"`. A 1.0 document is structurally a valid 2.0 +document (the only 2.0 change is the additive optional `identity` section), +and a 2.0 document is in turn a structurally valid 3.0 document (the only 3.0 +change is the additive optional `identity.transitions` / `identity.retention` +archival fields), so pre-existing 1.0 and 2.0 exports validate and import +unchanged. You never need to rewrite the version field of an older backup; +validation and import handle all three. + +### Archival identity transitions + +On a `history: true` graph, `exportGraph(store, { identityMode: "archival" })` +additionally carries every retained identity transition, plus the source's +own retention watermark when it has ever pruned. State export and working-copy branch cloning carry +neither field — a clone's own history starts at its clone revision, and +current-truth state export is not a backup of explanation. + +Restoring `identity.transitions` validates shape only (a known cause, a +well-formed reference, a `recordedRevision` a recorded instant can carry — +an integer from 1, below `Number.MAX_SAFE_INTEGER` — in a non-decreasing +sequence) and inserts every row verbatim. `importGraph` validates the whole +section before writing anything; `importGraphStream` validates each +`identity-transitions` chunk when it arrives, in order across chunks as well as +within one, after the stream's earlier chunks have committed. A restore never +re-derives membership or touches the target's closure. Every restored row is marked internally as such — a +restore always inserts rows this graph did not record itself, regardless of +what the archive's own history looks like. A replay over the restored graph +(`store.identity.replay`, see the [identity guide](/identity/#replay-and-identity-history)) +uses that marker, never a `recordedRevision` comparison, to exclude every +restored transition from its `steps` (a restored row's revision is minted by +the SOURCE graph's own clock and interleaves arbitrarily with the +destination's), so `transitionsOf` answers fully while `replay` never pairs +a restored transition with a fabricated before/after. + +The restore also sets the destination's own retention watermark to the +revision the restore's first commit takes on the destination — but only +when the destination recorded no identity transitions of its own before the +restore began. `importGraph` and `importGraphStream` set the same watermark +for the same archive. A graph that already retains its own history keeps its +existing watermark untouched, so an unrelated restore can never misreport that graph's own, +fully-retained classes as truncated. `transitionsOf` and `replay` report the +watermark, when set, as the `retention-truncated` reason of their +`completeness`. + +A `state`-mode document naming a `transitions` section or a `retention` +watermark is refused before anything is written, and a streamed one at its +header. Archival +transitions export is always whole-graph — an export's `nodeKinds` filter +does not scope the transitions section the way it scopes assertions. + +A document (or stream) naming a `transitions` section, or carrying a +non-zero `retention` watermark, into a target opened without `history: +true` is refused with `IDENTITY_REPLAY_REQUIRES_HISTORY`, before writing any +node, edge, or identity assertion. On the streaming protocol, the +`identity-transitions` chunk always arrives last (after nodes, edges, and +the `identity` assertions chunk), so `importGraphStream` reads a +`hasTransitions` boolean on the streamed header's `identity` object — set +whenever the export's transitions section is non-empty — to know this +before that chunk arrives. **This is a breaking change**: restoring an +archival export from a `history: true` source that carries retained +transitions or a retention watermark now requires the target to also be +opened with `history: true`; previously the transitions section did not +exist, so nothing was silently dropped, but nothing could refuse it either. +A zero watermark restores nothing and is accepted by any target. A stream whose +header announces a restore but that ends without an `identity-transitions` chunk +is refused at its end (`IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING`), since +accepting it would drop the transitions and the watermark the header announced. ## Exporting Data @@ -191,7 +282,7 @@ recorded as an `entityType: "identity"` entry in `result.errors`. | `nodeKinds` | `string[]` | all | Filter to specific node types | | `edgeKinds` | `string[]` | all | Filter to specific edge types | | `includeMeta` | `boolean` | `false` | Include version and timestamps | -| `includeTemporal` | `boolean` | `false` | Include validFrom/validTo fields | +| `includeTemporal` | `boolean` | `false` | Include validFrom/validTo fields. Always on for identity-enabled graphs and graphs with a `oneActive` cardinality (see the round-trip caveat below) | | `includeDeleted` | `boolean` | `false` | Include soft-deleted records | | `identityMode` | `"state" \| "archival"` | `"state"` | Export current identity assertions, or current plus ended assertions | | `signal` | `AbortSignal` | none | Cancel the export: roll its snapshot transaction back and release the connection. See [Cancelling an export](#cancelling-an-export) | @@ -211,10 +302,20 @@ from import time forward. Pass `includeTemporal: true` on export when the clone needs to match the source's `asOf` behavior exactly (this is what `branch()` does internally). -Identity-enabled graphs are the exception: their exports default temporal -fields on, because assertion windows cannot be validated against endpoints -without the endpoint bounds. Explicit `includeTemporal: false` is refused for -those graphs. +Two kinds of graph are the exception: their exports default temporal fields +on, and refuse an explicit `includeTemporal: false`. + +- **Identity-enabled graphs**, because assertion windows cannot be validated + against endpoints without the endpoint bounds + (`IDENTITY_EXPORT_REQUIRES_TEMPORAL_FIELDS`). +- **Graphs with an active-only edge cardinality** — any edge kind whose + `cardinality` or `targetCardinality` is `"oneActive"`, which includes every + realizing edge of a `oneActive` [composition](/ontology#composition) pair. + Such a kind counts a row only while its window is open, so its ended rows + are history: the row a `reparent` leaves behind, an employment that ended. + Exported without its `validTo`, an ended row would re-import as current — a + part attached to its former whole again, or refused as a second active + edge (`ACTIVE_CARDINALITY_EXPORT_REQUIRES_TEMPORAL_FIELDS`). **Repair a legacy graph before exporting it.** A row an older library version stored with a backwards window (`valid_from > valid_to`) exports as it is stored @@ -236,6 +337,13 @@ and is unaffected. To update props over existing rows from a temporal export, either omit `validFrom` from the update document, export with `includeTemporal: false`, or import into a fresh graph and swap it in. +On an identity-enabled graph the update leg's `validTo` goes through the same +check a store update's does. An end that an identity assertion naming the node +would outlive is reported as a per-row error (the +`IdentityEndpointValidityError` message) and the row is left as it was; an end +that lands on a member of an identity class records the `window-end` +transition. + ### Cancelling an export On a backend reporting `capabilities.execution.interactiveTransactions`, an export holds one @@ -421,14 +529,40 @@ The contract is deliberately narrow: - The TypeGraph node and edge tables must be globally empty. A different graph in the same database also makes the database non-empty. - The caller guarantees property shapes, endpoint existence, edge endpoint - types, cardinality, duplicate-free IDs, and duplicate-free durable edge match - identities. Only stream ordering and known kind names are checked. Trusted - import still derives each declared edge identity and stores it with the row, - so a collision reaches the database arbiter and rolls back the complete - trusted-import transaction. + types, cardinality, composition, duplicate-free IDs, and duplicate-free + durable edge match identities. Only stream ordering and known kind names are + checked. Trusted import still derives each declared edge identity and stores + it with the row, so a collision reaches the database arbiter and rolls back + the complete trusted-import transaction. - Recorded-time history, revision tracking, node uniqueness constraints, `searchable()` fields, and `embedding()` fields are rejected in this first version because their sidecar writes would otherwise be skipped. +- A target graph declaring [target cardinality](/core-concepts#target-cardinality) + or source-side `cardinality` constraints on any edge kind is rejected with + `details.reason === "cardinality_unsupported"` — trusted import writes only + the node and edge relations, so it cannot also maintain the cardinality + claim rows those constraints depend on. Use `importGraphStream` for a graph + with constrained edge kinds; it maintains claims the same way the store's + normal write path does. +- A target graph declaring any `acyclic: true` edge kind is rejected with + `details.reason === "acyclicity_unsupported"`: trusted import validates + nothing by contract and holds one transaction for the whole stream, so + there is no per-row point to probe the relation at, and no bounded + end-of-stream check that would not be a second, unbounded implementation + of the same predicate `importGraphStream` already enforces per row. Use + `importGraphStream` for a graph with an acyclic edge kind. +- A target graph declaring any `partOf`/`hasPart` pair is rejected with + `details.reason === "composition_unsupported"`: trusted import writes no + composition claim row and does not check the composition acyclicity + relation, so a graph loaded this way can carry a part with two live wholes + or a part/whole cycle. This refusal covers `existence: "required"` pairs + too — trusted import cannot honor that guarantee any more than the + one-whole claim, so there is no separate reason code for it. Use + `importGraphStream` for a graph with a composition pair (`importGraph` + when a pair declares `existence: "required"`); + `store.verifyConstraintFences()` reports either problem (plus a required + part with no whole) after the fact if trusted import is used anyway on + data prepared outside TypeGraph. - Operational Identity-enabled target stores are rejected with `details.reason === "identity_unsupported"`; identity-bearing input is rejected with `details.reason === "invalid_stream"`. The trusted session @@ -526,6 +660,108 @@ row is reported as the same per-row error rather than aborting the import. Nodes were never affected: their probe is `getNode(graphId, kind, id)`, which is kind-scoped, so a cross-kind id collision simply reads as absent. +#### A composition window that ends or starts later + +A composition edge's window may not start or end later than the write that +states it — on import as on every [store write](/errors#compositionerror). A +document row of a realizing edge whose `validFrom` or `validTo` lies after +the import's clock is refused as a per-row error (`error` starts with +`COMPOSITION_VALIDITY_FUTURE_START` or `COMPOSITION_VALIDITY_FUTURE_END`), +whether it would insert the row or update an existing one. A past `validTo` is +history and imports as such: an ended `oneActive` composition row is neither a +second whole for its part nor a member of the composition relation's +acyclicity check, so the history a `reparent` leaves behind re-imports in any +row order. + +#### A required composition part with no whole + +Import writes every node row before any edge row, so whether a required-existence +part (`existence: "required"`, see [Ontology](/ontology#existence-a-part-that-cannot-exist-without-a-whole)) +has its composition edge cannot be decided per row at insert time — only once +the whole payload's edge set is known. A part created by this import is +accepted when its composition edge arrives later in the SAME import (any +batch), or when it is already attached on the target from before this import; +otherwise it is reported as a per-row error on the node (`error` matches +`/requires a whole/`) and no orphan node row survives the import. + +A part nothing can attach — no composition edge anywhere in the payload names +it, and the target holds no attachment for it — is refused before its row is +written. No other row of the import is judged against it, so the outcome does +not depend on where the part sits in the document: a valid row sharing its +unique value is committed whichever comes first. Every row of the document +that would create the part is refused, a repeated one included, and an edge +naming it is refused for its missing endpoint. + +A part some composition edge in the payload does name is written, and judged +once the edges are in; if that edge turns out not to attach it (see below) its +row is removed in the same transaction. That ordering is visible. A later row +that collides with the part's row — the same value under a unique constraint, +an edge that takes a slot the part's edge holds — is refused against it and +reported naming the part, and it stays refused after the part is removed: the +import does not judge that row again. Every other row is unaffected. +Re-running the import without the refused part (or with a composition edge +that attaches it) accepts the rows it displaced. + +"Attached" is judged on the written rows, by the same reader +`store.verifyConstraintFences()` uses, so a composition edge in the payload is +not enough on its own: an edge whose window does not attach the part (an +ended `oneActive` window), or whose whole does not exist (possible with +`validateReferences: false`), leaves the part refused. The check repeats until +nothing changes, so a required part attached only to a part this import +refuses is refused with it. Removing a written part removes every edge +touching it, and each one is reported as its own per-row error on the edge. +That includes an edge that was on the target before this import — one written +ahead of the part under `validateReferences: false` — whose error says so. The +`created` and `updated` counts of `result.nodes` and `result.edges` count only +what was committed: a refused part, and each edge removed with it, comes off +every count this import had added it to. + +With `onConflict: "update"`, a document whose `validTo` would end the open +window of a live required part's composition edge is refused as a per-row +error (`error` starts with `COMPOSITION_DETACH_REFUSED`), as the same update +is through `store.edges..update(...)`. + +```typescript +const result = await importGraph(store, data, { onConflict: "error" }); +const orphaned = result.errors.filter( + (entry) => entry.entityType === "node" && /requires a whole/u.test(entry.error), +); +``` + +Only nodes THIS import creates are tracked this way: a required-existence node +already live on the target that this import merely updates or leaves alone is +never re-checked, even if it happens to have no whole (a pre-existing gap +`store.verifyConstraintFences()` — not import — reports). + +`importGraphStream` cannot make this check: every chunk commits on its own, +and a part's composition edge arrives in a later chunk than the part itself. +A streamed import into a graph that declares any required-existence part kind +is therefore refused before any chunk is read, with a `ConfigurationError` +whose `details.code` is `IMPORT_STREAM_REQUIRED_COMPOSITION_UNSUPPORTED`. Use +`importGraph` for such a graph. `branch()` and the other working-copy clones +switch to a materialized `importGraph` for these graphs on their own. + +#### An edge that would close a cycle + +An edge kind declaring `acyclic: true` is checked per row, sequentially: +each row's acyclicity probe runs inside the same savepoint-guarded attempt +as its insert, so a row that would close a cycle rolls back to the +savepoint and is reported as that row's per-row error while the rest of +the import commits — the same per-row recovery every other declared +constraint gets. Rows of an acyclic kind never join the batched slice +insert other rows in the same chunk use: the in-batch cardinality/endpoint +overlay intercepts simple counts and existence checks, not a recursive +reachability query, so it cannot see an in-batch cycle. This is the one +place import trades throughput for correctness on purpose — acyclic kinds +import one row at a time. + +`importGraph` / `importGraphStream` additionally takes the per-graph write +lock for the duration of each chunk whenever the target graph declares any +`acyclic: true` edge kind, and refuses the whole import up front — before +the first chunk, with `details.constraint: "edgeAcyclicity"` — on a backend +with no transactions. See [Backend Setup](/backend-setup) for the full +fence-reason table. + #### An update target that changed under the import `onConflict: "update"` is a read-then-write pair: the import probes the stored diff --git a/apps/docs/src/content/docs/limitations.md b/apps/docs/src/content/docs/limitations.md index 349a08fe1..44b6cfefc 100644 --- a/apps/docs/src/content/docs/limitations.md +++ b/apps/docs/src/content/docs/limitations.md @@ -86,9 +86,14 @@ primary and sidecar writes when a later statement fails. An authoritative comman whose database statement returns the decision it made. It can provide a safe transactionless create/found path only when the backend has a durable arbiter. -Operational Identity, single-edge claim/cardinality enforcement, and any -undeclared dynamic `matchOn` convergence that may write still require an -interactive transaction and fail closed on a backend that cannot provide one. +Operational Identity, single-edge claim/cardinality enforcement, edge +acyclicity (`acyclic: true`), and any undeclared dynamic `matchOn` +convergence that may write still require an interactive transaction and +fail closed on a backend that cannot provide one. No fused write program +applies acyclicity — the axis has no database key that could back a claim +inside a fused statement — so an `acyclic: true` edge kind always declines +the fused single-row and bulk-create fast paths and takes the complete +portable probe-then-insert path instead, whatever else is eligible for it. Outside the native durable-convergence envelope, an all-live `ifExists: "return"` endpoint batch is read-only and can return from its set-oriented root read without a transaction. Inside the native envelope, the @@ -184,7 +189,9 @@ await store.transaction(async (tx) => { Variable-length traversals use two depth caps and an explicit cycle policy: -1. Unbounded traversals (no `maxHops` option) are capped at 10 hops. +1. Unbounded traversals (no `maxHops` option) are capped at 10 hops. The one + exception is composition navigation: `parts()` / `wholes()` without + `maxHops` apply no hop ceiling and return the complete closure. 2. Explicit `maxHops` values are validated up to 1000 hops (`maxHops: >1000` throws). 3. Cycle prevention is on by default. To skip cycle checks for speed, opt into `cyclePolicy: "allow"` (which may revisit nodes across hops). @@ -224,6 +231,225 @@ import { MAX_RECURSIVE_DEPTH } from "@nicia-ai/typegraph"; // MAX_RECURSIVE_DEPTH = 10 ``` +### Recursive traversals cannot be paginated + +`paginate()`, `page()` and `stream()` refuse a query with a recursive traversal (`recursive()`, +`parts()` or `wholes()`) with a `ConfigurationError` (`details.code` is +`RECURSIVE_PAGINATION_UNSUPPORTED`), because a keyset cursor cannot position itself inside the rows +one start node fans out into. Use `execute()` with an explicit `limit()` or `maxHops`, or page the +start nodes in a non-recursive query and run the walk for each page. See +[Recursive traversals are not paginated](/queries/execute#recursive-traversals-are-not-paginated). +`parts()` and `wholes()` with `maxHops: 1` and neither `depth` nor `path` are not recursive: they +compile to a fixed one-hop step, so they page and stream. See +[Paging direct parts](/queries/recursive#paging-direct-parts). + +## Composition Cascade + +Deleting a [composition](/ontology#composition) whole cascades leaf-first +through its live parts closure, each part going through its own node-delete +pipeline in the same transaction. Several behaviors are easy to assume and +are not what happens: + +- **Resurrecting a soft-deleted whole restores the whole alone.** Ownership + of its parts ended when the cascade ran; the parts stay deleted. There is + no opt-in to cascade a resurrection back onto them. To make a delete + undoable, capture the parts it removes first; see + [Undoing a cascading delete](/ontology#undoing-a-cascading-delete). +- **Ending a whole's validity window is still not a cascade.** Closing an + `oneActive` composition edge's currency leaves the part node itself + untouched — it is not deleted, and a later reattach of the SAME part to a + different whole is a reparent, not a resurrection. +- **A belief-status close DOES close the whole's required parts, and it is + not this cascade that does it.** A [provenance](/provenance) retraction + that closes a whole's currency closes its required parts in the same + transition because support treats a required part as dependent on its + whole, not because a delete cascade ran: no edge is touched, an optional + part is left believed, and reopening the whole reopens the parts that are + otherwise supported. Each closed part fires its own `delete` operation hook, + rather than folding into one event for the whole the way this cascade does. + See [Composition and retraction](/provenance#composition-and-retraction). +- **The cascade emits one operation-hook event, for the whole.** Each + cascaded part delete runs through its own node-delete pipeline but is not + itself a caller-issued operation, so `onOperationEnd` fires exactly once — + for the whole's delete, not once per part. It does not leave you guessing + which parts went: that hook's context carries `cascadedParts` (one + `{ kind, id, whole, viaEdgeKind }` entry per part, naming the whole it was + directly a part of and the edge kind that bound it, taken from the plan the + cascade executed, leaf-first and then ordered deterministically by kind, + then id, within one level of the closure, so the list is the same on every + run and every backend), and a `store.transactionWithReceipt` receipt + carries the same entries for every cascade in the transaction. `onOperationStart` never carries them — the + cascade has not been planned when the operation begins. +- **A PostgreSQL `REPEATABLE READ` or `SERIALIZABLE` whole delete can fail + with a serialization failure.** The parts closure is read under the + per-graph write fence with no claim row behind it, so the session must see + a part attached while it waited. A snapshot taken before that attach + cannot, so the delete and the attach both advance the graph's row in + `typegraph_fences` and such a session fails on it (SQLSTATE `40001`) before + the closure is read, whatever isolation level the attaching writer ran at. A collection delete + TypeGraph opened the transaction for is replayed and then sees the part; + inside your own transaction it surfaces as `TransactionConflictError`, and + the whole transaction is yours to re-run. A custom backend whose fence is an advisory + lock with no fences relation cannot detect the stale snapshot and refuses + the delete at those levels with `ConfigurationError` + (`COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT`). +- **A property update racing a delete cannot strand a unique key.** An + ordinary node update takes no per-graph lock. On PostgreSQL the delete, direct + or cascaded, locks each node it deletes before it reads the node's connected + edges, so the update waits for the delete and then finds the node gone. The + delete releases the unique keys of the props the row holds once it is + tombstoned, so a key an update moved the node to is released with it. +- **A whole's delete can deadlock with an edge write on PostgreSQL.** The + cascade locks the whole, then its parts one kind at a time, so an edge write + that already holds one of those nodes and waits on another can deadlock with + it. See + [Deadlocks between deletes and edge writes](#deadlocks-between-deletes-and-edge-writes-postgresql). +- **`subgraph({ composition: true })` follows the edges the cascade + follows, but is not a delete preview.** + [`store.subgraph(root, { edges: [], composition: true })`](/ontology#choosing-a-containment-tier), + `parts()` and `wholes()` admit each composition EDGE by its pair's + population, exactly as the cascade does, not by the row's validity window: + an ended `population: "one"` row still binds its part, because a `one` + binding holds for the row's whole life, and an ended `population: + "oneActive"` row, the history a `reparent` leaves behind, does not. + `includeEnded` / `includeTombstones` do not widen that, and a pinned read + (`asOf`, `asOfRecorded`) follows the edges that bound at its instant. NODES + still follow the read's temporal filter at every hop, while the cascade + deletes every live part however its window stands: a part node whose own + validity window has ended (or not yet started) is not returned, and + neither is anything reachable only through it, although the delete removes + them. A read also takes no lock, so a write between the read and the + delete can change the answer. To know what a delete will do, and to delete + only if that still holds, use + [`planDelete`](/ontology#previewing-a-delete). + +The closure walk is bounded by its **visited set**, not a fixed depth: a kind +may declare a reflexive composition pair (a `Section` that is `partOf` +another `Section`, for example), so a kind-level depth bound cannot cap +instance depth. The same holds for the `subgraph({ composition: true })` +export above: its composition closure has no hop ceiling and never returns a +truncated unit — a part chain of any length comes back whole, and an engine +that cuts the closure statement short refuses with a `ConfigurationError` +(`COMPOSITION_UNIT_INDETERMINATE`) instead of returning the prefix it reached. +The write path refuses an INSTANCE-level cycle (the +composition union's acyclicity fence), so one only survives in rows written +before the pair was declared, by trusted import, or by direct SQL; deleting +any node in such a cycle throws `CompositionCycleError` (see +[Errors](/errors#compositioncycleerror)) rather than looping. Break the cycle +by hand (delete or reassign one of the composition edges that closes it) +before the affected nodes can be deleted. + +## Composition Existence (`existence: "required"`) + +- **Type-level narrowing of `partOf`'s whole kind is conservative.** On + `store.nodes.`, `partOf.whole.kind` is limited to the whole kinds the + ontology declares for that part kind, but only when that can be proven from + the ontology's declared tuple. A graph that also declares a `subClassOf`, + `equivalentTo` or `sameAs` relation, or whose ontology was built in a + variable annotated `readonly OntologyRelation[]`, accepts any node kind of + the graph at compile time. `via` is not narrowed from the ontology at all: + it takes any edge type or kind string. A composition pair added at runtime + through `store.evolve()` is invisible to the type; reach it through + `store.getNodeCollection(kind)`. The runtime check (`ConfigurationError`, + `COMPOSITION_WHOLE_NOT_DECLARED` / `COMPOSITION_VIA_NOT_DECLARED`) is + authoritative in every case. +- **Trusted import refuses every declared composition pair, required or + optional** (`composition_unsupported`), not just required ones: it writes rows + without the store's validation, so it cannot honor either the one-whole claim + or the existence guarantee. There is no separate + `composition_existence_unsupported` reason — a graph with ANY `partOf`/ + `hasPart` pair cannot use trusted import at all. +- **Fused/read-free programs decline a required-existence kind or a stated + `partOf`.** The three node-create fused resolvers + (`resolveAtomicNodeBatchExecutor`, `resolveAtomicNodeReplacementBatchProgram`, + `resolveAtomicNodeResolvedMutationSetExecutor`) and the fused edge-delete-batch + resolver (`resolveAtomicEdgeDeleteBatchExecutor`, for a required-existence + composition edge kind) all return `undefined` in that case, so a + required-existence bulk create or delete always takes the portable path — + slower, never silently incomplete. +- **No backfill tool.** Changing an existing pair's `existence` in place is a + schema-tightening commit like any other: it is refused for a dirty graph (a + live required part with no live whole) and ships no repair step. + `store.verifyConstraintFences()` reports the same finding on an already-live + graph but does not fix it. +- **Graph merge does not protect a reopened composition row from a + concurrent deletion of that same row.** If one branch moves a part back + under a whole by reopening an ended `oneActive` row (`clearValidTo`) while + another branch deletes that row itself, with both the part and the whole + surviving, the deletion wins under every `onDeleteModifyConflict` policy: a + window change is not a modification, so no `DeleteModifyConflict` is + recorded, and the absorbed reopen does not appear in `validityEnds`. If the + reopening branch also ended the part's other attachment, the merged part is + left with no whole and nothing reports it. + `store.verifyConstraintFences()`'s `compositionExistence` family reports + such a part only when its pair declares `existence: "required"`. The same + absorption drops a reopen of any edge kind; it is tracked in + [#789](https://github.com/nicia-ai/typegraph/issues/789). + +## Write Fence Row + +On PostgreSQL, a write that makes a decision only the per-graph write fence +protects also advances one row per graph in `typegraph_fences`, behind the +fence's advisory lock. Those writes are: + +- a write into an `acyclic: true` edge kind or a composition (`partOf` / + `hasPart`) relation, including the attach a part's create performs; +- a delete of a composition whole; +- a match-key convergence: `getOrCreateByEndpoints`, + `bulkGetOrCreateByEndpoints` or a coalescing upsert on a match key no + database key backs (a declared `matchIdentity` is backed by one). + +The row is what lets those decisions hold between a `REPEATABLE READ` or +`SERIALIZABLE` session and a `READ COMMITTED` one (see +[Acyclicity](/core-concepts#acyclicity)). Every other holder of the fence +takes the lock alone: a history-capturing or revision-tracking write, a +cardinality, uniqueness or disjointness write, `store.clear()`. A graph that +declares no acyclic or composition relation and never converges on a match key +never writes the row and issues no statement for it. + +Consequences of the row being a real write: + +- **A role that performs those writes needs `INSERT` and `UPDATE` on + `typegraph_fences`.** A role that only reads, or only performs the other + writes, does not. +- **Reads write nothing.** A read that takes the fence to keep a + multi-statement view from tearing, such as a provenance capability's + `holding()`, takes the advisory lock alone, so it runs in a read-only + transaction or under a `SELECT`-only role. +- **A long-lived snapshot slows those deciding writes.** The row is updated + once per deciding transaction (once per deciding write in a transaction you + adopted with `withTransaction`, or in a `store.transaction` whose + `tx.sqlAvailability` is `"available"`: raw SQL can roll back to a savepoint + and undo both the lock and the row advance, so each fenced write there takes + the fence again), and PostgreSQL cannot remove the dead + versions an open snapshot might still need. While any transaction holding + an older snapshot stays open (a long analytics query, `pg_dump`, an + idle-in-transaction session, or TypeGraph's own repeatable-read export, fork + and storage-inventory reads for as long as they run), each row update has + more dead versions to step over, and it runs while holding the graph's + advisory lock, so the cost is serialized into the throughput of every write + that waits on that lock. Measured with one client on PostgreSQL 18, issuing + the fence statement alone in its own transaction: 0.11 ms for the lock-only + form and 0.17 ms for the form that advances the row, with every update a + heap-only update and the table at 64 kB. With one `REPEATABLE READ` + transaction left open, the row-advancing form took 0.24 ms after 20,000 + deciding transactions, 0.37 ms after 40,000, 0.50 ms after 60,000 and + 0.63 ms after 80,000, still rising linearly, and the one-row table had grown + to 6 MB; the lock-only form stayed at 0.11 ms throughout. The growth stops + when the old snapshot closes and autovacuum (or a manual + `VACUUM typegraph_fences`) removes the dead versions. Watch `n_dead_tup` for + `typegraph_fences` in `pg_stat_user_tables` and the oldest `backend_xmin` in + `pg_stat_activity`; run dumps and long reports against a replica, and set + `idle_in_transaction_session_timeout`. +- **A mixed deployment is as strong as its weakest decider.** The row detects + a stale snapshot only against writers that advance it. A custom backend + whose fence is an advisory lock with no fences relation advances nothing, so + it supports those decisions at `READ COMMITTED` only, and a + snapshot-isolation session on a bundled backend writing the same graph is + not protected from its writes. + +SQLite takes no fence row: its single writer slot is the fence. + ## Connection Management Managed Store factories own their local SQLite or PGlite connection, and their @@ -490,6 +716,52 @@ and as `update` then `create` for edges, which have no single-item upsert. See [Data Sync](/data-sync#one-batch-cannot-hand-a-unique-value-from-one-row-to-another) for the worked example. +### A bulk refusal caught inside `store.transaction` can keep earlier rows + +A bulk method called on the store runs in its own transaction, so a refused +batch rolls back entirely. The same method called on `tx` runs on the +caller's transaction and has no savepoint of its own, so it reaches every +verdict its items can raise by reading before its first write instead: +`bulkUpsertById` and `bulkReplaceById` judge the update half (merged +properties, the validity window, unique keys, a restore's disjointness, +composition and identity verdicts, an edge resurrection's or reopen's +cardinality and composition claims) together with the creates they write +first, and `bulkGetOrCreateByConstraint` and `bulkGetOrCreateByEndpoints` +judge their matched items together with the rows they create. A row's own +identity fold is read before its write on every node path that inserts a +caller-chosen id or brings a tombstone back: `create`, `bulkCreate` and +`bulkInsert`, `upsertById` and `getOrCreateByConstraint` on a tombstone, +their bulk forms, and a fact `unRetract` reopens. Each item is judged +against the state the batch's earlier items leave, so a later item may take +a key or an allowance an earlier one gives up and is refused one an earlier +one takes. A refusal the callback catches therefore +leaves none of the batch written. + +What can still follow a write of the same batch: + +- **A concurrent writer the fence does not exclude.** A node kind whose + unique keys are enforced by the key itself takes no per-graph write fence, + so another transaction can claim a key, or remove a row, between the + batch's reads and its writes. That surfaces as the same typed error + (`UniquenessError`, `NodeNotFoundError`, `CardinalityError`) from the + write. Inside `store.transaction(...)` the get-or-create methods do not + re-run a batch that has already written on your transaction, so the + refusal reaches you rather than being applied twice; called on the store, + where the refusal rolled the batch back, they re-run it once and converge + on the winner's row. + When such a writer moves a row's stored window, the batch converges as a + single update does and prepares its remaining items again, after its + earlier items wrote, so a refusal reached there follows those writes. +- **An identity fold that depends on another restored row of the same + batch.** A restored row's fold is judged against the identity state as + stored, so a contradiction that only appears once another row of the batch + has been restored is raised when the batch reaches that row: before the + row itself is written, after the rows ahead of it. + +Let the refusal propagate so the transaction rolls back, or rethrow after +handling it. See +[Catching a bulk refusal inside a transaction](/performance/overview#catching-a-bulk-refusal-inside-a-transaction). + ## Graph Analytics Limits TypeGraph ships focused algorithms on `store.algorithms.*` — shortest path @@ -559,6 +831,49 @@ read; a schema-managed store refuses that delete instead (see SQLite needs no locks: writers take the single writer slot before their first read. +## Deadlocks Between Deletes and Edge Writes (PostgreSQL) + +The row locks that pair node deletes with edge writes (see +[Edge endpoints and concurrent node deletes](/backend-setup#edge-endpoints-and-concurrent-node-deletes)) +make the two wait for each other, and two writers that wait for each other can +deadlock. Both sides take their locks in `(kind, id)` code-point order, so a +single-edge write and a `bulkDelete` of one kind that covers both of its +endpoints queue behind each other rather than deadlock. These shapes can still +deadlock: + +- **A delete that is several statements.** A transaction deletes both + endpoints of an edge being created concurrently, one endpoint at a time, in + the other order. +- **A composition whole's delete.** The cascade locks the whole first, then its + parts one kind at a time, while an edge write between the whole and one of + its parts, or between two of its parts, locks the same nodes in `(kind, id)` + order. The parts it locks are every part its closure walk reaches, including + ones already deleted, since it reads each part's liveness through that lock. +- **An edge write that locks many endpoints.** A transaction that creates + several edges, or one closed batch edge program, locks endpoints row by row + in its own order. +- **`store.clear()`.** It deletes the graph's nodes while an edge write holds + one endpoint and waits on the other. + +PostgreSQL detects the cycle and aborts one of the two transactions; the other +commits, and no edge is left on a deleted node either way. The aborted one is +safe to re-run: + +- Inside `store.transaction()` the abort surfaces as `TransactionConflictError` + (`TRANSACTION_CONFLICT`, with the driver's `40P01` error as `cause`). Pass + `store.transaction(fn, { retry: { attempts } })` to have TypeGraph re-run the + callback, or re-run it yourself. +- A direct store call such as `store.nodes.Folder.bulkDelete(ids)` surfaces the + driver's deadlock error (SQLSTATE `40P01`) unchanged, as it does any other + conflict. Wrap the call in `store.transaction(..., { retry })` to get the + typed error and the retry. + +To make a deadlock less likely, keep transactions that delete nodes and +transactions that write many edges short, and delete a set of nodes with one +`bulkDelete` per kind rather than one statement per node. SQLite cannot +deadlock this way: its writers take the single writer slot before their first +read. + ## Temporal Query Limitations Temporal queries (`asOf`, `includeEnded`) work correctly but have some constraints: @@ -604,6 +919,16 @@ adopt a caller-owned transaction: `AdapterHistoryStore`, raw `tx.sql` is disabled under `history: true`; adopt external transactions with `store.withRecordedTransaction(...)` instead of `store.withTransaction(...)` (which is a compile error on a history store). +- **No partial rollback of TypeGraph writes inside `withRecordedTransaction`.** + Your own handle can open a savepoint inside the callback, for example a + nested Drizzle `transaction()` around your own tables. Do not roll one back + over TypeGraph writes: TypeGraph does not see that rollback, so the history + it records and the per-graph write fence it took still count the writes the + rollback undid. The result is not only wrong history: a concurrent + `REPEATABLE READ` or `SERIALIZABLE` writer is then no longer stopped by the + fence, and the two transactions can both commit a constraint violation, such + as a cycle in an `acyclic` relation. Let the error propagate and roll back the + whole transaction instead. - **Reconstruction cost.** Recorded reads rebuild from the history relations and are slower than live reads, most noticeably for full-graph subgraph / algorithm reconstructions on PostgreSQL. diff --git a/apps/docs/src/content/docs/ontology.md b/apps/docs/src/content/docs/ontology.md index 904af14d3..806f086f3 100644 --- a/apps/docs/src/content/docs/ontology.md +++ b/apps/docs/src/content/docs/ontology.md @@ -34,8 +34,10 @@ subClassOf(Employee, Person); When you define an ontology, TypeGraph: 1. **Precomputes closures** at store initialization (not query time) -2. **Expands only the query operations that explicitly opt in** (except inverse - traversal, whose store default is `"inverse"` and can be changed) +2. **Expands query operations by default where the relation guarantees it**: + `from()` and `to()` follow `subClassOf`, and traversal follows `inverseOf` + (the store default is `"inverse"`); `implying` and `narrower` expansion opt + in, and collection reads and search default to exact kinds 3. **Enforces the documented constraints** when building a registry or writing data It does not run a general reasoner, materialize implied edges, substitute @@ -45,24 +47,27 @@ properties between types, or automatically expand every query. | Relation / feature | Runtime contract | | --- | --- | -| `subClassOf` | Transitive registry closure, write-path endpoint assignability, and opt-in node-query expansion with `includeSubClasses` | -| `disjointWith` | Same-ID collision enforcement, propagated through interleaved `subClassOf` and `equivalentTo` closure (`sameAs` remains a deprecated equivalence alias) | +| `subClassOf` | Transitive registry closure, write-path endpoint assignability, and node-query expansion with `expansion: "subclasses"` (the default). The closure now also includes every `equivalentTo` class — two equivalent kinds are mutual subclasses of each other. The child's schema output must structurally extend the parent's — checked at compile time and refused at registry build otherwise | +| `disjointWith` | Same-ID collision enforcement, propagated through interleaved `subClassOf` and `equivalentTo` closure | | `implies` | Transitive registry closure and opt-in traversal expansion with `expand: "implying"`; endpoints are validated | | `inverseOf` | Single inverse partner, endpoint reversal validation, and traversal expansion with `expand: "inverse"` (the default store setting) | -| `equivalentTo` | Registry lookups and graph-merge type reconciliation; no automatic query or property behavior. `sameAs` is folded in as a full alias — the merge type reconciler and the registry treat a `sameAs` declaration identically to `equivalentTo` | -| `broader` / `narrower` | Transitive registry introspection only | -| `partOf` / `hasPart` | Transitive registry introspection only | +| `equivalentTo` | Between two registered kinds, MUTUAL SUBSUMPTION: folded into the same closure `subClassOf` reads, so `isAssignableTo`, `expandSubClasses` / the `expansion` option, edge-endpoint acceptance, disjointness propagation and the `kindWithSubClasses` claim axis all treat the two kinds as substitutable. An IRI on either side stays an inert cross-system reference — it never becomes a kind, but a class reached *through* one still folds together. Restricted to node kinds: an equivalence class that mixes a node kind and an edge kind, or that holds more than one registered edge kind, is refused (`ONTOLOGY_EQUIVALENCE_INVALID_CLASS`) | +| `broader` / `narrower` | Transitive registry introspection, plus kind-taxonomy query expansion with `expansion: "narrower"` (untyped alias — no schema relationship is claimed) | +| `partOf` / `hasPart` | Enforced composition (see [Composition](#composition)): one whole per part across every declared pair, acyclicity over the composition union, a leaf-first delete cascade, optional `existence: "required"`, `parts()` / `wholes()` navigation, and `subgraph({ composition: true })` export. Also a transitive registry closure | | `relatedTo` | Symmetric direct registry introspection through `getRelatedKinds` only | -| Type-level `sameAs` | Deprecated name for `equivalentTo` (see above); prefer calling `equivalentTo` directly | -| Type-level `differentFrom` | Deprecated and decorative — never enforced instance identity; migrate to the graph-level TypeGraph Identity Profile | -| Custom `metaEdge()` properties | Serialized introspection metadata only; custom transitivity, symmetry, inverse, and inference settings are not executed | + +`sameAs`, `differentFrom`, and the custom `metaEdge()` factory were removed +— see [Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` +APIs](/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) +for what replaces them and what a document that still persists one does on +load. ## Core Meta-Edges TypeGraph provides a standard set of meta-edges: ```typescript -import { subClassOf, broader, narrower, equivalentTo, sameAs, differentFrom, disjointWith, partOf, hasPart, relatedTo, inverseOf, implies } from "@nicia-ai/typegraph"; +import { subClassOf, broader, narrower, equivalentTo, disjointWith, partOf, hasPart, relatedTo, inverseOf, implies } from "@nicia-ai/typegraph"; ``` ### Subsumption (Type Inheritance) @@ -75,29 +80,235 @@ subClassOf(Article, Media); subClassOf(Company, Organization); ``` -**Query Behavior:** - -Subclass expansion is **opt-in** via `includeSubClasses: true`: +**The structural contract:** `subClassOf(child, parent)` requires the child's +schema output to structurally extend the parent's — every property the +parent requires, the child has with a compatible type; the child may add +properties (width subtyping) or narrow an optional-in-parent property. +`equivalentTo` between two registered kinds checks the same contract +in **both** directions. The check runs twice: at **compile time** +(TypeScript rejects an incompatible pair with a message naming the missing +or incompatible fields), and at **registry build** for whatever the type +checker cannot see — a value-level constraint like `z.string().min(3)` +tightening a bare `z.string()`. The registry check is authoritative and +covers all three authoring routes (a compile-time graph, an `evolve()`- +authored extension, and a deserialized persisted document), throwing a +`ConfigurationError` under one of four codes: + +| code | when | +| --- | --- | +| `ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE` | a `subClassOf` child's schema does not extend its parent's | +| `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` | the projected JSON Schema cannot judge the pair (`$ref`, `allOf`, `not`, or an unmodeled keyword) | +| `ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE` | a pair the equivalence class relates — through `equivalentTo`, or a `sameAs` a persisted document still carries — fails in one direction | +| `ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE` | the same, but the pair is incomparable | + +The message and `details.relation` name the relation the refused pair comes +from: the `subClassOf`, `equivalentTo` or `sameAs` that declared it, or, for a +pair the hierarchy only implies (`details.declared: false`), `subClassOf` or +the equivalence relation its class declares (`equivalentTo` whenever the class +declares one). `details` also carries `childKind`, `parentKind`, +`viaEquivalence`, `declared`, `reason`, `path` and every violation found. + +A child pinned to a finite set of values (`z.literal`, `z.enum`) narrows a +bounded parent when every value satisfies the parent's constraints: +`z.literal("x")` extends `z.string().min(1)`, `z.enum(["a", "b"])` extends +`z.string().max(10)`, and `z.literal(5)` extends `z.int().min(0)`. A record +narrows its parent's key set the same way, so `z.record(z.enum(["a", "b"]), V)` +extends `z.record(z.string(), V)`. Each value is checked against length bounds, +`regex()` patterns (and the patterns behind `startsWith()`, `endsWith()`, +`includes()`, `lowercase()` and `uppercase()`), numeric bounds and +`multipleOf()`. A value is accepted only when Zod and JSON Schema both accept +it, so a length is judged in code points and in UTF-16 units, and a pattern +with and without the `u` flag. Formats such as `z.email()` or `z.uuid()` are +not checked value by value; a child under one must use the same format. + +Some constraints have no JSON Schema form, so the check stays conservative +around them and refuses the pair as incomparable unless both sides use the same +schema: + +- a parent whose format is followed by another format — + `z.url().startsWith("https://")` projects only the second; +- a parent whose overwrite (`.trim()`, `.toLowerCase()`, `.toUpperCase()`, + `.normalize()`) runs before another check, which then judges the transformed + value; +- either side whose overwrite runs after another check, since the node stores a + value that check never saw; +- either side holding `z.literal(NaN)` or `z.literal(Infinity)`, which JSON + cannot carry. +- a parent `z.codec()` or `z.preprocess()`, whose function rewrites the value + before the output side judges it, and a parent `.pipe()` whose input side + states a check (`z.string().min(5).pipe(z.string())`): only the output side + has a JSON Schema form. + +The check is sound in one precise sense: a child stores nothing its parent +could not store itself. A field the child shares with its parent (the same Zod +schema, as `.extend()` gives it) stores exactly what the parent's does, so it +is accepted whatever its `.default()` or `.catch()` value — even one that fails +the field's own checks, since both kinds store it. A field the child declares +separately has each `.default()` or `.catch()` value, which is stored without +the field's own checks, judged against the parent field the way a literal child +would: `z.string().min(1).default("")` does not extend a separately declared +`z.string().min(1)`. A value that cannot be judged by value, such as a default +on `z.email()` or a `z.date()` factory, is refused unless the field is shared. A +parent's default applies only to an absent value, so it does not widen what the +parent accepts for a value the child stores. + +Which keys a kind stores and which it requires are read from Zod's own +property metadata rather than from the JSON Schema's `required`, which follows +neither: a key Zod may leave out of what the child stores counts as optional +(an exhaustive `z.record(z.enum([...]), V.optional())` does not extend the +same record with required values, and a `.catch()` field under a required +parent field is refused unless shared), and a key whose input the parent +requires counts as required even where its output may be absent +(`z.unknown().pipe(z.string().optional())`). + +**Fields JSON Schema cannot express:** the registry check compares kinds' +projected JSON Schema, and some Zod constructs have no JSON Schema form: +`z.set()`, `z.map()`, `z.date()`, `z.bigint()`, a `.transform()`, and any +`z.custom()`. TypeGraph's own `embedding(n)` is the exception: it is +compared as what it validates, an array of exactly `n` numbers, so +`embedding(3)` extends `z.array(z.number())`, `z.array(z.number()).length(3)` +extends `embedding(3)`, and `embedding(3)` does not extend `embedding(4)`. +An embedding's index options (`metric`, `indexType`, …) do not affect +subsumption. + +In a compile-time graph or an `evolve()`-authored extension, every other such +field is judged on its own and every other field of the pair is still +compared. Since nothing about its values can be compared, the field is +accepted only when: + +- the parent's field admits every value (`z.unknown()` or `z.any()`), so a + subclass may narrow it to a `z.date()`; +- both fields are the same Zod schema, as when the child reuses the parent's + field through `.extend()`; +- both are plain, check-free fields of the same type, such as two + independently declared `z.date()` or `z.bigint()` fields. + +Every other pair is refused as `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` with +`reason: "unsupported-construct"` and the field's `path`. That includes a +`.transform()` child field under a constrained parent field +(`z.string().transform(f)` under `z.string().min(3)`: the transform's output +is never shown to satisfy the bound), a plain child field under a parent whose +own transform hides its checks, a `z.date()` replacing a `z.string()`, two +separately declared transforms or `z.custom()` checks, and a bounded or +refined `z.date()` beside a plain one. The check also stays conservative +where it could in principle do better: two separately declared `z.set()`, +`z.uint64()` or bounded `z.date()` fields, an `embedding()` re-created by +`.describe()` or `.meta()` (a new schema without its dimensions), and an +`embedding(3)` under a three-element `z.tuple()` (the reverse is accepted) +are all refused. +Share the field schema between the two kinds to keep such a pair comparable. A subclass that adds a `z.date()` or an +`embedding()` to its parent still opens normally, and a pair that is also +incompatible elsewhere (a tighter `z.string().min(5)` on the parent, say) is +refused for that field. A field Zod converts only in part, such as a `.catch()` +with a dynamic fallback, keeps the type it states and is comparable only to the +same schema. A deserialized document has no Zod schemas, and it stores such a +kind without a property projection, so a pair with that kind on **either** +side is skipped there. +Every commit, `createStore`, `getSchemaChanges` and `requiresMigration` also +check the graph in your code, so this coarser check never decides alone. + +If your hierarchy is a **taxonomy** rather than a genuine subtype +relationship — the child doesn't actually extend the parent's schema — +declare `broader(child, parent)` instead; see +[Hierarchical (Concept Hierarchy)](#hierarchical-concept-hierarchy) below and +`expansion: "narrower"` in [Source](/queries/source#expansion-narrower--kind-level-taxonomies). + +**Query behavior — polymorphic by default:** + +A query against a kind other kinds declare themselves `subClassOf` returns +subtype rows **by default**, since the structural contract guarantees the +subtype rows satisfy the parent's shape: ```typescript -// Without expansion: returns only nodes with kind="Media" -const mediaOnly = await store +// Default: returns Media, Podcast, AND Article nodes +const allMedia = await store .query() .from("Media", "m") .select((ctx) => ctx.m) .execute(); +// Results include nodes of kind "Media", "Podcast", and "Article" -// With expansion: returns Media, Podcast, AND Article nodes -const allMedia = await store +// Narrowed: returns only nodes with kind="Media" +const mediaOnly = await store .query() - .from("Media", "m", { includeSubClasses: true }) + .from("Media", "m", { expansion: "exact" }) .select((ctx) => ctx.m) .execute(); -// Results include nodes of kind "Media", "Podcast", and "Article" ``` This is a fundamental difference from traditional ORM inheritance—TypeGraph stores the concrete type -(`kind: "Podcast"`) in the database, and expands at query time when requested. +(`kind: "Podcast"`) in the database, and expands at query time by default. The +alias's `kind` field and `NodeId` brand widen to `string` for a kind the +ontology can actually affect (a graph with no subsumption relations keeps its +exact literal types); only the parent's own properties are statically typed on +the alias, since that is all the contract guarantees. `search()` and the +collection APIs (`find`, `count`, `updateWhere`, `compareAndSet`) are +unaffected and stay exact-kind. See +[Subclass queries are polymorphic](/queries/source) for the full option, +[Scope at a glance](/queries/source#scope-at-a-glance) for the default scope +of every query, collection and search surface, and +`queryDefaults.expansion` in [Schemas & Stores](/schemas-stores) for +the store-wide migration knob. + +**Typed edge collections follow the same subsumption.** An edge declared +`to: [Person]` admits an `Employee` (a `subClassOf` descendant, or an +`equivalentTo` kind) at runtime, and `store.edges.` types it that way: + +- **Reads widen like a query alias.** For a declared endpoint kind the + ontology can affect, the edge's `toKind` is `string` and `toId` carries the + widened `NodeId` brand, on every typed edge read (`getById`, `findFrom`, + `findTo`, the bulk and batch reads, `create`, `update`, `bulkFindEdgesFrom`, + the edges `neighbors()` returns, and the view and recorded-time + collections). An endpoint kind no subsumption + relation names keeps its literal kind and exact id. So + `store.nodes.Person.getById(edge.toId)` no longer compiles for such an + endpoint; resolve it with + [`store.getNodeByRef({ kind: edge.toKind, id: edge.toId })`](/schemas-stores#storegetnodebyrefreference), + narrowing `edge.toKind` first when you want a specific kind's typed node. +- **Inputs admit subclass nodes and polymorphic rows.** Endpoint arguments + (`create`, `findFrom`/`findTo`, `find`/`count` filters, the bulk and batch + methods, `findByEndpoints`, `getOrCreateByEndpoints`) accept a node or + `{ kind, id }` of the declared kind or of any kind assignable to it, followed + transitively through `subClassOf` and `equivalentTo`, a row from a + polymorphic `from("Person", ...)` query, whose `kind` is `string`, and a + widened endpoint reference such as `{ kind: edge.toKind, id: edge.toId }`. + A subclass row from a supertype query connects without a cast. The runtime + endpoint check stays authoritative. The widened form is accepted at compile + time by its id brand, which is structural, so any node or reference whose + properties include the declared kind's compiles at an affected endpoint, + even of an unrelated kind, and the runtime refuses an unrelated kind with + `EndpointError`; and because an IRI is typed as a plain `string`, every kind + mapped to an IRI with `equivalentTo` is admitted wherever any one of them + is. +- **The next hop takes the same references.** The `sources` / `targets` of + `bulkFindEdgesFrom` / `bulkFindEdgesTo` and the `source` of `neighbors()` / + `countNeighbors()` accept the widened reference and the polymorphic row + too, so `{ kind: edge.toKind, ids: [edge.toId] }` feeds the next read as it + is. A `kind` the graph does not register is refused with + `KindNotFoundError`. + +An endpoint lookup matches the kind it is given exactly. A `Node` value +carries its real kind, so `findFrom(employee)` finds an `Employee`'s edges, +but `findFrom({ kind: "Person", id: employee.id })` compiles (the declared +kind is always accepted) and returns `[]`, because no `Person` row has that +id. Pass the node, or the reference with its real kind. A graph whose +ontology type has lost its literals (a relation annotated as the bare +`OntologyRelation`) admits every node kind at each endpoint and widens every +endpoint read, the same conservative arm the query aliases take. A +`subClassOf` added at runtime through `store.evolve()` is invisible to these +types, as it is to query aliases: reach such an endpoint through +`store.getEdgeCollectionOrThrow(kind)` and `store.getNodeByRef(...)`, whose +kinds are runtime strings. + +**Changing this on a populated graph**: adding a `subClassOf` relation is +checked against existing data before it commits — it can merge two +uniqueness components or propagate a `disjointWith` down to a new +descendant — and removing one is checked for live edges whose endpoints rely +on the subsumption. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). +An incompatible hierarchy on a populated graph is refused before the upgrade +even reaches that data check — see +[Schema evolution](/schema-evolution#structural-subsumption-is-checked-before-you-upgrade). ### Hierarchical (Concept Hierarchy) @@ -118,20 +329,139 @@ const narrowerTopics = registry.expandNarrower("Technology"); // ["ArtificialIntelligence", "MachineLearning", "DeepLearning", ...] ``` +A query can expand through this same closure with `expansion: "narrower"` on +`from()`/`to()`/`fromDynamic()`/`toDynamic()` — since no schema relationship +is claimed, the resulting alias is untyped (no static property access; use +`fromDynamic()`'s `.field(name)` discriminator). This is the small, +fixed-vocabulary reading of a kind taxonomy — a handful of concepts known at +schema-authoring time, each declared as its own node kind. For a vocabulary +that grows at runtime (new concepts added without a schema change), prefer +the instance-level pattern instead: a single `Concept` node kind, a +`broader` **edge** between concept instances, traversed with +`.recursive()` — the SKOS / Wikidata / LinkML model. Choose the kind-level +form when the vocabulary is closed and small; choose the instance-level form +when it is open-ended. The instance-level `broader` edge kind can declare +[`acyclic: true`](#enforce-an-instance-level-taxonomy-with-acyclic-true), and +the engine then refuses at write time any edge that would close a `broader` +cycle ([`EdgeAcyclicityError`](/errors#edgeacyclicityerror)) rather than +leaving cycle freedom to the application; a search the engine cuts short +reports [`EdgeAcyclicityIndeterminateError`](/errors#edgeacyclicityindeterminateerror) +instead of guessing. Kind-level `broader`/`narrower` needs no declaration: +a cyclic kind taxonomy is refused when the registry is built. + +```typescript +// Instance-level: a single Concept kind, broader as an edge +const Concept = defineNode("Concept", { schema: z.object({ label: z.string() }) }); +const broaderEdge = defineEdge("broader", { schema: z.object({}) }); + +const ancestors = await store + .query() + .from("Concept", "c") + .whereNode("c", (n) => n.id.eq(leafConceptId)) + .traverse("broader", "e") + .recursive() + .to("Concept", "ancestor") + .select((ctx) => ctx.ancestor) + .execute(); +``` + +**Changing this on a populated graph**: `broader`/`narrower` never gate a +write or change what a claim contends for, so adding or removing one is +always safe and auto-migrates unconditionally. + ### Equivalence -**`equivalentTo`**: Defines semantic equivalence between types or external IRIs. +**`equivalentTo`**: Between two registered kinds, `equivalentTo` is MUTUAL +SUBSUMPTION — the registry folds the class into the same closure `subClassOf` +uses, so the two kinds become fully substitutable: + +```typescript +const Company = defineNode("Company", { schema: z.object({ name: z.string() }) }); +const Corporation = defineNode("Corporation", { schema: z.object({ name: z.string() }) }); + +equivalentTo(Company, Corporation); + +registry.isAssignableTo("Corporation", "Company"); // true +registry.isAssignableTo("Company", "Corporation"); // true — mutual +registry.expandSubClasses("Company"); // ["Company", "Corporation"] +``` + +That substitutability reaches every consumer of the subsumption closure: +`expansion: "subclasses"` on a `Company`-scoped query or `search()` call also +returns `Corporation` rows, an edge endpoint declared `to: [Company]` accepts +a `Corporation` node, and disjointness declared against `Company` propagates +to `Corporation` too. A `kindWithSubClasses` uniqueness constraint fences +across the pair — the two kinds share one claim axis, so a value unique for +`Company` is now also unique for `Corporation`. + +Because subsumption is a strict order (`isSubClassOf(k, k)` is always false, +and `expandSubClasses` never repeats a kind), a kind that sits strictly +between two mutually-equivalent kinds collapses into the same class too, even +though nothing declares it equivalent to either one directly: + +```typescript +equivalentTo(Doctor, Physician); +subClassOf(Doctor, Consultant); +subClassOf(Consultant, Physician); +// Doctor and Physician were declared equivalent; Consultant only ever +// declared subClassOf. But Doctor ⊑ Consultant ⊑ Physician ⊑ Doctor is now a +// cycle, so all three are mutually assignable and share one subclass +// component: ["Consultant", "Doctor", "Physician"]. +``` + +This is the mathematically forced consequence of making `equivalentTo` mutual +subsumption, not a special case the registry detects — `Consultant` never +appears in an `equivalentTo` declaration, but the closure over `subClassOf` +and `equivalentTo` together puts it on the same cycle as `Doctor` and +`Physician`. + +`equivalentTo` also maps a type to an external IRI for cross-system mapping, +exactly as before. An IRI is an inert reference — it never becomes a kind of +its own — but a class *reached through* one still folds together: + +```typescript +equivalentTo(Person, "https://schema.org/Person"); +equivalentTo(Individual, "https://schema.org/Person"); +// Person and Individual are now one class, even though neither equivalentTo +// call named the other directly. +``` + +An edge kind may be mapped to an external IRI too (`equivalentTo`'s left +parameter accepts `NodeType | AnyEdgeType`), but subsumption itself is a +node-kind relation. An equivalence class is refused, with a +`ConfigurationError` whose details code is `ONTOLOGY_EQUIVALENCE_INVALID_CLASS`, +when it mixes a node kind and an edge kind, or when it contains more than one +registered edge kind (which can happen transitively, through two edges mapped +to the same IRI): ```typescript +// Refused: worksAt (an edge kind) and Person (a node kind) in one class. +equivalentTo(worksAt, Person); + +// Refused: two registered edge kinds folded together through a shared IRI. +equivalentTo(worksAt, "https://schema.org/worksFor"); +equivalentTo(employedBy, "https://schema.org/worksFor"); + +// Legal: one edge kind and one node kind, each mapped to its OWN IRI. +equivalentTo(worksAt, "https://schema.org/worksFor"); equivalentTo(Person, "https://schema.org/Person"); -equivalentTo(Organization, "https://schema.org/Organization"); ``` -**`sameAs`** and **`differentFrom`** are deprecated type-level factories. -`sameAs` is currently a type-equivalence alias; `differentFrom` is decorative. -For durable individual identity, enable the graph-level TypeGraph Identity -Profile and use `store.identity`. That ledger deliberately does not provide OWL -property substitution or automatic graph-wide query expansion. +**`sameAs`** and **`differentFrom`** — deprecated type-level factories that +behaved identically to `equivalentTo` and as a decorative no-op relation, +respectively — were removed. For durable individual identity, enable the +graph-level TypeGraph Identity Profile and use `store.identity`. That ledger +deliberately does not provide OWL property substitution or automatic +graph-wide query expansion. See +[Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) +for what a document that still persists one of these relations does on load. + +**Changing this on a populated graph**: `equivalentTo` is classified exactly +like `subClassOf` — an addition is checked against existing data (it can +propagate a `disjointWith`, and merges uniqueness components, since +equivalence folds into subsumption), and a removal is checked for live edges +relying on it. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). ### Constraints @@ -160,28 +490,726 @@ await store.nodes.Organization.create({ name: "Acme" }, { id: "entity-1" }); A kind disjoint with itself, a kind disjoint with one of its own subclass ancestors, a common subclass of two disjoint parents, and a kind declared both `equivalentTo` and `disjointWith` another are all rejected, including overlaps -reached through mixed equivalence/subclass paths. These checks run -both when you construct a graph and when a persisted schema is reloaded, so a -document written by an older, more permissive version can fail validation on -load with a `ConfigurationError` whose details code is -`ONTOLOGY_DISJOINT_CONFLICT`. To recover, fix the graph definition and, for a -persisted schema, correct the stored document before upgrading (or rewrite it -through the previous minor version, which still accepts it). The same -construction-and-reload rule applies to the other ontology coherence checks -(duplicate relations, hierarchical self-loops and cycles, and inverse-partner -uniqueness). +reached through mixed equivalence/subclass paths. An equivalence class that +mixes a node kind and an edge kind, or that holds more than one registered +edge kind, is rejected too (`ONTOLOGY_EQUIVALENCE_INVALID_CLASS`; see +"Equivalence" above). These checks run both when you construct a graph and +when a persisted schema is reloaded, so a document written by an older, more +permissive version can fail validation on load with a `ConfigurationError` +whose details code names the specific check. To recover, fix the graph +definition and, for a persisted schema, correct the stored document before +upgrading (or rewrite it through the previous minor version, which still +accepts it). The same construction-and-reload rule applies to the other +ontology coherence checks (duplicate relations, hierarchical self-loops and +cycles, and inverse-partner uniqueness). + +**Changing this on a populated graph**: adding `disjointWith` is checked +against every live node before it commits — two nodes already sharing an id +under kinds the new relation makes mutually exclusive refuse the migration. +Removing `disjointWith` can never invalidate existing data, so it stays safe +and auto-migrates unconditionally. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). ### Composition -**`partOf`** and **`hasPart`**: Define compositional relationships. +**`partOf`** and **`hasPart`**: Declare a whole/part relationship, realized by +an edge kind that actually stores the containment. `via` is required and +names that edge; `partSide` is required only when the edge's endpoints make +both orientations valid. + +```typescript +const episodeOf = defineEdge("episodeOf"); + +partOf(Episode, Podcast, { via: episodeOf }); +// hasPart(Podcast, Episode, { via: episodeOf }) declares the identical pair — +// pick whichever direction reads more naturally, not both. +``` + +Deleting a whole deletes its parts with it, leaf-first and in the same +transaction, and resurrecting the whole later does not bring them back (see +[Undoing a cascading delete](#undoing-a-cascading-delete) for a recipe). If +parts must outlive their whole, use the aggregation tier instead (see +[Choosing a containment tier](#choosing-a-containment-tier)). To see what a +delete will remove before running it, and to delete only if that still holds, +call `planDelete` and pass its plan to `delete` (see +[Previewing a delete](#previewing-a-delete)). + +`via`'s realizing edge must declare a whole-side cardinality of `"one"` or +`"oneActive"`: `cardinality` when the part is the edge's `from` endpoint, +`targetCardinality` when the part is its `to` endpoint. Every `(from, to)` +pair the edge admits must be declared as a composition pair in that same +orientation — an edge cannot be a composition edge for some of its endpoints +and a plain edge for the rest. + +A same-kind (reflexive) pair, or any pair whose realizing edge admits both +orientations between the two kinds, is ambiguous: `partSide: "from" | "to"` +must be declared explicitly, naming which endpoint holds the part. + +```typescript +const parentSection = defineEdge("parentSection"); + +// `parentSection` admits Section -> Section either way, so the orientation +// can't be inferred. +partOf(Section, Section, { via: parentSection, partSide: "from" }); +``` + +**Changing this on a populated graph**: the schema commit checks a newly +declared pair against the rows that already exist. Adding a `partOf`/`hasPart` +pair is a `warning`: inside the schema-commit transaction TypeGraph audits the +composition claims (a part with more than one live whole is refused, because +one whole per part is now enforced) and acyclicity over the proposed +composition relation, and adds the required-whole audit when the pair declares +`existence: "required"` (a live part with no live whole is refused). A graph +that passes auto-migrates; one that does not is refused with a `MigrationError` +(`reason: "ontology-tightening-violated"`) whose `details.violations` list the +offending rows to resolve before retrying. Optional to `existence: "required"` +on an already-declared pair is checked the same way, and `required` to optional +is `safe`. Removing a pair is `breaking`: it changes what `parts()`/`wholes()` +resolve to and stops parts from being deleted with their whole, so +`ensureSchema()` refuses it and it needs an explicit `migrateSchema()`. +`store.verifyConstraintFences()` (the `family: "composition"` entries) reports +the same violations after the fact for rows written by trusted import or direct +SQL. + +**Upgrading a graph that declared `partOf(part, whole)` with no `via`**: an +earlier release accepted that form and stored it. Upgrade the library first, +then give the relation its `via` (or delete it) in the graph, and open the +graph with `createStoreWithSchema()` or commit it with `migrateSchema()`. The +stored relation realized nothing, so its removal is `safe`; the pair you +declare with `via` is a newly added pair and is checked against existing rows +as described above. A graph that still declares the relation without `via` is +refused at registry build. See +[Structural subsumption is checked before you upgrade](/schema-evolution#structural-subsumption-is-checked-before-you-upgrade) +for the full order. + +#### Attaching a part: `partOf: { whole, via?, props? }` + +Every write that can give a part a whole takes the same attachment value: +`create`, `bulkCreate` (per item), `getOrCreateByConstraint` / +`bulkGetOrCreateByConstraint`, and +[`reparent`](#reparent-moving-a-part-to-a-new-whole). + +```typescript +const chapter = await store.nodes.Chapter.create( + { title: "Openings" }, + { + partOf: { + whole: book, + via: chapterOf, + props: { order: 1 }, + }, + }, +); +``` + +The node and its realizing composition edge are written in **one** +transaction: a lost composition claim, a dead or missing whole, a cardinality +refusal, or an acyclicity refusal aborts the node create too. Those refusals +are reached before the node row is written (for `bulkCreate`, before any row +of the batch), so catching one inside an enclosing `store.transaction(...)` +leaves nothing of the refused create behind. + +- **`whole`** names the whole: a node you already hold, or a + `{ kind, id }` reference when you only have the id + (`whole: { kind: "Book", id: bookId }`). Only its `kind` and `id` are read, + so a whole node's own properties never bleed into the attachment. On + `store.nodes.`, `whole.kind` is limited at compile time to the whole + kinds the ontology declares for that part kind, in either declaration + direction (`partOf(Chapter, Book)` or `hasPart(Book, Chapter)`). When the + ontology also declares a subclass or equivalence relation, or was built in + a variable annotated `readonly OntologyRelation[]`, the type accepts any + node kind of the graph instead. The runtime check is authoritative either + way: a pair that is not declared between the two kinds raises + `ConfigurationError` (`details.code: "COMPOSITION_WHOLE_NOT_DECLARED"`). + `store.getNodeCollection(kind)` takes any kind string, and a + `StoreProjection` carries the same whole kinds its graph declares for each + projected kind. +- **`via`** names the realizing edge. Pass the edge's type when you have it + (a typo is a compile error, the same check `partOf`/`hasPart` apply) or its + kind string when the edge is chosen at runtime. It is required only when the + part kind declares **more than one** composition pair toward that whole kind: + omitting it there raises `ConfigurationError` + (`COMPOSITION_VIA_AMBIGUOUS`) rather than silently picking one, and naming + an edge kind that realizes no declared pair between them raises + `COMPOSITION_VIA_NOT_DECLARED`. `parts()`, `wholes()`, and + `subgraph({ composition: { via } })` take the same value and refuse a via + that realizes no pair at the source kind itself, rather than silently + walking every realizing edge or reading nothing. +- **`validFrom` / `validTo`** are the realizing edge's window, not the part + node's. A `validFrom` on `create` is not copied onto the edge. A malformed + bound or an inverted pair is refused with `ValidationError` on every + surface, including a get-or-create that finds the attachment already held. + On a `population: "oneActive"` pair only an open-ended edge is a + membership, so stating `validTo` there is refused (issue path + `partOf.validTo`, code `COMPOSITION_ATTACHMENT_WINDOW_BOUNDED`): the edge + it would write attaches nothing. Record a past membership as history with + `store.edges..create(...)` and a `validTo` at or before now instead. + No composition window may start or end later than the write that states + it (see [Choosing a containment tier](#choosing-a-containment-tier)): a + later `validFrom` or `validTo` refuses with `CompositionError` + (`COMPOSITION_VALIDITY_FUTURE_START` / `COMPOSITION_VALIDITY_FUTURE_END`), + here and on every write of a realizing edge. A schema commit that declares + a pair over an edge kind whose stored rows already start or end later than + the commit is refused the same way + ([`compositionFutureWindow`](/schema-evolution#ontology-tightenings-are-checked-against-your-data)). +- **`props`** are the realizing edge's own properties, validated against that + edge kind's schema exactly as `store.edges..create(...)` would + validate them. A realizing edge with required schema fields therefore needs + `props` here. + +Any other key on the attachment is refused with `ValidationError` (issue path +`partOf.`) rather than ignored, so a misspelled option, or the whole's +`kind` / `id` stated beside `via` instead of under `whole`, cannot be dropped +silently. + +#### `reparent`: moving a part to a new whole ```typescript -partOf(Chapter, Book); -hasPart(Book, Chapter); -partOf(Episode, Podcast); -hasPart(Podcast, Episode); +await store.nodes.Chapter.reparent(chapter.id, { + whole: anthology, + via: includedIn, +}); ``` +`reparent` writes a brand-new realizing edge, so it takes the same `whole`, +`via` and `props` as `create`'s `partOf`: a realizing edge whose schema has +required fields needs them restated on every move. It returns +`{ edge, moved }`. `bulkReparent` runs the same move for many parts in one +transaction. Every item's verdict is reached before the first write, so a +refused batch moves nothing, including when you catch the refusal on `tx` +inside `store.transaction(...)`. The batch is judged on the state it produces +as a whole: two moves that close a cycle between them are refused, and a part +may move under one that the same batch moves out from under it, whichever +order the items are listed in. Listing one part twice is a `ValidationError`. + +`at` is the move instant, and its only spelling: the same timestamp ends the +old window and opens the new one. Omit it to read the clock once. `reparent` +takes no `validFrom` or `validTo` — stating either is a compile error and a +`ValidationError` at runtime (issue path `options.validFrom` or +`options.validTo`). A move opens the new attachment; bounding a membership is +a window edit on the realizing edge. A malformed `at` is refused under +`options.at`, and an `at` later than now with `CompositionError` +(`COMPOSITION_MOVE_FUTURE`): a move happens when it is made, never scheduled. +The move's two edge writes — the old window's end and the new edge — are +judged at the move instant rather than a second clock read, so they agree +with the instant the move was judged at. +Like every other batch method, `bulkReparent` fires no operation hooks; +`reparent` fires one `update` operation for the part it moves. + +Moving a part is a first-class operation because neither half is legal on its +own: the new attachment refuses while the old edge still holds the part's +one-whole claim, and — under `existence: "required"` — detaching the old edge +refuses while the part is live. `reparent` performs both under one per-graph +fence, in one transaction, and validates the **final** state: one whole, +acyclicity over the composition union, and a required part never left +detached. The part keeps its id, its properties, and every descendant beneath +it. + +The move is ONE instant — the same timestamp ends the old window and opens +the new one — but what that buys a reader depends on the incumbent row's own +population, which is also what decides how the old attachment is retired: + +| population | retire | valid-time read at any instant before the move | +| --- | --- | --- | +| `"one"` | the composition edge is deleted | the attachment is gone from every `store.asOf(t)` read, at every coordinate — a `"one"` binding persists for the row's whole life, ended or not, so an ended row would still read as an attachment | +| `"oneActive"` | the window is ended at the move instant | the previous membership stays readable as valid-time history; because the same instant ends the old window and opens the new one, no `store.asOf(t)` coordinate shows the part with zero wholes or with two | + +A `"oneActive"` window cannot end before it starts, so the move instant is +judged against the incumbent's `validFrom`. An `at` equal to it retires a +zero-width window. An earlier `at` is refused before any write with +`CompositionError` (`COMPOSITION_MOVE_BEFORE_INCUMBENT`, `situation: +"moveBeforeIncumbent"`), whose details name the incumbent edge's kind, id and +`validFrom`. An omitted `at` is never refused for this: when the clock has +stepped backward since the incumbent was written, the move happens at the +incumbent's `validFrom` instead of the clock read, the way the recorded clock +clamps to its previous anchor (see +[Logical revision and physical time](/queries/temporal#logical-revision-and-physical-time)). +Both windows then start later than the clock. Until wall time catches up, a +current-time read shows the part under neither whole (as it already showed +the incumbent's whole nowhere before the move), and +`store.verifyConstraintFences()` reports both rows under +`compositionFutureWindow`, as it already reported the incumbent. + +Reparenting to the whole the part already holds (through the same `via`, when +one is stated) is accepted as a **no-op** — no write, no history — so a +caller converging on a destination need not first ask where the part is. +Stated `props` are still checked on that no-op: valid and canonically equal +to the realizing edge's live stored props, the no-op stands; valid but +different raises `CompositionExistenceError` (`situation: "props"`, +`COMPOSITION_PROPS_CONFLICT`) rather +than being silently dropped, since the no-op performs no write to apply it — +`store.edges..update(...)` changes the edge directly. A stated `at` is +checked the same way: the no-op opens no new window, so `at` stands only when +it equals the realizing edge's stored `validFrom`, and any other instant is +refused with `ValidationError` under `options.at` +(`COMPOSITION_ATTACHMENT_WINDOW_CONFLICT`), as a differing `partOf.validFrom` +is on get-or-create. An omitted `at` states nothing and is never refused here. +A kind that +declares no `partOf`/`hasPart` pair at all raises +`ConfigurationError` (`COMPOSITION_NOT_A_PART`); a missing or already-deleted +part raises `NodeNotFoundError`. + +#### `partOf` on get-or-create is a postcondition + +`partOf` on `getOrCreateByConstraint` / `bulkGetOrCreateByConstraint` states +what must be true when the call returns: **the resolved node holds exactly +this attachment**, whichever way the constraint resolved. It applies to +optional and required parts alike, and most of its outcomes are successes: + +| the constraint resolved to… | and the node… | outcome | `action` | +| --- | --- | --- | --- | +| no match | — | created with the attachment, exactly as `create` with `partOf` | `"created"` | +| a soft-deleted match | — | restored, and attached in the same transaction | `"resurrected"` | +| a live match | holds this whole through the resolved pair's realizing edge | **satisfied**: no write, no history; with `ifExists: "update"` the property update alone runs | `"found"` / `"updated"` | +| a live match | holds this whole, but stated `props` differ from the edge's live stored props | refused: `CompositionExistenceError` (`situation: "props"`, `COMPOSITION_PROPS_CONFLICT`) — a satisfied match writes no edge, so a different value cannot be applied silently | — | +| a live match | holds this whole, but a stated `validFrom` / `validTo` differs from the edge's stored window | refused: `ValidationError` (issue path `partOf.validFrom` or `partOf.validTo`, code `COMPOSITION_ATTACHMENT_WINDOW_CONFLICT`) — the same rule as `props` | — | +| a live match | has no live whole | **attached now**, required or optional alike; with `ifExists: "update"` the attachment and the property update are one write plan | `"found"` / `"updated"` | +| a live match | holds a **different** whole, or the same whole through another realizing edge | refused: `CompositionExistenceError` (`situation: "existing"`, `COMPOSITION_WHOLE_CONFLICT`), naming the held and the requested attachment | — | + +"Satisfied" means the whole matches, the realizing edge matches the resolved +pair, any stated `props` are valid and canonically equal to the edge's +live stored props, and any stated window bound equals the stored one — so an +idempotent ingest that restates the attachment on +every run stays read-only. Moving a part is never a side effect of a lookup; +that is [`reparent`](#reparent-moving-a-part-to-a-new-whole)'s decision, and +two concurrent calls naming different wholes for one part end with exactly +one attachment and one `situation: "existing"` refusal, never a silent move. + +The attachment is resolved before the match is read, so the +`ConfigurationError` refusals above (`COMPOSITION_WHOLE_NOT_DECLARED`, +`COMPOSITION_VIA_NOT_DECLARED`, `COMPOSITION_VIA_AMBIGUOUS`) fire identically +on a found node and on a created one. On the `ifExists: "update"` and +resurrection legs every read the attachment owes runs before the property +update's first statement, so a refusal leaves the properties untouched — and +a refused property update (a unique conflict, a validation error) leaves the +attachment and its history untouched — even for a caller that catches the +error inside an enclosing `store.transaction(...)`. + +`bulkGetOrCreateByConstraint` applies the same rule to the batch as a whole. +Before its first write it reaches, for **every** item whose constraint +matched an existing node, each verdict a read can decide: + +- the attachment's own — satisfied, attach, or one of the refusals in the + table above, plus a dead or missing whole, a cardinality bound, and + acyclicity judged over all of the batch's attachments together. The + attachments of the items it will **create** are counted in that same set, + after the matched ones, so a matched part and a new row that together + exceed a whole's bound are refused before the matched part is attached; +- with `ifExists: "update"` or on a resurrection, the property update's — + schema validation of the merged properties, and its unique keys, probed in + item order so a later item may take a key an earlier item of the same + batch gives up and is refused a key an earlier item takes; +- on a resurrection, the same refusals a create would raise: a required part + restored with no live whole, an id that a `disjointWith` kind now holds, + and an identity contradiction (`sameIdAcrossKinds: "fold"`) the restored + row's fold would raise, judged against the identity state as stored; +- for the items it will create, the refusals that need no read at all (a + required part with no `partOf`, an undeclared or ambiguous pair), and + their unique keys — every key of the new rows, not only the one the lookup + read, probed after the matched items' in the order the batch writes, so a + matched update that gives up a key lets a new row take it and one that + takes a key refuses the new row that asks for it. + +A refusal from any of them therefore leaves no item attached, updated, +resurrected or created, including when you catch it on `tx` inside +`store.transaction(...)`. What remains outside that guarantee: + +- **A concurrent writer the fence does not exclude.** A node kind whose + unique keys are enforced by the key itself takes no per-graph write fence, + so another transaction can claim a key, or remove a matched row, between + the batch's reads and its write. That surfaces as the same typed error + (`UniquenessError`, `NodeNotFoundError`) from the write, after earlier + items wrote. The batch is not re-run on your transaction once a leg has + written, so the refusal reaches you instead of an update applied twice; + called on the store, where the refusal rolled the batch back, it is re-run + once and converges on the winner's row. +- **An identity fold that depends on another restored row of the same + batch.** Each restored row's fold is judged against the identity state as + stored, so a contradiction that only appears once another row of the + batch has been restored is raised when that restore lands. + +The up-front verdicts cost one extra fenced read pass over the batch, paid +only by a batch with more than one item that owes a write (an item that +already satisfies the stated attachment owes none), or one such item next +to a new row. A batch that finds everything satisfied stays read-only and +reads each part's attachment once. + +Writing a realizing edge directly decides the whole batch first too. +`edges..bulkCreate` and `bulkInsert` decide every item before writing any, +and `edges..bulkUpsertById` and `bulkGetOrCreateByEndpoints` decide every +update, resurrection and reopen — its composition claim, cardinality and +window, counting the edges the batch creates — before the batch's first +write, so an item refused for a second whole (`CompositionError`) or a +detached required part (`CompositionExistenceError`) leaves none of the batch +written. See +[A bulk refusal caught inside `store.transaction` can keep earlier rows](/limitations#a-bulk-refusal-caught-inside-storetransaction-can-keep-earlier-rows) +for what stays outside that. + +#### `existence`: a part that cannot exist without a whole + +`existence: "required"` on a `partOf`/`hasPart` pair says a part of that kind +can never exist without a live whole — the default, `"optional"`, is every +declaration written before this option existed. + +```typescript +partOf(Segment, Episode, { via: segmentOf, existence: "required" }); +``` + +Two refusals follow from that one declaration: + +- **A bare create is refused.** `store.nodes.Segment.create({...})` with no + `partOf` throws `CompositionExistenceError` + (`COMPOSITION_WHOLE_REQUIRED`) before any row is written. Pass + `partOf: { whole }` naming the whole; the node and its composition edge are written + in the same transaction — a lost composition claim or a dead/missing whole + aborts the create too. Restoring a soft-deleted part is held to the same + rule: `upsertById`, `bulkUpsertById` and `bulkReplaceById` take no `partOf`, + so they refuse to restore one that holds no live whole. Restore it with + `getOrCreateByConstraint` and a `partOf`, or `create` with the same `id`. +- **Detaching a live part is refused.** Ending, soft-deleting, or + hard-deleting the composition edge of a LIVE required part throws the same + error with `situation: "detach"` (`COMPOSITION_DETACH_REFUSED`). A part that + is already retired (soft- or hard-deleted) is not orphaned by losing its + edge, so that case is allowed. + The two ways out are deleting the part itself (which frees its edge) and + [`reparent`](#reparent-moving-a-part-to-a-new-whole), which retires the old + attachment and creates the new one in one transaction so the part is never + detached at all. + +Both refusals read state no claim row records — the part's liveness, its +surviving composition edge, its whole — so every write that decides them +takes the per-graph write fence: a restore of a required part kind, and a +soft delete, hard delete or stated window end of a composition edge whose +part kind is required. A concurrent restore and detach (or whole delete) of +the same part therefore run one after the other. At `READ COMMITTED` the +second reads what the first committed and refuses or cascades accordingly. +At PostgreSQL `REPEATABLE READ` or `SERIALIZABLE`, a second whose snapshot +predates the first's commit fails at the fence with a retryable +`TransactionConflictError` instead of deciding from that snapshot, whatever +other constraint the write is also fenced for. Like every constrained write, +these are refused with +`CONSTRAINT_WRITE_FENCE_UNSUPPORTED` on a backend without transactions. +Optional parts and other edge kinds take no fence for them. + +A required part found by `getOrCreateByConstraint` is held to the same +postcondition as an optional one — see [`partOf` on get-or-create is a +postcondition](#partof-on-get-or-create-is-a-postcondition). + +`existence: "required"` is about detachment and bare creation, not about +deleting the *whole* — deleting a whole still cascades to its required parts +(see the cascade note above), rather than refusing. + +`partOf` (and the equivalent option on `bulkCreate`'s per-item `partOf`) is +also accepted as a convenience on an `existence: "optional"` pair — the same +one write, never required for one. + +Composition existence is a create/detach-time write-path guarantee, not a +retroactive repair: `store.verifyConstraintFences()` reports a live required +part with no live whole (data written before the declaration, or by trusted +import — see below) but does not fix it. + +**Changing `existence` on an already-declared pair**: flipping `existence` in +place — the pair itself (`via`, `partSide`) stays the same, only the +`"optional"`/`"required"` value changes — is classified as one `modified` +change, never as removing and re-adding the pair. Tightening +(`"optional"` → `"required"`) is a `warning`-severity change: it runs the +SAME data check a brand-new required pair does (a live part with no live +whole refuses the commit), reached directly through `ensureSchema`/ +`createAdapterStoreWithSchema`'s ordinary auto-migrate path — no explicit +`migrateSchema()` call is needed. Loosening (`"required"` → `"optional"`) is +always `safe`: every state the tightened constraint forbade is still +admitted, so it auto-migrates unconditionally regardless of data. + +#### Previewing a delete + +`nodes..planDelete(id)` answers what `delete(id)` would do, computed by +the planner the delete itself runs, with every write skipped. It returns +`{ cascadedParts, removedEdgeIds }`: the parts the cascade would delete, in +the shape and order the delete reports them as `cascadedParts`, and the ids +of every edge the delete would remove. It returns `undefined` for a node that +is absent or already deleted, where `delete(id)` is a no-op. A delete the +planner refuses is refused by `planDelete` with the same error, such as +`RestrictedDeleteError` for an `onDelete: "restrict"` node or part that still +has an ordinary edge. A `hardDelete(id)` of a live node cascades through the +same parts. + +The usual flow spans two requests: show "delete this and these N parts", and +delete once the user confirms. No lock can be held across that gap, so pass +the confirmed plan back as `expect`: + +```typescript +// Request 1: preview. +const plan = await store.nodes.Podcast.planDelete(podcast.id); +if (plan === undefined) return; // nothing to delete +// ...show plan.cascadedParts to the user and keep the plan with the page... + +// Request 2: the user confirmed this plan. +await store.nodes.Podcast.delete(podcast.id, { expect: plan }); +``` + +The delete computes its own plan with the same planner inside its +transaction (for a composition whole, under the per-graph write lock its +delete takes) and compares the two in any order: the same parts, each with the same `whole` and `viaEdgeKind` +(so a reparented part counts as changed), and the same `removedEdgeIds`. If +anything differs — a part attached, detached or moved since the preview, an +edge added or removed — it refuses with +[`NodeDeletePlanMismatchError`](/errors#nodedeleteplanmismatcherror) before +any write, and `details` names the parts and edge ids added to and dropped +from the plan. A node deleted since the preview refuses the same way +(`situation: "notLive"`) instead of being a silent no-op. Preview again and +ask the user to confirm the new plan. + +The plan is plain data and survives a JSON round trip. Once you pass options, +`expect` is required: `planDelete`'s `undefined` is not a plan, and +`{ expect: undefined }` is refused with `ValidationError` like any other +options of the wrong shape. A guarded delete always takes the portable delete +path. `hardDelete` takes no `expect`, because it also purges a tombstoned node +and the soft-deleted edge rows at each deleted node, which the plan does not +name. `bulkDelete` takes no `expect` either. Both refuse any options argument +with `ValidationError` rather than run unguarded. + +The comparison covers the parts and edges the delete planned. For a +composition whole, no concurrent attach or reparent can change that plan +before the delete runs it. A delete of any other kind takes no per-graph lock, +so every concurrent write can change the answer up to the comparison. + +A whole's delete reads each part's liveness through the delete lock that +part's own delete takes, so a part deleted concurrently either committed +before that read — and drops out of the plan, which `expect` then refuses — +or waits for the whole's delete and finds the part gone. A write that takes no +per-graph lock can still commit after the comparison and before the delete's +statements: for a whole, a detach of an optional part, or an edge of a kind +with no cardinality or acyclicity constraint; for any other kind, every +write. The delete treats such a write exactly as an unguarded delete does: + +- At `READ COMMITTED`, an edge created on a node the delete removes is removed + with it under `cascade` / `disconnect`, or refuses the delete with + `RestrictedDeleteError` under `restrict`. Inside your own transaction that + refusal comes after the cascade's earlier writes, so let it abort the + transaction. +- At PostgreSQL `REPEATABLE READ` or `SERIALIZABLE`, the delete's snapshot does + not see such an edge, and it stays live on the deleted node. This holds for + every delete, guarded or not: the edge write takes no lock the delete holds, + and PostgreSQL arbitrates `SERIALIZABLE` transactions only against each + other. + +SQLite admits no concurrent writer, so neither case arises there. + +When the decision and the delete happen in one transaction, call the +transaction's collections: + +```typescript +await store.transaction(async (tx) => { + const plan = await tx.nodes.Podcast.planDelete(podcast.id); + if (plan === undefined) return; + if (plan.cascadedParts.length > 100) { + throw new Error(`refusing to delete ${plan.cascadedParts.length} parts`); + } + await tx.nodes.Podcast.delete(podcast.id, { expect: plan }); +}); +``` + +For a composition whole, `tx.nodes..planDelete` reads the plan under the +per-graph write lock the delete takes and keeps it until the transaction ends, +so no concurrent attach or reparent can add a part between the preview and the +delete. Writes that take no per-graph lock can still change the outcome: a +part detached or deleted concurrently drops out of the delete's cascade, and +an ordinary edge created concurrently changes `removedEdgeIds` or a restrict +verdict. Any other kind's delete takes no per-graph lock, so every concurrent +write can. Passing the plan as `expect` turns a change the delete can see +(every committed change at `READ COMMITTED`) into a refusal. On the store's +collections, `store.nodes..planDelete` is an unlocked read and only a +best-effort answer. + +Neither form writes a graph, sidecar, history or revision row. The +transactional form does take the delete's lock, and for a composition whole on +PostgreSQL that lock advances the graph's fence row as the delete does. Two +consequences follow: + +- It fails the way the delete's fence fails: a `REPEATABLE READ` or + `SERIALIZABLE` transaction whose snapshot predates a concurrent attach gets a + serialization failure, and a custom backend with no fence row refuses at + those levels (see the isolation bullet under + [Composition Cascade](/limitations#composition-cascade)). +- It needs a read-write transaction. Inside + `store.transaction(fn, { accessMode: "read_only" })`, + `tx.nodes..planDelete` refuses before any statement, on every backend + and for every kind, with `UnsupportedBackendCapabilityError`. Call + `store.nodes..planDelete` instead for an unlocked preview. + +#### Undoing a cascading delete + +Restoring a soft-deleted whole brings back the whole alone. TypeGraph has no +undo for the cascade, but you can capture what it will remove and recreate +it. Before the delete, read the whole, the parts the plan names, and their +props, in the same transaction as the delete: + +```typescript +function propsOf(node: N): Omit { + const { kind: _kind, id: _id, meta: _meta, ...props } = node; + return props; +} + +const snapshot = await store.transaction(async (tx) => { + const plan = await tx.nodes.Podcast.planDelete(podcast.id); + const whole = await tx.nodes.Podcast.getById(podcast.id); + if (plan === undefined || whole === undefined) return; + const parts: { + part: CompositionCascadedPart; + props: Record; + }[] = []; + for (const part of plan.cascadedParts) { + const node = await tx.getNodeByRef(part); + if (node !== undefined) parts.push({ part, props: propsOf(node) }); + } + await tx.nodes.Podcast.delete(podcast.id, { expect: plan }); + return { whole: propsOf(whole), parts }; +}); +``` + +To undo, recreate the whole with its id, then each part with its id and its +`partOf`. `cascadedParts` is leaf-first, so reverse it to create every part +after its whole. `create` with the id of a soft-deleted node restores that +node with the props you pass: + +```typescript +if (snapshot !== undefined) { + await store.transaction(async (tx) => { + await tx.nodes.Podcast.create(snapshot.whole, { id: podcast.id }); + for (const { part, props } of snapshot.parts.toReversed()) { + const collection = tx.getNodeCollection(part.kind); + if (collection === undefined) throw new Error(part.kind); + await collection.create(props, { + id: part.id, + partOf: { whole: part.whole, via: part.viaEdgeKind }, + }); + } + }); +} +``` + +This brings back the nodes and their composition edges, not everything the +delete removed. The ordinary edges in `removedEdgeIds` stay deleted, and the +composition edges come back as new edges with new ids, without their props +or validity windows unless you capture and pass those too (`partOf.props`, +`partOf.validFrom`). Each restored node also starts a fresh validity window. + +#### Choosing a containment tier + +Not every "this belongs to that" relationship is composition. Before reaching +for `partOf`/`hasPart`, place the relation in exactly one of three tiers: + +| tier | what you write | what the runtime guarantees | +| --- | --- | --- | +| 1 — Composition | `partOf(Part, Whole, { via: edge })` | everything below | +| 2 — Aggregation | `cardinality`, `targetCardinality`, `acyclic` on an edge registration | each rule enforced independently, with no ownership slot, no cascade, no cross-relation constraint | +| 3 — Mereology | an ordinary edge kind + `.recursive()` | transitivity only, no integrity claim | + +Tier 1 is enforced, in full, at write time: + +- **Declaration shape.** The realizing edge's whole-side cardinality is + `"one"` or `"oneActive"`, and every endpoint pair the edge admits is a + declared composition pair in that orientation. +- **One whole per part.** A part holds exactly one whole across **every** + declared composition relation and **both** orientations — two different + realizing edge kinds contend for one reserved claim row, so the second + attach refuses with `CompositionError` + ([`COMPOSITION_WHOLE_OCCUPIED`](/errors#compositionerror)). Move a part + with [`reparent`](#reparent-moving-a-part-to-a-new-whole), never by + attaching a second edge. +- **Acyclicity over the composition union.** Every realizing edge kind forms + ONE oriented (part → whole) relation, probed on each composition edge + write, so a cycle spanning two different realizing edge kinds is caught + even though neither kind is `acyclic` alone + ([`EdgeAcyclicityError`](/errors#edgeacyclicityerror)). The union holds the + rows composition itself counts as memberships: an ended row of a + `oneActive` realizing edge is the history a `reparent` leaves behind, not a + membership, so earlier moves never stop a part from later moving under one + of its former descendants. A realizing edge kind that is also declared + `acyclic: true` keeps that declaration's rule: every non-deleted row of the + kind counts, ended or not. Every write is judged on the row it leaves: a + history row created, imported or restored still ended is no member and + closes no cycle, while reopening an ended row (`clearValidTo`) puts it back + and is probed like a create. +- **One row, one fact.** A composition edge's window never starts or ends + later than the write that states it — every write path refuses one with + `CompositionError` (`COMPOSITION_VALIDITY_FUTURE_START`, + `COMPOSITION_VALIDITY_FUTURE_END`, `COMPOSITION_MOVE_FUTURE` for a + `reparent` `at`, whose two edge writes are judged at that move instant). + So a row with no `validTo` is the part's attachment now + and at every later instant, and an ended row is history for good: the + claim, the cascade, the acyclicity check and every current read agree on + which whole a part holds, without any of them choosing an instant. Record a + change when it happens. "Later" is judged against the clock of the process + running the store, so a client whose clock runs ahead of it is refused + when it states its own `new Date()` as a bound: omit `validFrom` or `at` + to take the store's clock instead. +- **Leaf-first cascade.** Deleting a whole deletes its live parts closure + leaf-first, in the same transaction, each part through its own node-delete + pipeline. See + [Composition Cascade](/limitations#composition-cascade) for what the + cascade deliberately does *not* do. +- **`existence: "required"`.** An opt-in per pair: a part of that kind can + never exist without a live whole (see below). +- **Navigation and export.** `parts()`/`wholes()` cross heterogeneous, + mixed-orientation edge kinds, and `subgraph({ composition: true })` returns + the owned unit — a root plus its entire parts closure, at whatever depth the + part tree happens to be rather than at the caller's `maxDepth`. All three + read **ownership**, not validity: they follow the same memberships the + claim and the delete cascade count, so an ended `population: "one"` edge + still binds its part and an ended `population: "oneActive"` edge does not. + The read's temporal mode sets the instant: a current read, and an + `includeEnded` or `includeTombstones` read, follows the edges that bind + now — those modes do not widen ownership — while `store.asOf(t)` follows + the edges that bound at `t`, where a `"one"` edge ended before `t` still + binds. That rule is for the EDGES: nodes still follow the read's temporal + filter at every hop, so a part whose own validity window has ended (or not + yet started) is not shown, and neither is anything reachable only through + it, although the whole owns them and its delete removes them. A plain + `.traverse()` over a realizing edge kind keeps the edge's validity window. + Preview a delete with [`planDelete`](#previewing-a-delete), which lists + every part the delete removes and checks the plan still holds when the + delete runs. + There is no depth ceiling on that closure and no partial answer: it is a + set-semantics recursion bounded by its own visited set, the same way the + acyclicity probe is exhaustive, so a part chain of any length comes back + whole. An engine that cuts the closure statement short (a + `statement_timeout`, a cancelled query) REFUSES with a `ConfigurationError` + (`COMPOSITION_UNIT_INDETERMINATE`) naming the root, rather than handing back + the rows it managed to reach — the same treatment the acyclicity probe gives + a cut-short search. `maxDepth` and `cyclePolicy` continue to bound the + `edges` traversal a caller lists alongside it, and the engine's 1000-hop + ceiling on an explicit traversal applies to that list alone. + +The composition claim rides `typegraph_edge_claims`, the same relation edge +cardinality claims use. A deployment initialized before that relation existed +raises `ConfigurationError` (`EDGE_CLAIM_RELATION_MISSING`) on the first +`partOf`/`hasPart` write and must be migrated under owner credentials — see +[Backend Setup](/backend-setup). + +**The only tier with a cascade is the only tier with an ownership slot.** A +part with two owners is not a part. Any relation whose users need shared +membership, overlapping hierarchies, or re-homing a child when its parent is +removed is tier 2, not tier 1 — declaring it `partOf` would mean deleting the +parent deletes children that should have survived. + +**Rows retained for external referrers are tier 2, not composition.** A +"version of X" relation whose parts must stay resolvable by id after the +whole is deleted — a historical run record, a receipt, an audit snapshot — is +single-parent and acyclic like composition, but its members must *outlive* +their parent. That is exactly what composition's cascade refuses to do +(parts die with the whole), so this pattern is aggregation: a plain edge with +`cardinality`/`targetCardinality`, no `partOf`/`hasPart`. + +A worked, product-shaped version of this distinction: `Artifact -> +ArtifactVersion -> ArtifactChunk`, `ChangeSet -> ChangeSetItem`, `Skill -> +SkillVersion`, and `EvalRun -> results` are true composition (exactly one +parent, parts die with the whole). A folder tree where deleting a folder +re-homes its children to the deleted folder's parent, or a document whose +deletion retracts claims but leaves the referenced entity standing on +remaining support, is aggregation — declaring either `partOf` would be wrong, +not just imprecise. + ### Edge Relationships **`inverseOf`**: Declares two edge kinds as inverses of each other. @@ -225,6 +1253,12 @@ using equal kinds or `subClassOf` assignability. Matching the independent source and target unions is insufficient for source-dependent edges. A self-inverse edge must satisfy the same reversed-pair check against itself. +**Changing this on a populated graph**: adding or removing `inverseOf` +changes what a default `expand: "inverse"` traversal returns for existing +edges — a read-semantics change — so it is `breaking` and requires an +explicit `migrateSchema()`. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). + **`implies`**: Declares that one edge kind implies another exists. ```typescript @@ -260,6 +1294,12 @@ into a store or committed as a schema version (`createStore`, `createStoreWithSchema`, `store.evolve({ ontology })`) — including relations authored through a graph extension, not just `implies()` calls in code. +**Changing this on a populated graph**: adding or removing `implies` changes +what an `expand: "implying"` traversal returns for existing edges — +the same read-semantics reasoning as `inverseOf` — so it is also `breaking` +and requires an explicit `migrateSchema()`. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data). + ## Using the Ontology ### In Graph Definition @@ -284,7 +1324,7 @@ const graph = defineGraph({ disjointWith(Media, Person), // Composition - partOf(Episode, Podcast), + partOf(Episode, Podcast, { via: episodeOf }), // Edge relationships inverseOf(cites, citedBy), @@ -319,56 +1359,41 @@ registry.getImplyingEdges("knows"); // ["marriedTo", "bestFriends", "friends"] registry.getRelatedKinds("MachineLearning"); // ["DataScience", ...] ``` -## Custom Meta-Edges +## Type-Level Annotations -Define domain-specific meta-edges for serialized introspection metadata: +The custom `metaEdge()` factory was removed — it never made the registry +compute a custom closure or made the query builder execute custom inference +in any release; it only carried metadata through serialization. For +domain-specific, type-level vocabulary the library has no built-in reasoning +for, attach it as free-form JSON on the graph definition instead: ```typescript -import { metaEdge } from "@nicia-ai/typegraph"; - -// Custom meta-edge for prerequisite relationships -const prerequisiteOf = metaEdge("prerequisiteOf", { - transitive: true, - inference: "hierarchy", - description: "Learning prerequisite (Calculus prerequisiteOf LinearAlgebra)", -}); - -// Custom meta-edge for superseding relationships -const supersedes = metaEdge("supersedes", { - transitive: true, - inference: "substitution", - description: "Replacement relationship (v2 supersedes v1)", +const graph = defineGraph({ + // ... + annotations: { + prerequisiteOf: { + transitive: true, + pairs: [["Calculus", "LinearAlgebra"]], + }, + supersedes: { + transitive: true, + pairs: [["v2", "v1"]], + }, + }, }); ``` -### Meta-Edge Properties - -Each custom meta-edge can carry these properties as metadata. In the current -release they do **not** make the registry compute a custom closure or make the -query builder execute custom inference. Only the built-in relations in the -support matrix have runtime behavior. - -| Property | Type | Description | -| ------------ | --------------- | ------------------------- | -| `transitive` | `boolean` | A→B, B→C implies A→C | -| `symmetric` | `boolean` | A→B implies B→A | -| `reflexive` | `boolean` | A→A is always true | -| `inverse` | `string` | Name of inverse meta-edge | -| `inference` | `InferenceType` | How this affects queries | - -### Inference Types - -For custom meta-edges, `inference` is descriptive metadata for consumers: - -| Type | Description | -| ---------------- | -------------------------------------------- | -| `"subsumption"` | Query for X includes instances of subclasses | -| `"hierarchy"` | Enables broader/narrower traversal | -| `"substitution"` | Can substitute equivalent types | -| `"constraint"` | Validation rules | -| `"composition"` | Part-whole navigation | -| `"association"` | Discovery/recommendation | -| `"none"` | No automatic inference | +`annotations` (`GraphAnnotations`, a `Record`) travels +with the graph definition exactly like the core meta-edges do — persisted, +serialized, and visible through `store.introspect().annotations` — with no +KindRegistry closure computed over it. Your application interprets the +vocabulary, the same way `metaEdge()`'s custom properties always required +application code to interpret them. See +[examples/08-custom-ontology.ts](https://github.com/nicia-ai/typegraph/blob/main/packages/typegraph/examples/08-custom-ontology.ts) +for a complete example, including a transitive-closure walk over an +annotated relation. See +[Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](/schema-evolution#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) +for the migration from an existing `metaEdge()` declaration. ## Closure Computation @@ -396,16 +1421,23 @@ These have different semantics: - `broader`: Conceptual relation (ML **relates to** AI, but ML instance ≠ AI instance) ```typescript -// CORRECT: Type hierarchy +// CORRECT: Type hierarchy — Podcast's schema extends Media's subClassOf(Podcast, Media); -// CORRECT: Concept hierarchy +// CORRECT: Concept hierarchy — MachineLearning does not extend +// ArtificialIntelligence's schema, so this could not compile as subClassOf broader(MachineLearning, ArtificialIntelligence); -// WRONG: Don't mix them +// WRONG: Don't mix them — and a subClassOf declaration whose +// child does not structurally extend the parent no longer compiles, and is +// refused at registry build even when authored dynamically: // subClassOf(MachineLearning, ArtificialIntelligence); ``` +If you already have a `subClassOf` that models a taxonomy rather than a +subtype relationship, the fix is `broader(child, parent)` plus +`expansion: "narrower"` on the queries that relied on the old expansion. + ### Use Disjoint Constraints Prevent impossible combinations: @@ -438,62 +1470,111 @@ inverseOf(cites, citedBy); This lets you query efficiently in either direction without duplicating edges. -## API Reference +### Enforce an Instance-Level Taxonomy with `acyclic: true` -### Ontology Functions +`subClassOf` is a **type-level** relation between kinds; it says nothing +about instances of one concept kind pointing at each other (a category tree, +a concept hierarchy). For that, declare the edge itself `acyclic: true` and +traverse it with `.recursive()`: -#### `subClassOf(child, parent)` +```typescript +const Concept = defineNode("Concept", { schema: z.object({ name: z.string() }) }); +const broaderEdge = defineEdge("broader", { schema: z.object({}) }); -Declares type inheritance. +const graph = defineGraph({ + nodes: { Concept: { type: Concept } }, + edges: { + broader: { + type: broaderEdge, + from: [Concept], + to: [Concept], + acyclic: true, + }, + }, +}); -```typescript -function subClassOf(child: NodeType, parent: NodeType): OntologyRelation; +// A concept can never (transitively) be broader than itself. +const ancestors = await store + .query() + .from("Concept", "c") + .whereNode("c", (c) => c.id.eq(root.id)) + .traverse("broader", "e") + .recursive({ maxHops: 20 }) + .to("Concept", "ancestor") + .select((ctx) => ctx.ancestor) + .execute(); ``` -#### `broader(narrower, broader)` +This is not expressible as an OWL 2 DL axiom — OWL has no acyclicity +constraint on a property — but it matches SHACL-SPARQL's cycle shape +(`$this ex:broader+ $this`) exactly, enforced transactionally rather than +left to a query the caller must remember to run. -Declares hierarchical relationship (narrower concept to broader concept). +## API Reference -```typescript -function broader(narrower: NodeType, broader: NodeType): OntologyRelation; -``` +### Ontology Functions -#### `narrower(broader, narrower)` +#### `subClassOf(child, parent)` -Declares hierarchical relationship (broader concept to narrower concept). +Declares type inheritance. `child`'s schema output must structurally extend +`parent`'s — checked at compile time (a mismatched pair fails to compile, +naming the incompatible fields) and at registry build for what the type +checker cannot see (value-level constraints). See +[the structural contract](#subsumption-type-inheritance) above. ```typescript -function narrower(broader: NodeType, narrower: NodeType): OntologyRelation; +function subClassOf( + child: C, + parent: P & SubClassOfCheck, // SubClassOfCheck resolves to `unknown` on success, or a mismatch object naming the incompatible fields +): TypedOntologyRelation<"subClassOf", C, P>; ``` -#### `equivalentTo(a, b)` +#### `broader(narrower, broader)` -Declares semantic equivalence between types or with external IRIs. +Declares hierarchical relationship (narrower concept to broader concept). ```typescript -function equivalentTo( - a: NodeType | string, - b: NodeType | string -): OntologyRelation; +function broader( + narrower: N, + broader: B, +): TypedOntologyRelation<"broader", N, B>; ``` -#### `sameAs(kindA, kindBOrIri)` +#### `narrower(broader, narrower)` -Deprecated type-level alias of `equivalentTo`, including the equivalence with -external IRIs. Migrate to the graph-level TypeGraph Identity Profile for -individual identity. +Declares hierarchical relationship (broader concept to narrower concept). ```typescript -function sameAs(kindA: NodeType, kindBOrIri: NodeType | string): OntologyRelation; +function narrower( + broader: B, + narrower: N, +): TypedOntologyRelation<"narrower", B, N>; ``` -#### `differentFrom(a, b)` +#### `equivalentTo(kindA, kindBOrIri)` -Deprecated decorative type-level relation. Migrate to the graph-level TypeGraph -Identity Profile for individual identity. +Declares mutual subsumption between two registered kinds, or maps a kind to an +external IRI for cross-system mapping. The left parameter accepts an edge kind +too, so an edge can be mapped to an IRI — subsumption itself stays a node-kind +relation, so an equivalence class mixing a node kind and an edge kind, or +holding more than one registered edge kind, is refused +(`ONTOLOGY_EQUIVALENCE_INVALID_CLASS`; see "Equivalence" above). Between two +node kinds, `equivalentTo` carries the same structural contract as +`subClassOf`, checked in **both** directions. ```typescript -function differentFrom(a: NodeType, b: NodeType): OntologyRelation; +function equivalentTo( + kindA: A, + kindB: B & EquivalentToCheck, +): TypedOntologyRelation<"equivalentTo", A, B>; +function equivalentTo( + kindA: A, + iri: string, +): TypedOntologyRelation<"equivalentTo", A, string>; +function equivalentTo( + kindA: A, + kindB: B, +): TypedOntologyRelation<"equivalentTo", A, B>; ``` #### `disjointWith(a, b)` @@ -501,23 +1582,58 @@ function differentFrom(a: NodeType, b: NodeType): OntologyRelation; Declares mutual exclusion (types cannot share the same ID). ```typescript -function disjointWith(a: NodeType, b: NodeType): OntologyRelation; +function disjointWith( + a: A, + b: B, +): TypedOntologyRelation<"disjointWith", A, B>; ``` -#### `partOf(part, whole)` +#### `partOf(part, whole, options)` -Declares compositional relationship (part to whole). +Declares a compositional relationship (part to whole), realized by the edge +kind named in `options.via`. `options.partSide` is required only when `via`'s +endpoints admit both orientations between `part` and `whole` (a same-kind +pair, or any pair the edge's declaration otherwise leaves ambiguous). ```typescript -function partOf(part: NodeType, whole: NodeType): OntologyRelation; +type CompositionPartSide = "from" | "to"; + +type CompositionOptions = { + via: EdgeType; + partSide?: CompositionPartSide; + /** Default `"optional"`. See `existence` above. */ + existence?: "optional" | "required"; +}; + +function partOf( + part: Part, + whole: Whole, + options: CompositionOptions, +): TypedOntologyRelation<"partOf", Part, Whole>; ``` -#### `hasPart(whole, part)` +#### `hasPart(whole, part, options)` -Declares compositional relationship (whole to part). +Declares a compositional relationship (whole to part) — the mirror of +`partOf`. Declaring both directions for the same pair is redundant; pick one. +When both are declared they are one pair and must state the same `existence` +(an omitted one reads as `"optional"`); a disagreement raises +`ConfigurationError` (`ONTOLOGY_COMPOSITION_EXISTENCE_MIXED`) in either +declaration order, from `defineGraph` and `store.evolve` alike. + +Both factories refuse an option key they do not know +(`ONTOLOGY_COMPOSITION_OPTION_UNKNOWN`) and an `existence` outside +`"optional" | "required"` (`ONTOLOGY_COMPOSITION_EXISTENCE_INVALID`), and a +`partSide` outside `"from" | "to"` (`ONTOLOGY_COMPOSITION_PART_SIDE_INVALID`), +so a misspelled option cannot silently declare an optional part or persist a +schema that cannot be read back. ```typescript -function hasPart(whole: NodeType, part: NodeType): OntologyRelation; +function hasPart( + whole: Whole, + part: Part, + options: CompositionOptions, +): TypedOntologyRelation<"hasPart", Whole, Part>; ``` #### `relatedTo(a, b)` @@ -526,7 +1642,10 @@ Declares a symmetric association available through `registry.getRelatedKinds(kind)`. It has no query behavior. ```typescript -function relatedTo(a: NodeType, b: NodeType): OntologyRelation; +function relatedTo( + a: A, + b: B, +): TypedOntologyRelation<"relatedTo", A, B>; ``` #### `inverseOf(edgeA, edgeB)` @@ -534,7 +1653,10 @@ function relatedTo(a: NodeType, b: NodeType): OntologyRelation; Declares edge types as inverses of each other. ```typescript -function inverseOf(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation; +function inverseOf( + edgeA: A, + edgeB: B, +): TypedOntologyRelation<"inverseOf", A, B>; ``` #### `implies(edgeA, edgeB)` @@ -542,7 +1664,10 @@ function inverseOf(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation; Declares that one edge type implies another exists. ```typescript -function implies(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation; +function implies( + edgeA: A, + edgeB: B, +): TypedOntologyRelation<"implies", A, B>; ``` Each allowed pair in `edgeA` must be assignable to one allowed pair in `edgeB` @@ -551,24 +1676,6 @@ Each allowed pair in `edgeA` must be assignable to one allowed pair in `edgeB` schema version if they aren't — see [Edge Relationships](#edge-relationships) above. -#### `metaEdge(name, options?)` - -Creates a custom meta-edge for domain-specific relationships. - -```typescript -function metaEdge( - name: string, - options?: { - transitive?: boolean; - symmetric?: boolean; - reflexive?: boolean; - inverse?: string; - inference?: InferenceType; - description?: string; - }, -): MetaEdge; -``` - ### Type Registry API The type registry is available via `store.registry` and provides methods to query the ontology at runtime. diff --git a/apps/docs/src/content/docs/overview.md b/apps/docs/src/content/docs/overview.md index bc560aa6d..c2d3248d9 100644 --- a/apps/docs/src/content/docs/overview.md +++ b/apps/docs/src/content/docs/overview.md @@ -41,7 +41,7 @@ Type-level relationships enable sophisticated inference: | -------------- | ----------------------------------------- | ---------------------- | | `subClassOf` | Instance inheritance (Podcast IS-A Media) | Query expansion | | `broader` | Hierarchical concept (ML broader than DL) | Topic navigation | -| `equivalentTo` | Same concept, different name | Cross-system mapping | +| `equivalentTo` | Mutual subsumption between kinds, or a cross-system IRI mapping | Substitutable types + cross-system mapping | | `disjointWith` | Cannot be both (Person ≠ Organization) | Constraint validation | | `implies` | Edge entailment (marriedTo implies knows) | Relationship inference | | `inverseOf` | Edge pairs (manages/managedBy) | Bidirectional queries | diff --git a/apps/docs/src/content/docs/performance/indexes.md b/apps/docs/src/content/docs/performance/indexes.md index 36b31d1cd..12af9741d 100644 --- a/apps/docs/src/content/docs/performance/indexes.md +++ b/apps/docs/src/content/docs/performance/indexes.md @@ -510,6 +510,27 @@ compiled query relies on. They are declared once (`SYSTEM_INDEX_DECLARATIONS`, e inspection), and both dialects' schemas derive from that single list, so SQLite and PostgreSQL always carry the same set. +The `acyclic: true` reachability probe is one of the paths these +system indexes cover: its recursive term seeks `(graph_id, from_kind, +from_id, kind)`, filters `deleted_at IS NULL`, and projects `to_kind, +to_id` — every one of those columns is in `typegraph_edges_from_idx`, in +that leading order, so the walk is a covering index scan on SQLite and an +index-only scan on PostgreSQL once the visibility map is warm. No +additional index is needed to declare an edge `acyclic: true`. + +`store.edges[kind].bulkCreate()` runs one combined probe across every row in +the call, treating them all as simultaneous origins against the +newly-inserted set — the only way to catch a cycle formed entirely *within* +one batch (`a → b` and `b → a` in the same call). When the batch is itself a +long path (importing or seeding a 10,000-edge chain in one `bulkCreate`), +each origin's walk traverses the rest of that path, so cost grows with the +square of the batch size rather than linearly. Building a long chain-shaped +run of an acyclic edge kind incrementally — one `create()` per edge, or many +small `bulkCreate()` batches — avoids this and is also how `importGraph` / +`importGraphStream` behave: they check an `acyclic: true` edge kind one row +at a time regardless of `batchSize`, for the same reason (see +[Interchange](/interchange#an-edge-that-would-close-a-cycle)). + You normally never manage them: fresh databases get them at bootstrap, and `createStoreWithSchema()` brings an already-initialized database up to the running library version's set on boot (with `CREATE INDEX CONCURRENTLY` on PostgreSQL). Deployments that boot diff --git a/apps/docs/src/content/docs/performance/overview.md b/apps/docs/src/content/docs/performance/overview.md index 621095d7e..bd0a34e79 100644 --- a/apps/docs/src/content/docs/performance/overview.md +++ b/apps/docs/src/content/docs/performance/overview.md @@ -376,8 +376,8 @@ You don't need to chunk manually — pass arrays of any size and TypeGraph handl ### Transaction wrapping -On a transaction-capable backend, each bulk method call is atomic across all of its bind-budget -chunks. Eligible plain `nodes.bulkInsert()` and `nodes.bulkCreate()` calls, +On a transaction-capable backend, each bulk method call made on the store is atomic across all of +its bind-budget chunks. Eligible plain `nodes.bulkInsert()` and `nodes.bulkCreate()` calls, eligible plain node `bulkDelete()` calls, and direct edge `bulkInsert()` / `bulkCreate()` / `bulkDelete()` calls also provide whole-call atomicity on bundled transactionless roots through one native @@ -404,6 +404,62 @@ await store.transaction(async (tx) => { Without the wrapping transaction, a failure in a later bulk call leaves earlier calls committed. +#### Catching a bulk refusal inside a transaction + +A bulk call's atomicity comes from the transaction it runs in, and that +decides what a refused batch leaves behind: + +- **Called on the store**, the bulk method owns its transaction. A refusal + from any item rolls the whole call back: nothing of the batch is written. +- **Called on `tx` inside `store.transaction(...)`**, the bulk method runs on + your transaction and opens none of its own. The write layer has no + savepoint to roll one bulk call back to, so the bulk methods reach every + verdict their items can raise by reading before their first write: a + refusal you catch inside the callback leaves none of the batch written, + and the transaction can commit. + +```typescript +await store.transaction(async (tx) => { + try { + await tx.nodes.Person.bulkUpsertById(items); + } catch (error) { + // Nothing of `items` was written on `tx` by the refused call, unless a + // concurrent writer raced it: rethrow to roll the transaction back + // when that must not be left to chance. + throw error; + } +}); +``` + +That covers `bulkUpsertById` and `bulkReplaceById`, whose updates are +judged together with the creates they write first; `bulkGetOrCreateByConstraint` +and `bulkGetOrCreateByEndpoints`, whose matched items are judged together +with the rows they create; the composition batches (`bulkCreate` / +`bulkInsert` with `partOf`, `bulkReparent`); and, on an identity graph, +every node create, whose identity fold on a caller-chosen id is read before +the insert. A batch is judged as the state it produces, in the order it +writes, so a later item may take a unique key or a cardinality allowance an +earlier item gives up and is refused one an earlier item takes. A concurrent +writer that the per-graph write fence does not exclude can still claim a key +or remove a row between a batch's reads and its writes; that refusal comes +from the write, after earlier items wrote. See +[A bulk refusal caught inside `store.transaction` can keep earlier rows](/limitations#a-bulk-refusal-caught-inside-storetransaction-can-keep-earlier-rows) +for what stays outside the guarantee. + +Deciding a batch before writing costs reads. A batch that writes its rows one +at a time (an update half, resurrections, the matched items of a +get-or-create) prepares every row before the first write, against the rows +read inside the transaction, and probes the batch's unique keys and +cardinality claims once more as a set. A `bulkGetOrCreateByConstraint` batch +with more than one item that owes a write (an update, a resurrection, or an +attachment not already held), or one such item next to a new row, runs one +extra fenced pass that reads each item once more before the per-item writes; +a batch whose matched items all need nothing stays read-only. A +`bulkUpsertById`, `bulkReplaceById` or `bulkGetOrCreateByEndpoints` batch +that both creates and updates, and does not run as one native statement, +writes its creates through the portable create path rather than the native +batch create program, so they are decided together with the updates. + ### Choosing the right pattern ```typescript @@ -811,7 +867,7 @@ SQLite always operates at `serializable` isolation. When you define an ontology (e.g., `subClassOf`, `implies`), TypeGraph precomputes the full transitive closure at store initialization. Queries like -`.from("Parent", "p", { includeSubClasses: true })` use a pre-calculated list of kinds rather than +`.from("Parent", "p", { expansion: "subclasses" })` use a pre-calculated list of kinds rather than recursive lookups at runtime. ### Smart Select @@ -1002,8 +1058,11 @@ described above. ### Use specific kinds -Unless you specifically need to query across a hierarchy, avoid `includeSubClasses: true`. Being -specific about the node kind allows the SQL engine to use more restrictive index scans. +A query against a kind with `subClassOf` descendants returns their rows too +by default. Unless you specifically need to query across the hierarchy, pass +`{ expansion: "exact" }` (or set `queryDefaults.expansion: "exact"` on the +store). Being specific about the node kind allows the SQL +engine to use more restrictive index scans. ### Use cursor pagination diff --git a/apps/docs/src/content/docs/performance/profiler.md b/apps/docs/src/content/docs/performance/profiler.md index 2d638b486..f59cb6db0 100644 --- a/apps/docs/src/content/docs/performance/profiler.md +++ b/apps/docs/src/content/docs/performance/profiler.md @@ -47,9 +47,10 @@ queries execute, it extracts property access patterns from the query AST: The profiler then compares these patterns against your declared indexes and generates recommendations for missing coverage. -## Kinds and `includeSubClasses` +## Kinds and subclass expansion -When you query with `includeSubClasses: true`, a single alias can represent multiple kinds. +When an alias is polymorphic — the default for a kind that has subclasses, or an explicit +`expansion: "subclasses"` — a single alias can represent multiple kinds. When the profiler is attached to a store, it uses the graph schema to attribute a property access only to kinds where that JSON path exists. This avoids recommending indexes for unrelated subclasses. diff --git a/apps/docs/src/content/docs/provenance.md b/apps/docs/src/content/docs/provenance.md index b9c8cd5ea..b9a980c6b 100644 --- a/apps/docs/src/content/docs/provenance.md +++ b/apps/docs/src/content/docs/provenance.md @@ -120,13 +120,70 @@ const current = after ? store.asOfRecorded(after) : undefined; The report partitions facts relative to the retracted source: -- `died`: facts that were believed before and lost grounded support +- `died`: every fact whose currency this transition closed - `survivedVia`: affected facts that still have a firing justification - `unaffected`: previously believed facts outside the source's provenance +`died` names every close, including a fact that was not believed to begin with. +A live fact is unsupported whenever no justification of it fires, and a +transition that reaches such a fact closes it and reports it. Ordinary store +calls reach that state: a justification with two premises, one of them a source +created with its retracted flag already set, leaves the fact it derives live and +unsupported from the moment it is linked, with no constraint violated and +nothing for `store.verifyConstraintFences()` to report. The composition case is +narrower — a required part whose whole was tombstoned outside the store's own +paths — and that one the fence audit does report. Either way a tombstone the +report cannot mention would be invisible data loss, so the pass that writes the +tombstones and the report that names them read one set of rows. + `unRetract(source)` clears the source flag, recomputes support, and reopens facts that regain support. +## Composition and retraction + +A [required composition part](/ontology#composition) cannot exist without a +live whole, so its belief status follows its whole's. Support treats the +dependency as part of the fact's grounding: a required part is supported only +while the whole it currently hangs from is itself supported (a whole that is +also a fact kind) or live (any other whole, including a plain node that carries +no belief status at all). + +Closing a whole therefore closes its required parts in the same transition, +transitively through a part that is itself a whole, and the report names every +one of them in `died`. Reopening the whole reopens the parts that are otherwise +supported, because a reopen is driven by support rather than by a ledger of +what a close closed: a part whose own justification no longer fires stays +closed. A required part is closed even when a different source supports it — +the existence dependency dominates its own grounding. + +An optional part is untouched. It can exist with no whole, so it keeps both its +attachment to the closed whole and its own belief status; the composition claim +still stops a second whole from taking it. + +No edge is deleted by any of this, so `store.verifyConstraintFences()`'s +`compositionExistence` family reports nothing after a close: a closed required +part is tombstoned, not an orphan. + +A whole that is not a fact is held to the same liveness the write path holds an +attachment's whole to: present and not tombstoned. A closed validity window does +not make it dead, because the write path would still accept it as a whole. A +whole that IS tombstoned leaves its live required parts unsupported, which is +also what the `compositionExistence` audit reports for that state. A transition +that reaches such a part closes it and names it in `died`, even though the part +was already unbelieved when the transition began. + +Each closed fact fires its own `delete` operation hook, parts included — unlike +the delete cascade, which emits one event for the whole. A belief close has no +cascade to report: every part reaching the close is a fact of its own, closed by +its own support verdict, so its hook is its own too. + +A configuration that cannot reach a required part is refused when the capability +is created. If a fact kind is the whole of an `existence: "required"` pair whose +part kind is not itself in `fact.kinds`, `createRetractionCapability` throws +`ConfigurationError` (`PROVENANCE_REQUIRED_PART_NOT_A_FACT`) naming both kinds — +closing such a whole would leave a live required part hanging from it, and no +transition could fix that. + Use `retractMany(sources)` or `unRetractMany(sources)` to change several source flags in one recorded transaction: @@ -155,3 +212,9 @@ Use `holding()` when you only need the current well-founded believed facts: ```typescript const facts = await provenance.holding(); ``` + +`holding()` waits for a transition or history write in flight on the same +graph, so its multi-statement read never sees half of one, and it writes +nothing: on PostgreSQL it takes the graph's advisory lock and no row, so it +runs in a read-only transaction or under a `SELECT`-only role. On a standby +the lock excludes nothing, since the primary's writers do not hold it there. diff --git a/apps/docs/src/content/docs/queries/execute.md b/apps/docs/src/content/docs/queries/execute.md index ea0112d38..3bf4e5969 100644 --- a/apps/docs/src/content/docs/queries/execute.md +++ b/apps/docs/src/content/docs/queries/execute.md @@ -236,7 +236,8 @@ bind-parameter budget. ### Pagination with Traversals -Pagination works with graph traversals: +Pagination works with graph traversals. Each matched row is one page entry, and the order may name +the traversed alias: ```typescript const employeesPage = await store @@ -254,6 +255,55 @@ const employeesPage = await store .paginate({ first: 50 }); ``` +### Recursive traversals are not paginated + +`paginate()`, `page()` and `stream()` refuse a query with a recursive traversal (`recursive()`, +`parts()` or `wholes()`) with a `ConfigurationError` whose `details.code` is +`RECURSIVE_PAGINATION_UNSUPPORTED`. The refusal is the same on every backend and happens before +any statement runs, including for `recursive({ maxHops: 1 })`. `parts()` and `wholes()` with +`maxHops: 1` and neither `depth` nor `path` are the exception: they compile to a fixed one-hop step, +not a recursive one, so they page and stream like `traverse()`. See +[Paging direct parts](/queries/recursive#paging-direct-parts). + +A recursive traversal fans one start node out into rows that have no single edge to extend the +start node's keys with, so a cursor can only name the start node. The next page would resume after +that node and lose the rows of it not yet returned. Paging one start node at a time does not avoid +this, because a single start node's rows are exactly the ones that straddle a page boundary. + +To read a recursive walk in bounded pieces, either: + +- Read it with `execute()` and a bound you choose: `.limit(n)` on the query, or + `recursive({ maxHops })` to bound the walk itself. +- Page the start nodes in a query without the recursive traversal, then run the recursive walk for + each page of start nodes: + +```typescript +const startNodes = store + .query() + .from("Person", "p") + .orderBy("p", "name", "asc") + .select((ctx) => ctx.p.id); + +let after: string | undefined; +do { + const page = await startNodes.paginate({ + first: 50, + ...(after === undefined ? {} : { after }), + }); + const reachable = await store + .query() + .from("Person", "p") + .whereNode("p", (p) => p.id.in(page.data)) + .traverse("knows", "e") + .recursive({ maxHops: 3 }) + .to("Person", "friend") + .select((ctx) => ({ from: ctx.p.id, friend: ctx.friend.name })) + .execute(); + // handle `reachable` + after = page.nextCursor; +} while (after !== undefined); +``` + ## Streaming For very large datasets, use streaming to process results without loading everything into memory. @@ -655,9 +705,17 @@ Both `paginate()` and `stream()` require an `orderBy()` clause: ### Stable Ordering -Cursor pagination and streaming automatically append missing start-node identity keys: `id ASC` -for a single kind, or `kind ASC` and `id ASC` for a multi-kind source. Existing caller-specified -identity ordering is preserved. Offset pagination needs an explicit total ordering. +Cursor pagination and streaming automatically append the keys that identify one result row, so a +sort that ties never skips or repeats a row at a page boundary. For the start node that is +`id ASC`, or `kind ASC` and `id ASC` for a multi-kind source. Each traversal then adds the `id` of +the edge it matched: one start node that fans out into several rows, two parallel edges +between the same pair of nodes, and several rows that reach one intermediate node of a multi-hop +traversal, page as distinct rows. Existing caller-specified identity ordering +is preserved, and order keys may read any alias in the query: the start node, a traversed node, or +an edge. Offset pagination needs an explicit total ordering. + +A cursor encodes the full key list, so a cursor saved from a traversal query before these edge keys +were appended is refused; restart that pagination from the first page. For deterministic offset pagination of one kind, include `id` in your ordering: diff --git a/apps/docs/src/content/docs/queries/overview.md b/apps/docs/src/content/docs/queries/overview.md index 5f64908e6..c020a6694 100644 --- a/apps/docs/src/content/docs/queries/overview.md +++ b/apps/docs/src/content/docs/queries/overview.md @@ -139,14 +139,19 @@ store ### Be Specific with Kinds -Unless you need subclass expansion, use exact kinds: +Querying a kind with subclasses is polymorphic **by default** — `from`/`to`/ +`fromDynamic`/`toDynamic` include subtype rows unless you narrow. If you only +want the exact kind, say so: ```typescript -// More efficient: Exact kind +// Exact kind by construction — no subclasses to expand .from("Podcast", "p") -// Less efficient: Includes all subclasses -.from("Media", "m", { includeSubClasses: true }) +// Polymorphic by default: includes every Media subclass (Podcast, Article, ...) +.from("Media", "m") + +// Narrow it back to exactly Media rows +.from("Media", "m", { expansion: "exact" }) ``` ### Always Paginate Large Results diff --git a/apps/docs/src/content/docs/queries/recursive.md b/apps/docs/src/content/docs/queries/recursive.md index a31196734..bfe183dd9 100644 --- a/apps/docs/src/content/docs/queries/recursive.md +++ b/apps/docs/src/content/docs/queries/recursive.md @@ -242,6 +242,10 @@ Each route alternates node and edge references, starting and ending with a node: do not hydrate node or edge properties. Legacy `path: true` and `path: "alias"` continue to return node ID arrays. +To keep only the paths that end at particular nodes, filter the endpoint with `.where(...)`. A +`whereNode()` on the target alias constrains every hop, so a path through a node that fails it is +never extended (see [Expansion and completed-result filters](#expansion-and-completed-result-filters)). + ## Chaining Fixed and Recursive Traversals Fixed-hop and recursive traversals compose from left to right. Each later stage expands from the @@ -477,6 +481,17 @@ const results = await store To get unique nodes, deduplicate in your application or use [set operations](/queries/combine). +## Pagination + +A query with a recursive traversal cannot be paged with `paginate()`, `page()` or `stream()`: a +cursor cannot position itself inside the rows one start node fans out into, so those calls refuse it +with a `ConfigurationError` (`details.code` is `RECURSIVE_PAGINATION_UNSUPPORTED`) before running +any statement. Read the walk with `execute()` and a `limit()` or `maxHops` you choose, or page the +start nodes in a query without the recursive traversal and run the walk for each page. See +[Recursive traversals are not paginated](/queries/execute#recursive-traversals-are-not-paginated). +The one-hop composition shortcut is not recursive and does page; see +[Paging direct parts](#paging-direct-parts). + ## Depth Limits Two safety caps prevent runaway recursion: @@ -508,6 +523,192 @@ The default depth was lowered from 100 to 10. If your traversals relied on the implicit 100-hop cap, add `.recursive({ maxHops: 100 })`. ::: +## Composition Shortcuts: `parts()` and `wholes()` + +A `.traverse(edgeKind, "e").recursive().to(kind, "x")` chain is the general +tool for "follow this edge to unknown depth". When the edge you'd name is a +declared [composition](/ontology#composition) relation (`partOf` / `hasPart`), +`parts()` and `wholes()` are shorter and stay correct as the relation grows: + +```typescript +// Instead of naming the realizing edge kind and recursing over it by hand: +const segments = await store + .query() + .from("Episode", "e") + .whereNode("e", (e) => e.id.eq(episodeId)) + .traverse("segmentOf", "seg_edge", { direction: "in" }) + .recursive() + .toDynamic("Segment", "s") + .select((ctx) => ctx.s) + .execute(); + +// parts() derives the edge kind(s) and direction(s) from the registry: +const parts = await store + .query() + .from("Episode", "e") + .whereNode("e", (e) => e.id.eq(episodeId)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); +``` + +The difference is not just brevity. A composition relation can be realized by +**more than one edge kind** across levels — a Podcast whose Episodes attach +via `episodeOf` and whose Segments attach via `segmentOf` — and those levels +can even mix orientation, a `partOf` (`part -> whole`) edge at one level and a +`hasPart` (`whole -> part`, the `has_*` convention) edge at another. +`parts()`/`wholes()` follow the whole declared structure in one call; spelling +the equivalent by hand would mean naming every realizing edge kind and getting +each one's direction right. + +```typescript +partOf(Episode, Podcast, { via: episodeOf }); +partOf(Segment, Episode, { via: segmentOf }); + +// One call reaches both levels, regardless of how many edge kinds realize them: +const parts = await store + .query() + .from("Podcast", "p") + .whereNode("p", (p) => p.id.eq(podcastId)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); +// parts includes every Episode AND every Segment transitively under the Podcast. + +// wholes() is the mirror — the ancestor chain from a part: +const ancestors = await store + .query() + .from("Segment", "s") + .whereNode("s", (s) => s.id.eq(segmentId)) + .wholes("x") + .select((ctx) => ctx.x) + .execute(); +// ancestors includes the Segment's Episode and that Episode's Podcast. +``` + +**Direction is derived, not declared.** `registry.compositionPartSide` decides +per realizing edge kind: a `part -> whole` edge is followed reversed (`"in"`) +by `parts()` (forward, `"out"`, by `wholes()`), and a `whole -> part` (`has_*`) +edge is followed in its own direction (`"out"` by `parts()`, reversed, `"in"`, +by `wholes()`). When a relation mixes both orientations across levels, the +step compiles to one traversal whose direct and inverse edge-kind sets union +via the same mechanism `{ expand: "inverse" }` already uses — no extra query, +no extra call. + +**Recursion is the default**, matching the value proposition over `traverse()`: +`parts()`/`wholes()` reach the full transitive closure unless you pass +`maxHops: 1` for the direct level only. Unlike a bare `.recursive()`, which +stops at 10 hops unless told otherwise, an omitted `maxHops` here applies no +hop ceiling: the walk ends where the composition ends, so a chain of any +length comes back whole, on every backend. A `maxHops` you state bounds the +walk exactly as given (up to 1000, as for any recursive step). The unbounded +walk tracks each row's path to prevent cycles, so on a chain thousands of +levels deep its cost grows faster than that of +`store.subgraph(root, { composition: true })`, which reads the owned unit with a +set-semantics walk; the rows returned are complete either way. `depth` and +`path` behave exactly as they do for `.recursive({ depth, path })`. Like any recursive step, they chain +after earlier fixed or recursive traversals as a later stage (see +[Chaining Fixed and Recursive Traversals](#chaining-fixed-and-recursive-traversals)). + +```typescript +const directChildren = await store + .query() + .from("Section", "s") + .whereNode("s", (s) => s.id.eq(sectionId)) + .parts("x", { maxHops: 1 }) // direct sub-sections only, no recursion + .select((ctx) => ctx.x) + .execute(); +``` + +### Paging direct parts + +`parts()` and `wholes()` with `maxHops: 1` and neither `depth` nor `path` compile to a fixed one-hop +step rather than a recursive one, so `paginate()`, `page()` and `stream()` accept them, with every +realizing edge kind and orientation of that level in one result. This is the way to list one level +of a part tree at a time, however many children a node has: + +```typescript +const children = store + .query() + .from("Folder", "f") + .whereNode("f", (f) => f.id.eq(folderId)) + .parts("child", { maxHops: 1 }) + .orderBy("f", "name", "asc") + .select((ctx) => ({ kind: ctx.child.kind, id: ctx.child.id })); + +let after: string | undefined; +do { + const page = await children.paginate({ + first: 50, + ...(after === undefined ? {} : { after }), + }); + // render `page.data` + after = page.nextCursor; +} while (after !== undefined); + +for await (const child of children.stream({ batchSize: 500 })) { + // one row per direct part +} +``` + +Asking for `depth` or `path`, or for any `maxHops` above 1, makes the step recursive again, and the +pagination surfaces refuse it as described in [Pagination](#pagination). + +**`via` narrows the walk to one realizing edge.** `parts("x", { via: episodeOf })` +on the Podcast above follows `episodeOf` alone, so it returns the Episodes and +not their Segments; pass the edge's type or its kind string. A `via` that +realizes no pair at the source alias itself, such as `segmentOf` on a Podcast, +is refused with `ConfigurationError` (`COMPOSITION_VIA_NOT_DECLARED`), and +`details.declaredVia` lists the edges that would work. + +**They follow ownership, not validity.** A composition step follows the +edges that bind a part to its whole — the same membership the delete cascade +counts — rather than the edge's validity window. An ended `population: "one"` edge still binds +its part, because a `one` binding holds for the row's whole life, so it is +still walked and its row still carries its `validTo`. An ended +`population: "oneActive"` edge is the history a `reparent` leaves behind and +is not walked. The query's temporal mode sets the instant: + +- A current query, and an `includeEnded` or `includeTombstones` query, walks + what is owned now. Those modes widen which nodes and which other edges a + query sees, not what a whole owns. +- `store.asOf(t)` walks what was owned at `t`: an edge that existed at `t` + and, for `"oneActive"`, had not yet ended. A `"one"` edge ended before `t` + still binds at `t`. +- `store.asOfRecorded(...)` walks the ownership its pinned revision recorded. + +That rule is for the edges. Nodes still follow the query's temporal filter at +every hop, so a part whose own validity window has ended (or not yet started) +is not returned, and neither is anything reachable only through it, although +the whole owns them and a delete would remove them. To list what a delete +removes, use [`planDelete`](/ontology#previewing-a-delete). A plain +`.traverse()` over a realizing edge kind is not a composition step and keeps +the edge's validity window. + +**The result alias is untyped** (reached via `.field(name)`, like +[`fromDynamic`](/queries/source#runtime-declared-kinds)): the parts or wholes +closure under a kind is registry data — it may span more than one node kind +with different schemas — not a single kind the graph's static type can name. + +**`parts()` on a kind declaring no composition parts refuses** rather than +returning zero rows — an accepted call that cannot mean anything is an error, +not a silently empty result: + +```typescript +await store.query().from("Segment", "s").parts("x"); +// throws ConfigurationError, details.code: "COMPOSITION_NO_PARTS_DECLARED" +// (Segment has no partOf/hasPart relation declaring parts of its own) +``` + +`wholes()` refuses the same way with `COMPOSITION_NO_WHOLES_DECLARED` on a +kind declaring no composition wholes. `expand` is not accepted on either +step: it means "same relation, more members" (ontology implying/inverse +expansion), and the edge-kind set here is derived from the composition +relation, not from an expansion mode. + +For the whole-plus-parts *export* shape (rather than a query result), see +[`subgraph({ composition: true })`](/schemas-stores#composition-export). + ## Limitations - **Match predicates in a staged query can reference only the alias they constrain.** Cross-alias match diff --git a/apps/docs/src/content/docs/queries/source.md b/apps/docs/src/content/docs/queries/source.md index 75b6dcd08..85be9b2b6 100644 --- a/apps/docs/src/content/docs/queries/source.md +++ b/apps/docs/src/content/docs/queries/source.md @@ -24,9 +24,9 @@ const results = await store | Parameter | Type | Description | |-----------|------|-------------| -| `kind` | `string` | The node kind to query (must exist in your graph definition) | +| `kind` | `string \| readonly string[]` | The node kind to query (must exist in your graph definition), or a nonempty list of kinds | | `alias` | `string` | A unique identifier for referencing this node in the query | -| `options.includeSubClasses` | `boolean` | Include nodes of subclass kinds (default: `false`) | +| `options.expansion` | `"exact" \| "subclasses" \| "narrower"` | The alias's expansion axis. `"subclasses"` (the default) includes nodes of subclass kinds; `"exact"` is the named kind alone; `"narrower"` follows `broader`/`narrower` descendants instead | ## Multiple kinds in one query @@ -43,13 +43,29 @@ const page = await store .paginate({ first: 20 }); ``` -The source scans exactly these kinds. Repeated kinds are normalized, an empty list is rejected, -and unknown kinds throw `KindNotFoundError`. List sources do not accept an options bag or expand -subclasses. For a reusable list, preserve its nonempty tuple type with `as const`. +A list source follows the same expansion rule as `from(kind)`: each listed kind expands through +its subclasses and `equivalentTo` kinds unless the call or the store says otherwise, and the +source scans the union. `from(["Media", "Company"], "entity")` therefore also returns `Podcast` +rows when `Podcast` is a subclass of `Media`. Pass the same `expansion` option a single kind +takes, or rely on [`queryDefaults.expansion`](/schemas-stores#createstoregraph-backend-options): -Predicates and database expressions expose compatible properties shared by every selected kind, +```typescript +// Only rows whose kind is exactly Media or Company. +store.query().from(["Media", "Company"], "entity", { expansion: "exact" }); +``` + +The alias is typed kind by kind, the way `from(kind)` would type each listed kind: a listed kind +the ontology affects has its `kind` and `NodeId` widened, while the other listed kinds keep their +literal types. `{ expansion: "exact" }` keeps the exact union. Repeated kinds, including a kind +that is both listed and reached through another listed kind's expansion, are normalized; an empty +list is rejected, and unknown kinds throw `KindNotFoundError`. For a reusable list, preserve its +nonempty tuple type with `as const`. + +Predicates and database expressions expose compatible properties shared by every listed kind, plus system fields such as `id` and `kind`. A property present on only one kind, or declared with -incompatible types across kinds, cannot be used as a shared query field. Full-node selections +incompatible types across kinds, cannot be used as a shared query field. A subclass reached +through a listed kind reads each shared property as that listed kind declares it, the same way a +single polymorphic alias does (see [Subclass Expansion](#subclass-expansion)). Full-node selections retain each kind's properties as a discriminated union; check the returned node's `kind` before reading kind-specific properties. @@ -98,7 +114,12 @@ store ## Subclass Expansion -If your ontology defines subclass relationships, you can query a parent kind and include all subclasses: +A query against a kind that other kinds declare `subClassOf` (or that +participates in a registered-kind `equivalentTo`/`sameAs`) is **polymorphic by +default**: `subClassOf` carries a structural contract (the child's schema +extends the parent's, checked at compile time and at registry build), so a +supertype query that silently excluded its subtypes would be a partial answer +presented as complete. ```typescript // Graph definition with subclass relationships: @@ -106,29 +127,173 @@ If your ontology defines subclass relationships, you can query a parent kind and // subClassOf(Article, Media) // subClassOf(Video, Media) -// Query only exact Media nodes (default behavior) -const exactMedia = await store - .query() - .from("Media", "m") - .select((ctx) => ctx.m) - .execute(); - -// Query Media and all subclasses +// Query Media and all subclasses (default behavior) const allMedia = await store .query() - .from("Media", "m", { includeSubClasses: true }) + .from("Media", "m") .select((ctx) => ({ - kind: ctx.m.kind, // "Media" | "Podcast" | "Article" | "Video" + // Runtime values: "Media", "Podcast", "Article", or "Video" — the row's + // actual kind. Static type: `string`, because the alias is widened for a + // kind the ontology can affect (see below). + kind: ctx.m.kind, title: ctx.m.title, })) .execute(); + +// Query only exact Media nodes +const exactMedia = await store + .query() + .from("Media", "m", { expansion: "exact" }) + .select((ctx) => ctx.m) + .execute(); +``` + +By default (`expansion` absent, or explicitly `"subclasses"`): + +- Results include nodes of the specified kind AND all subclass kinds — AND any + kind declared `equivalentTo` the specified kind (or one of its subclasses), + since equivalence is mutual subsumption and folds into the same closure +- The `kind` field in results reflects the actual node kind, so its type is + widened to `string` (and the row's id brand widened correspondingly) for a + kind the ontology can actually affect — a graph declaring no subsumption + relations keeps its exact literal types +- Only the properties the structural contract guarantees — the PARENT kind's + own properties — are statically accessible; a subclass-only field needs + `fromDynamic()` or a cast, the same way a graph-extension kind's field does +- Each of those properties is read as the parent kind declares it, in + `select()`, `whereNode()`, `orderBy()` and `groupBy()` alike. A subclass that + omits a parent-optional property, or narrows its type, does not change that: + its rows read as absent or as the parent's type. `$fulltext.matches()` is + likewise available when the parent kind declares a `searchable()` field, and + matches only the rows that have searchable content. A subclass that + redeclares that field as a plain string (or an `embedding()` field as a plain + number array) is still a valid subtype, but its rows would never reach the + index, so `$fulltext.matches()` refuses and names the kind, and the embedding + accessor is not offered + +**Annotating a relation as `OntologyRelation` widens every alias in the +graph.** Every relation factory returns a typed relation carrying its +meta-edge name and endpoint kinds, which is what lets the alias type tell +"this relation does not touch my kind" from "this relation was never typed". +Storing one in a variable annotated `OntologyRelation` (or the whole array in +a `readonly OntologyRelation[]`) erases those literals, and the alias type +then widens every kind in the graph conservatively rather than risk typing a +polymorphic alias as exact. Let the relation types be inferred, or annotate +with `typeof` the factory call, if you want the narrow types back. + +**An endpoint declared as a union of kinds widens every kind the union can +hold.** A helper that declares a subclass of either media root — +`(root: typeof Audio | typeof Video) => subClassOf(Episode, root)`, or the +same helper annotated `NodeType<"Audio" | "Video">` — keeps its meta-edge name +and its endpoint kind literals, so nothing is erased, but its declared type no +longer says which root the call passed. Both `Audio` and `Video` therefore get +the polymorphic alias type, while a kind the union cannot hold stays exact. +Pass the root directly (`subClassOf(Episode, Audio)`) if you want only the +root you named to widen. + +**A subclass only an extension declares is refused, not mistyped.** The +`kind`/`NodeId` widening above is computed from your compile-time graph +definition. A `subClassOf` an [extension](/graph-extensions) adds through +`store.evolve(...)` changes what the live registry returns, and it stays +live: `createStoreWithSchema(graph, backend)` folds the persisted extension +back in on every later boot. Neither changes the compile-time `G`, so +`from("Media", "m")` would type `m` as exact `Media` while a row could come +back as the extension's `Podcast`. An alias that states no `expansion` over +such a kind is therefore refused with `ConfigurationError` +(`QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`, naming the kind and the extra +kinds) on `from()`, a list source, and `to()`. A list source is refused only +for an extra kind no member of its type admits: a kind the list itself names +is typed exactly by the list, and so is a kind in the expansion of a listed +kind the compile-time graph already types polymorphically. State the axis to +choose: + +```typescript +// Typed polymorphically: kind is string, the id is not Media's own NodeId. +store.query().from("Media", "m", { expansion: "subclasses" }); +// Media rows only, typed exactly. +store.query().from("Media", "m", { expansion: "exact" }); +// Or reach the kind as a runtime kind. +store.query().fromDynamic("Media", "m"); ``` -When `includeSubClasses: true`: +A kind the compile-time graph's ontology affects (it has a compile-time +subclass, say) is not refused when an extension adds another one. +`store.search()` is never refused: its `expansion: "subclasses"` hit is typed +polymorphically whatever the compile-time graph declares, as a stated +`"subclasses"` alias is. + +The refusal is decided from the **values** of the graph's ontology at run time, +because the store cannot see the type it was declared with. So it also fires +where the static type already widened: for a store typed over a wide graph +type (`Store` in generic tooling or a wrapper), and for an ontology +whose literals an `OntologyRelation` annotation erased. Such code reads its +aliases as `string`-kinded anyway, so state `expansion` on the call or use +`fromDynamic()`. + +### Scope at a glance + +| Surface | Default scope | How to change it | Follows `queryDefaults.expansion`? | Static `kind` type | +|---------|---------------|------------------|------------------------------------|--------------------| +| `from(kind, alias)` | The kind, its subclasses and its `equivalentTo` kinds | `expansion: "exact" \| "subclasses" \| "narrower"` | Yes | `string` for a kind the compile-time ontology affects, else the literal; `"exact"` keeps the literal, a stated `"subclasses"` widens | +| `from([kinds], alias)` | Each listed kind expanded as `from(kind)` would, deduplicated | The same `expansion` option | Yes | Per listed kind, as `from(kind)` types it | +| `traverse(...).to(kind, alias)` | Same as `from(kind)` | The same `expansion` option | Yes | Same as `from(kind)` | +| `fromDynamic()` / `toDynamic()` | Same as `from(kind)` | The same `expansion` option | Yes | `string` (untyped for `"narrower"`); the token's literal for a `RuntimeNodeKind` under `"exact"` | +| [`parts()` / `wholes()`](/queries/recursive#composition-shortcuts-parts-and-wholes) | Every kind the composition relation reaches | `maxHops`, `via` | No | Untyped | +| Node collection reads (`find`, `count`, `updateWhere`, `compareAndSet`) | The exact kind | No option; query instead | No | The literal | +| [`store.search`](/fulltext-search) (`fulltext`, `vector`, `hybrid`) | The exact kind | `expansion: "exact" \| "subclasses"` | No | The literal; `"subclasses"` widens, as a stated `from(kind, alias, { expansion: "subclasses" })` does | +| [`subgraph({ includeKinds })`](/schemas-stores#storesubgraphroot-options) | Exactly the listed kinds | List every kind to include | No | Not applicable | + +An omitted `expansion` means the store default everywhere in the query +builder, and nowhere else. A kind whose subclasses only a persisted extension +declares is refused when the axis is left to the default (see above). + +Pass `{ expansion: "exact" }` to narrow one alias back to the exact +kind, or set `queryDefaults.expansion: "exact"` on `createStore(...)` to +restore the exact-kind rows everywhere. The store default changes which rows +come back, not how an alias is typed: an alias over an affected kind keeps the +widened `kind` and `NodeId` types whatever the default, so state +`{ expansion: "exact" }` on the call to keep the literal alias type. `search()`, the collection +APIs (`find`, `count`, `updateWhere`, `compareAndSet`), and +`subgraph({ includeKinds })` are unaffected by this default and stay +exact-kind. An `updateWhere()` `candidates` query is an ordinary query, though: +it follows this default, and it must resolve to exactly one kind, so pass +`{ expansion: "exact" }` when the candidate kind has subclasses or equivalents +(see [`updateWhere`](/schemas-stores#updatewhereparams)). + +### `expansion: "narrower"` — kind-level taxonomies + +`broader`/`narrower` model a hierarchy that is **not** a subtype relationship +— no schema contract is claimed, so the alias type stays untyped (`NodeAlias`, +no static property access). Use it for a small, fixed vocabulary known at +schema-authoring time; for a vocabulary that grows at runtime, prefer an +instance-level concept kind with a `broader` **edge** traversed +with `.recursive()` (see [Ontology](/ontology)). + +```typescript +// ontology: [broader(Podcast, Media), broader(Video, Media)] +const rows = await store + .query() + .from("Media", "m", { expansion: "narrower" }) + .select((ctx) => ctx.m) + .execute(); +``` -- Results include nodes of the specified kind AND all subclass kinds -- The `kind` field in results reflects the actual node kind -- All properties common to the parent kind are accessible +One alias carries exactly one axis, because `expansion` is one option with +one value — a contradiction like "subclasses and narrower" is not +expressible. An expansion naming a kind that is not registered, or (on +`to()`/`toDynamic()`) that the traversed edge does not admit as an endpoint, +is refused rather than silently narrowed; so is an `expansion` value outside +the three axes (`QUERY_ALIAS_EXPANSION_INVALID`), which only a JavaScript +caller can reach. + +Omitting `options` entirely, passing `{}`, or passing an explicit `undefined` +all mean the same thing — take the store default — so an options bag can be +forwarded through your own helper without special-casing the empty case. A bag +whose `expansion` is not one literal — `{ expansion?: "exact" }` from a +wrapper that only ever narrows, say — forwards too; since the axis is not +known at compile time, that alias is typed conservatively (untyped, the same +way `"narrower"` is). State a literal axis at the call site to keep the +precise alias type. ## Runtime-declared kinds @@ -171,6 +336,9 @@ available. String inputs retain the discriminator-based dynamic surface. - [Filter](/queries/filter) - `whereNode()`, `whereEdge()` - [Traverse](/queries/traverse) - `traverse()`, `optionalTraverse()` +- [Recursive](/queries/recursive) - `.recursive()`, and the composition + shortcuts [`parts()` / + `wholes()`](/queries/recursive#composition-shortcuts-parts-and-wholes) - [Shape](/queries/shape) - `select()`, `aggregate()` - [Order](/queries/order) - `orderBy()`, `limit()`, `offset()` - [Aggregate](/queries/aggregate) - `groupBy()`, `groupByNode()` diff --git a/apps/docs/src/content/docs/queries/traverse.md b/apps/docs/src/content/docs/queries/traverse.md index 3e83d499f..4b7bd3179 100644 --- a/apps/docs/src/content/docs/queries/traverse.md +++ b/apps/docs/src/content/docs/queries/traverse.md @@ -59,7 +59,7 @@ Uses the same options as `traverse()`, but returns optional edge/node values in |-----------|------|-------------| | `nodeKind` | `string` | The target node kind | | `nodeAlias` | `string` | Unique alias for referencing this node | -| `options.includeSubClasses` | `boolean` | Include subclass kinds (default: `false`) | +| `options.expansion` | `"exact" \| "subclasses" \| "narrower"` | The target alias's expansion axis. `"subclasses"` (the default) includes subclass kinds — and any kind declared `equivalentTo` the target; `"exact"` is the named kind alone; `"narrower"` follows `broader`/`narrower` descendants instead | ## Direction @@ -289,6 +289,15 @@ from your ontology. To opt out for a single traversal, pass `expand: "none"`. To for all traversals, set `queryDefaults.traversalExpansion` in `createStore` options. ::: +## Composition Traversals + +When the edge you'd traverse is a declared [composition](/ontology#composition) +relation (`partOf` / `hasPart`), `parts()` and `wholes()` are shorter than +`traverse().recursive().to(...)` and stay correct as the relation grows across +edge kinds and orientations — see [Recursive ▸ Composition Shortcuts: `parts()` +and `wholes()`](/queries/recursive#composition-shortcuts-parts-and-wholes) for +the full picture. + ## Runtime-declared kinds For kinds and edges added at runtime via [graph diff --git a/apps/docs/src/content/docs/schema-evolution.md b/apps/docs/src/content/docs/schema-evolution.md index c6f5770c3..7b0355ab2 100644 --- a/apps/docs/src/content/docs/schema-evolution.md +++ b/apps/docs/src/content/docs/schema-evolution.md @@ -30,10 +30,17 @@ These changes are backwards compatible and auto-migrate without intervention: - Adding new node types - Adding new edge types - Adding optional properties (with defaults) -- Adding ontology relations +- Adding `broader`, `narrower`, or `relatedTo` ontology relations +- Removing `disjointWith` ontology relations - Changing per-kind annotations (UI hints, audit policy, etc.) - Changing graph-scoped annotations (display metadata, capabilities, etc.) +Adding `disjointWith`, `subClassOf`, `equivalentTo`, `partOf`, or `hasPart` — +and removing `subClassOf` or `equivalentTo` — auto-migrate too, but only after a +data check. See +[Ontology tightenings are checked against your data](#ontology-tightenings-are-checked-against-your-data) +below. + ### Adding an Optional Property ```typescript @@ -156,6 +163,441 @@ before annotations are enabled. TypeGraph 0.54+ preserves unknown top-level schema fields across parse-and-recommit cycles, so later additive metadata slices follow the same rollout rule. +## Ontology tightenings are checked against your data + +Some ontology changes can invalidate rows that already exist. TypeGraph +classifies these by what they do to your data, not just to the schema +document, and runs a data check inside the schema-commit transaction before +publishing the new version. On PostgreSQL that transaction runs at +`READ COMMITTED` whatever the server's `default_transaction_isolation` is, so +the check sees every row a concurrent writer committed while the commit waited +for it. A schema write in a caller-owned `REPEATABLE READ` or `SERIALIZABLE` +transaction, which TypeGraph cannot move to `READ COMMITTED`, is refused with +`SCHEMA_WRITE_REQUIRES_READ_COMMITTED`: + +| Meta-edge / property | Added | Removed | +| --------------------------------------------- | -------------------------------------------------- | -------------------------------------------------- | +| `disjointWith` | Warning — checked against live nodes | Safe | +| `subClassOf`, `equivalentTo` | Warning — checked against live nodes | Warning — checked against live edges | +| `inverseOf`, `implies` | Breaking | Breaking | +| `partOf`, `hasPart` | Warning — checked against live parts (one whole per part, no cycles, and a required whole when `existence: "required"`) | Breaking | +| `broader`, `narrower`, `relatedTo` | Safe | Safe | +| a custom meta-edge (from the removed `metaEdge()`) | Safe | Breaking — see [the upgrade guide](#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) | +| an edge's `acyclic: true` | Warning — checked against live edges for an existing cycle | Safe | +| an edge's admitted endpoint pair | Safe | Warning — checked against live edges | + +`sameAs` and `differentFrom` no longer have a public factory to author them +with (see +[Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis) +below), but a document persisted before the removal can still name one: the +classifier treats a `sameAs` relation exactly like `equivalentTo` above, and +a `differentFrom` relation exactly like the always-safe row. + +- **Adding `disjointWith`** is checked against every live node: if two nodes + already share an id under kinds the new relation makes mutually exclusive + (directly, or via `subClassOf` propagation), the commit refuses. +- **Adding `subClassOf` or `equivalentTo`** is checked three ways: it + can propagate an existing `disjointWith` down to a kind that was not + disjoint before (same check as above); it can merge two previously + independent `kindWithSubClasses` uniqueness components — if both already + hold a live row under the same key, the commit refuses; and it can make a + kind a required-existence composition part by making it a subclass of + one — if a live row of that kind has no live whole, the commit refuses + (`compositionExistence`). +- **Removing `subClassOf` or `equivalentTo`** can shrink an edge + kind's admitted endpoint pairs. If a live edge's endpoints rely on the + subsumption the relation provided, the commit refuses. It can also move a + `kindWithSubClasses` uniqueness claim away from where existing rows hold + theirs, which is refused while the affected kind holds rows — see + [Unique constraints need an empty kind](#unique-constraints-need-an-empty-kind). +- **Removing `disjointWith`** never invalidates anything — loosening a + constraint cannot make an existing row wrong — so it stays safe and + auto-migrates unconditionally. +- **Adding or removing `inverseOf` or `implies`** changes what an + `expand: "inverse"` (the store default) or opt-in `expand: "implying"` + traversal returns for existing + edges — a read-semantics change, not a data-validity one — so it is + `breaking` and requires an explicit `migrateSchema()`, the same treatment + the Operational Identity `sameIdAcrossKinds` flip gets. +- **Adding `partOf` or `hasPart`** is checked against the live parts of the + realizing edge kind: a part already holding more than one live whole, or a + cycle in the proposed composition relation, refuses the commit (families + `composition` and `edgeAcyclicity`). A pair declared `existence: "required"` + also refuses when a live part has no live whole (`compositionExistence`); + flipping an existing pair from optional to required is checked the same way, + and required to optional is safe. Because no composition window may start + or end later than its write, the commit also refuses when a non-deleted row + of the realizing edge kind already has a `validFrom` or `validTo` later + than the commit (`compositionFutureWindow`). This includes an edge kind + re-added over rows a discarded drop left behind. End or delete those rows, + then retry. +- **Making a realizing edge's ended rows count** is checked the same way. + Redeclaring a `oneActive` realizing edge `one` refuses the commit when a + part's ended row would become a second whole beside its live row under + another realizing edge (`composition`), or would close a cycle + (`edgeAcyclicity`). Declaring `acyclic: true` on a `oneActive` realizing + edge refuses it when an ended row would close a cycle. +- **Removing `partOf` or `hasPart`** changes what `parts()` and `wholes()` + resolve to and stops parts from being deleted with their whole — a + read/write-semantics change, not a data-validity one — so it is `breaking` + and requires an explicit `migrateSchema()`, like `inverseOf` and `implies`. + A stored relation with no `via` realized none of that and is safe to + remove. +- A relation whose `from` or `to` names a kind **this same commit removes** + is itself safe with no check, and rows of the removed kind never hold the + commit back. They stay in storage until `materializeRemovals()` (or + `removeKinds(..., { eager: {} })`) reclaims them, and + `store.verifyConstraintFences()` can report their edges until then. +- **An edge kind that stops admitting an endpoint pair** between kinds that + survive the commit is checked against its live edges, whatever caused the + loss: a removed `subClassOf` or `equivalentTo`, a narrowed `from` / `to` + declaration, or a kind removed from the middle of a subclass chain. With + `Dog ⊂ Mammal ⊂ Animal` and an edge declared `to: [Animal]`, + `Store.removeKinds(["Mammal"])` refuses while a live edge still points at a + `Dog`: nothing would admit that edge afterwards. Delete those edges, or + declare `subClassOf(Dog, Animal)` first, then retry. +- **Declaring `acyclic: true`** on an edge kind that already carries live + rows is checked against the whole relation: if any live edge's `to` + endpoint already reaches its `from` endpoint, the commit refuses with the + offending edge ids in `details.violations` (family `edgeAcyclicity`). + Dropping `acyclic: true` never invalidates anything and stays safe. A + brand-new edge kind's `acyclic: true` is vacuously safe — there is no + prior data it could violate. + +A refused tightening throws `MigrationError`: + +```typescript +try { + await createStoreWithSchema(graph, backend); +} catch (error) { + if (error instanceof MigrationError && error.details.reason === "ontology-tightening-violated") { + console.log(error.details.violations); + // → the exact rows blocking the migration, in the shape + // store.verifyConstraintFences() returns + } +} +``` + +Resolve the offending rows (delete them, change their kind, or narrow the +ontology change), then retry. `store.verifyConstraintFences()` lists the same +rows on demand at any time — run it against a live store to find conflicts +before attempting a migration. + +**Residual window.** The check runs inside the commit transaction but takes no +additional lock: under the previous schema the tightening's kinds are not yet +disjoint (or their uniqueness components have not yet merged, or the edge +kind's endpoints have not yet shrunk), so there is no claim for a lock to +fence. A writer that commits under the previous schema version between the +check and the version compare-and-swap is invisible to it — the same residual +window the existing empty-kind removal fence carries. +`store.verifyConstraintFences()` remains the post-hoc detector for exactly +that window. + +## Edge cardinality tightenings are checked against your data + +Making an edge's `cardinality` or `targetCardinality` more restrictive — for +example widening `many` to `one`, or adding `targetCardinality: "one"` to an +edge that previously had none — runs the same kind of data check as an +ontology tightening, inside the same schema-commit transaction, using the +same fold every cardinality-aware layer shares +(`edgeCardinalityAxisReferences`, see +[Target cardinality](/core-concepts#target-cardinality)). Each axis this +commit newly constrains — source or target, independently — is probed +against the live population before the version is published: + +- **Source-side tightening** (`cardinality` narrowing) counts live edges per + source; a source already exceeding the new bound refuses the commit. +- **Target-side tightening** (`targetCardinality` narrowing) counts live + edges per target instead, using the same probe shape with the endpoint + swapped — a target already exceeding the new bound refuses the commit. +- An edge kind that tightens **both axes in the same commit** is checked + independently for each; either violation refuses the whole commit, and the + thrown error reports every newly-constrained axis, not just the first one + found. +- Moving an axis from `one` to `oneActive` is a **loosening**: `one` counts + every edge for its whole life and `oneActive` only edges whose `validTo` is + open, so data that satisfies `one` already satisfies `oneActive`. That move + owes no check and commits on any backend. The reverse, `oneActive` to + `one`, is a tightening (an ended edge beside an open one from the same + endpoint violates it) and is probed. +- A commit that declares a constrained `cardinality` or `targetCardinality` + on a **brand-new edge kind** owes this exact same check: a stored schema + with no entry for the kind reads as `many` on both axes, so any constrained + value the new kind declares differs from that default and is probed like + any other tightening. This is intentional — an edge kind can be re-added + after removal, with live rows already under it — but it means a purely + additive schema change (adding a kind) can still require the atomic + preflight primitive described below. + +Like an ontology tightening, this check needs the backend's atomic +preflight-commit primitive (`commitSchemaVersionWithPreflight`); a backend +that implements only `commitSchemaVersion` throws `ConfigurationError` code +`EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND` — see +[Schema-tightening and constraint-fence audit guard codes](/errors#schema-tightening-and-constraint-fence-audit-guard-codes). + +A refused tightening throws `MigrationError` with +`details.reason === "edge-cardinality-tightening-violated"`: + +```typescript +try { + await createStoreWithSchema(graph, backend); +} catch (error) { + if ( + error instanceof MigrationError && + error.details.reason === "edge-cardinality-tightening-violated" + ) { + console.log(error.details.axes); // → the newly-constrained axes + console.log(error.details.violations); // → the offending rows + } +} +``` + +Resolve the offending rows (delete the excess edges, or loosen the target +schema change), then retry. This check has the same residual window as the +ontology tightening check above: it takes no additional lock, so a writer +committing under the previous schema version between the probe and the +version compare-and-swap is invisible to it. + +## Unique constraints need an empty kind + +A unique constraint is enforced by claim rows written alongside each node +write. A schema commit does not validate existing rows against a constraint +and does not rebuild claims, so a commit that would leave a constraint +unenforced over rows that already exist is refused. Two kinds of change are +gated, on every path that publishes a version — `createStoreWithSchema()` / +`ensureSchema()`, `migrateSchema()`, `Store.evolve()`, +`Store.withEvolvedTransaction()`, `Store.removeKinds()` and +`rollbackSchema()`: + +- **Adding or reshaping a unique constraint** on a kind that holds rows. A + constraint is reshaped when one of the same name changes its `fields`, + `scope`, `collation` or `where`. No existing row has a claim for the new + shape, so pre-existing duplicates would stay and a new node duplicating an + old value would be accepted. The rows do not have to contain a duplicate + for the commit to be refused. A kind the commit adds is probed too: rows a + previous drop left in the table (`migrateSchema()` with + `discardDroppedKindRows: true`) become live again under a re-added kind of + the same name, with no claim. +- **Dropping a member from the subclass component of a + `kindWithSubClasses` constraint** a populated kind declares. Such a + constraint keeps each claim under one kind of the subclass hierarchy (the + first in code-point order when the claim was written, so the kind itself or + one that sorts before it) and reads the whole hierarchy. Merging two + hierarchies moves where new claims are written without moving the old ones, + so a kind's claims can sit under any earlier-sorting member its hierarchy + has had. Removing a `subClassOf` or `equivalentTo` relation — or removing a + kind of the hierarchy — that drops such a member leaves claims where the kind + no longer reads. Dropping only members that sort after the kind is not + gated. Adding a relation only widens what is read, so existing claims keep + fencing and the addition is checked for collisions instead (see above). + +The check is one existence probe (a count that stops at the first row) per +affected kind, inside the schema-commit transaction. It looks at every row that +is not deleted, including rows whose validity period has ended. A kind with no +rows is unaffected, and removing a unique constraint is never gated. + +A refused commit throws `MigrationError`: + +```typescript +try { + await createStoreWithSchema(graph, backend); +} catch (error) { + if ( + error instanceof MigrationError && + error.details.reason === "unique-constraint-requires-empty-kind" + ) { + console.log(error.details.constraints); + // [{ cause: "constraint-added", kind: "Person", + // constraintName: "person_email", axis: "Person" }] + } +} +``` + +Each entry names the `kind` that must be empty, the `constraintName`, the +`cause` (`"constraint-added"`, `"constraint-reshaped"` or +`"claim-axis-moved"`) and the `axis` the proposed schema writes the +constraint's claims at; a shrunken component also carries `lostKinds`, the +members of the kind's subclass component that sort before it and that it no +longer has. + +The supported paths are: + +- **Declare the constraint before the kind is populated**, along with the + subclass hierarchy a `kindWithSubClasses` constraint spans. +- **Empty the kind first.** Export its rows, delete them (for a re-added kind, + run `store.materializeRemovals()` to reclaim the rows its removal left), + commit the schema, then write them back. The writes run under the new constraint, so a + duplicate is refused with `UniquenessError` where you can resolve it. +- **Introduce a new kind.** Declare the constraint on a new kind and move + rows into it with the + [expand-contract pattern](#the-expand-contract-pattern). + +A post-migration `store.verifyConstraintFences()` is not a substitute: it +reports rows that share a claim, but a row with no claim is invisible to it +and stays unfenced. + +Like the other data checks, this one needs the backend's atomic +preflight-commit primitive (`commitSchemaVersionWithPreflight`, or +`setActiveVersionWithPreflight` for a rollback). A backend without it refuses +a gated commit — even on an empty kind, and including a new kind that declares +a unique constraint, because the probe cannot be bound to the commit it +decides — with `ConfigurationError` code +`UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND`; see +[Schema-tightening and constraint-fence audit guard codes](/errors#schema-tightening-and-constraint-fence-audit-guard-codes). + +## Structural subsumption is checked before you upgrade + +Separately from the data check above, a `subClassOf`/`equivalentTo` +hierarchy is checked for a **schema-shape** violation — the child's schema +no longer structurally extends the parent's — and this check happens before +the data check, before any commit: `getSchemaChanges(backend, graph)` throws +a `ConfigurationError` naming the child, the parent, and the offending +property path if the graph you're about to commit declares one. This runs +whenever the graph declares a `subClassOf`/`equivalentTo` relation, whatever +the diff touches: a property change on a kind already party to an existing +hierarchy can break it just as surely as a relation change can, and an +unchanged graph can carry a hierarchy an earlier release accepted — which is +exactly what running it before an upgrade is for. + +**`requiresMigration` does not surface this refusal.** By design, it +collapses any `ConfigurationError` from `getSchemaChanges` — this one +included — to `true` rather than propagating it, so it can serve as a +least-privilege routing check that never throws for a document it cannot +interpret. Call `getSchemaChanges` directly (as below) to see the refusal +and its details; `requiresMigration` only tells you a migration is needed, +never why. + +```typescript +try { + await getSchemaChanges(backend, graph); +} catch (error) { + if (error instanceof ConfigurationError) { + console.log(error.details.code); // e.g. ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE + console.log(error.details.childKind, error.details.parentKind); + } +} +``` + +No data migration is required for this class of refusal — it's a +schema-authoring fix (loosen the parent, tighten the child, or replace +`subClassOf` with `broader` if the relation was really a taxonomy). Only the +**AFTER** side of a diff is enforced this way; the BEFORE (stored) side is a +delta input the diff never writes through, so a persisted document holding +such a hierarchy can still be repaired by a fix-forward migration that +removes or corrects the offending relation. + +The stored side is excused from the `partOf`/`hasPart` declaration rules the +same way. A document stored with the earlier three-part +`partOf(part, whole)` relation (no `via`) upgrades in this order: + +1. Upgrade the library. +2. In the graph, give the relation its realizing edge — + `partOf(Part, Whole, { via: edge })` — or delete it. A graph that still + declares the three-part relation is refused at registry build. +3. Run `getSchemaChanges(backend, graph)`. It reports the stored relation + removed (`safe`: with no `via` it realized nothing) and, when you declared + a `via`, the new pair added (`warning`). +4. Open the graph with `createStoreWithSchema()` or commit it with + `migrateSchema()`. The new pair is checked against live data like any + added composition pair. + +## Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs + +Three ontology APIs were removed: the custom `metaEdge()` factory (and its +`MetaEdgeOptions`), the public `InferenceType` union and the +`transitive`/`symmetric`/`reflexive`/`inverse`/`inference` members of +`MetaEdgeProperties`, and the deprecated `sameAs`/`differentFrom` factories. +None of them ever drove runtime behavior beyond serialized introspection — +see [Type-Level Annotations](/ontology#type-level-annotations) and the +[Verified Support Matrix](/ontology#verified-support-matrix). A bare +upgrade — deploying this release against an existing store with no +accompanying ontology change — keeps loading the schema document unmodified +and reports `status: "unchanged"`; no action is required for that case +alone. **This does not mean every existing document is unaffected** — see +the `metaEdge()` bullet immediately below for the one case where opening a +store under this release stops at a breaking change until you migrate. + +**If your code calls `metaEdge()`.** Delete the declaration and copy any +relation you rely on into `annotations` on `defineGraph()`. The custom +`metaEdge()` factory is gone, so a graph that no longer declares the relation +can no longer serialize it, and the stored schema document holds its last +copy. Removing such a relation is therefore classified `breaking`, so it +never auto-migrates. The first `ensureSchema`/`createStoreWithSchema` call +after upgrading throws `MigrationError`, or returns `status: "breaking"` with +`throwOnBreaking: false`. Its migration actions include one entry naming +every persisted relation that uses a custom meta-edge, and each such change +in `diff.ontology` carries the relation in `removedCustomRelation`. +`store.evolve()`, `store.planEvolution()` and `store.removeKinds()` would +drop the relations too, so each refuses before writing with `MigrationError` +(`reason: "custom-relation-removal"`, the relations in `details.relations`), +even on a store opened despite the breaking status. Once +those relations are in `annotations`, commit the new schema once with +`migrateSchema(backend, graph, currentVersion)`. That commit drops them from +the stored document, and later opens report `status: "unchanged"`. The prior +schema-version row keeps the old document. Move any other free-form +metadata you attached (a custom `description`, or the +`transitive`/`inference`/etc. properties you set) into `annotations` too, and +update whatever application code walked `store.introspect().ontology` for +that meta-edge's relations to instead walk the annotated data — see +[Type-Level Annotations](/ontology#type-level-annotations) for a worked +example. + +**If your code calls `sameAs(A, B)`.** Replace it with `equivalentTo(A, B)` +— behaviorally identical in every release `sameAs` ever shipped in (the +registry always folded `sameAs` into the same equivalence closure as +`equivalentTo`; that fold is exactly how a persisted `sameAs` relation keeps +being interpreted below). + +**If your code calls `differentFrom(A, B)`.** Delete the call. It was +decorative — the registry never enforced it — so removing it changes +nothing your application could observe. For actual cross-kind instance +identity, enable the graph-level TypeGraph Identity Profile +(`identity: { sameIdAcrossKinds: "fold" }`) and use `store.identity`. + +**A store opened against a persisted document that still has one of +these keeps loading**, and reading `store.introspect()` on it still works: + +- A `metaEdges` catalog entry that still carries `transitive`/`symmetric`/ + `reflexive`/`inverse`/`inference` parses — those fields are simply never + read. The serializer no longer emits them, but only the next commit that + detects an actual semantic change rewrites the document — an upgrade with + no accompanying ontology change leaves the old document, extra fields and + all, in place, together with the `schema_hash` the earlier release stored + for it. Every comparison of a stored version against your graph, or + against another stored version (boot, `registerGraphTemplate`, + `instantiateGraph` onto a target an earlier release instantiated, + `refreshSchema`, evolution plans, `branch()`, merge plans and reviews), + hashes the stored document as this release serializes it — the extra + fields dropped and the precomputed closures recomputed from the stored + relations — so the old document compares equal to the unchanged graph. + `store.reconciledSchema.hash` and `store.introspect().schemaHash` report + that recomputed hash, which differs from the stored `schema_hash` column + for such a version. +- A relation naming `sameAs` keeps folding into the equivalence closure + exactly like `equivalentTo` — `registry.areEquivalent(A, B)`, + `isAssignableTo`, and every other equivalence-driven check are unaffected. +- A relation naming `differentFrom` keeps being inert, as it always was. + +**This is a one-way door for rolling deploys and rollback.** The narrowed +`SerializedMetaEdge` shape only appears once a document gets rewritten (see +above), but from that point on it cannot be read by a `@nicia-ai/typegraph` +release older than this one — the pre-change `serializedSchemaZod` required +`transitive`/`symmetric`/`reflexive`/`inference` on every `metaEdges` entry, +so an older reader's `parseSerializedSchema` throws on the new shape instead +of degrading. In a mixed-version fleet, upgrade every application instance +sharing a database to this release or later before any of them commits a +schema change, and do not roll back to an older release once one has. + +**Once your code moves a `sameAs(A, B)` to `equivalentTo(A, B)`, or deletes a +`differentFrom(A, B)`, the next commit auto-migrates.** Both are classified +by the same relation-level severity table as any other ontology change (see +[Ontology tightenings are checked against your data](#ontology-tightenings-are-checked-against-your-data) +above): migrating `sameAs` to `equivalentTo` is a relation removed +(`warning`, checked against live edges) plus a relation added (`warning`, +checked against live nodes) — never `breaking` — and dropping +`differentFrom` is a relation removed (`safe`). Neither requires an explicit +`migrateSchema()`. + ## Breaking Changes These require explicit handling: @@ -164,6 +606,9 @@ These require explicit handling: - Removing properties - Adding required properties (no default) - Renaming types or properties +- Adding or removing an `inverseOf` or `implies` ontology relation +- Removing a persisted relation that names a custom meta-edge (see + [Upgrading past the removed `sameAs`/`differentFrom`/`metaEdge()` APIs](#upgrading-past-the-removed-sameasdifferentfrommetaedge-apis)) TypeGraph will throw `MigrationError` by default. You have two options: fix the schema to be backwards compatible, or use the expand-contract pattern. @@ -499,7 +944,12 @@ console.log("Current version:", active?.version); | Add node type | Safe | Yes | | Add edge type | Safe | Yes | | Add optional property | Safe | Yes | -| Add ontology relation | Safe | Yes | +| Add `broader`/`narrower`/`relatedTo` | Safe | Yes | +| Add `disjointWith`, `subClassOf`, `equivalentTo`, `partOf`, `hasPart` | Warning (data-checked) | Yes, if the check passes | +| Remove `subClassOf`, `equivalentTo` | Warning (data-checked) | Yes, if the check passes and no populated kind's `kindWithSubClasses` component drops an earlier-sorting member | +| Remove `disjointWith` | Safe | Yes | +| Add/remove `inverseOf`, `implies` | Breaking | No | +| Remove `partOf`, `hasPart` | Breaking | No | | Change kind annotations | Safe | Yes | | Add required property | Breaking | No | | Remove property | Breaking | No | @@ -507,10 +957,13 @@ console.log("Current version:", active?.version); | Rename node/edge type | Breaking | No | | Change property type | Breaking | No | | Change onDelete behavior | Warning | Yes | -| Change unique constraints | Warning | Yes | -| Change edge cardinality | Warning | Yes | -| Change edge endpoint kinds | Warning | Yes | -| Remove allowed source-dependent endpoint pairs | Breaking | No | +| Add or reshape a unique constraint | Warning (refused while the kind holds rows, a re-added kind included) | Yes, if the kind is empty | +| Remove a unique constraint | Warning | Yes | +| Change edge cardinality (source-side, `cardinality`) | Warning (data-checked if tightened) | Yes, if the check passes | +| Change edge target cardinality (`targetCardinality`) | Warning (data-checked if tightened) | Yes, if the check passes | +| Widen edge endpoint kinds | Warning | Yes | +| Narrow edge endpoint kinds | Warning (data-checked) | Yes, if the check passes | +| Remove allowed source-dependent endpoint pairs | Breaking (data-checked by `migrateSchema()`) | No | ## Rollback @@ -527,6 +980,17 @@ await rollbackSchema(backend, "my_app", 2); This does not delete newer versions. You can migrate forward again later. +Reactivating an older version is checked like a forward commit. When the +target version declares an ontology axiom or edge cardinality the active +version does not (a `disjointWith`, `acyclic: true`, or a tighter +`cardinality`, for example), existing rows are validated under the same schema +fence as the switch, and a violation refuses the rollback with the same +`MigrationError` a forward migration would raise. A target version that +declares a unique constraint the active one lacks or shapes differently, or +whose hierarchy drops an earlier-sorting member of a `kindWithSubClasses` component, is refused while the +affected kind holds rows +([Unique constraints need an empty kind](#unique-constraints-need-an-empty-kind)). + ## Migration Hooks Use `onBeforeMigrate` and `onAfterMigrate` for observability — logging, diff --git a/apps/docs/src/content/docs/schema-management.md b/apps/docs/src/content/docs/schema-management.md index de55107e1..638bbf34d 100644 --- a/apps/docs/src/content/docs/schema-management.md +++ b/apps/docs/src/content/docs/schema-management.md @@ -263,7 +263,25 @@ These changes are backwards compatible and can be auto-migrated: - Adding new node types - Adding new edge types - Adding optional properties with defaults -- Adding new ontology relations +- Adding `broader`, `narrower`, or `relatedTo` ontology relations +- Removing `disjointWith` ontology relations + +Adding `disjointWith`, `subClassOf`, `equivalentTo`, `partOf`, or `hasPart` — +and removing `subClassOf` or `equivalentTo` — also auto-migrate, but +only after a data check inside the commit transaction. A `partOf` or `hasPart` +addition is checked against the live parts of its realizing edge kind (one whole +per part, no cycles, and a required whole when `existence: "required"`), and a +tightened `cardinality` or `targetCardinality` is checked against live edges. +Both refuse the commit when the data does not satisfy them. See +[Ontology tightenings are checked against your data](/schema-evolution#ontology-tightenings-are-checked-against-your-data) +and +[Edge cardinality tightenings are checked against your data](/schema-evolution#edge-cardinality-tightenings-are-checked-against-your-data) +for what each one checks and how a refusal is reported. + +Enabling `identity` on an existing graph does not backfill identity +transitions for events that happened before enablement — replay begins at +the coordinate identity was turned on, not at the graph's own origin. See +[Replay and identity history](/identity/#replay-and-identity-history). ### Breaking Changes (Require Manual Action) @@ -273,8 +291,9 @@ These changes require manual migration: - Renaming node or edge types - Changing property types - Removing properties -- Changing cardinality constraints to be more restrictive - Removing allowed endpoint pairs from a source-dependent edge +- Adding or removing an `inverseOf` or `implies` ontology relation +- Removing a `partOf` or `hasPart` ontology relation ### Endpoint Pair Changes diff --git a/apps/docs/src/content/docs/schemas-stores.md b/apps/docs/src/content/docs/schemas-stores.md index bb84b0a83..785b2ee58 100644 --- a/apps/docs/src/content/docs/schemas-stores.md +++ b/apps/docs/src/content/docs/schemas-stores.md @@ -545,6 +545,7 @@ function createStore( | `recordedRead` | `ExternalRecordedReadSource` | Bind an already-populated recorded relation for `store.asOfRecorded(T)` reads without enabling TypeGraph-managed capture. Must be created with `recordedRelation({ schema })` using a `createSqlSchema(...)` schema; the store validates those factory descriptors at runtime. Use `history: true` when TypeGraph should capture writes and advance `store.recordedNow()`. | | `schema` | `SqlSchema` | Custom table name configuration created with `createSqlSchema(...)` | | `queryDefaults.traversalExpansion` | `TraversalExpansion` | Default ontology expansion mode for traversals (default: `"inverse"`) | +| `queryDefaults.expansion` | `"exact" \| "subclasses"` | Default expansion axis for `from`/`to`/`fromDynamic`/`toDynamic` when an alias states no `expansion` (default: `"subclasses"` — a supertype query is polymorphic by default; see [Ontology](/ontology#subsumption-type-inheritance) and [Scope at a glance](/queries/source#scope-at-a-glance)). It applies to list sources, `from([...])`, too. `"narrower"` is not a store-wide default. `search()` takes its own `expansion` option (default `"exact"`) and the collection APIs stay exact-kind; neither follows this default. Any other value, `"narrower"` included, is refused at store construction (`QUERY_ALIAS_EXPANSION_INVALID`) | | `autoRefreshStatistics` | `false \| number` | Row threshold at which a single autocommit `bulkCreate`/`bulkInsert` triggers an automatic planner-statistics refresh (default: `1000`); `false` disables. See [Refreshing planner statistics](/backend-setup#refreshing-planner-statistics-after-bulk-loads). | | `coalesceUnchangedUpserts` | `boolean` | Skip the write for an `upsertById` or endpoint get-or-create update whose validated props and requested window already equal the existing live row; bulk forms behave identically (default: `false`). Node `getOrCreateByConstraint` updates are not coalesced; use `upsertById` for replay projectors that must avoid unchanged node history churn. For at-least-once / replay materializers: a byte-identical re-delivery performs no write, no history row, and no revision advance. See [`upsertById`](#upsertbyidid-props-options), [`getOrCreateByEndpoints`](#getorcreatebyendpointsfrom-to-props-options), and [Materializing external event logs](/materializing-event-logs). | @@ -572,6 +573,16 @@ const store = createStore(graph, backend, { }); ``` +Restore the earlier exact-kind query rows (no subclass expansion) everywhere. This +changes which rows an alias returns, not its type: aliases over a kind the ontology +can affect stay widened, and `{ expansion: "exact" }` on the call keeps the literal type: + +```typescript +const store = createStore(graph, backend, { + queryDefaults: { expansion: "exact" }, +}); +``` + ### `createStoreWithSchema(graph, backend, options?)` Creates a store and ensures the database schema is initialized or migrated. @@ -718,9 +729,14 @@ writes still preserve endpoint and claim ordering. `getOrCreateByEndpoints` uses an outside read only for its no-write found fast path; every create, resurrection, or update makes its decision on the fenced transaction target. An adopted PostgreSQL transaction can therefore return an existing match at -any isolation level. If the operation reaches the create leg, TypeGraph checks -the effective isolation captured by the graph-lock statement and refuses -repeatable read before issuing the convergent write. +any isolation level. A convergence on a match key no database key backs +advances the graph's fence row behind the graph lock, so a repeatable-read or +serializable transaction whose snapshot predates another converging writer's +commit fails there with +`TransactionConflictError` (SQLSTATE `40001`) rather than converging on a +relation it can no longer see; retry the whole transaction. A custom backend +whose fence advances no fence row refuses the create leg at those levels with +`MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT`. Dynamic `matchOn` remains transaction-scoped because a call-level field list has no database uniqueness object. For latency-sensitive paths, declare a @@ -1051,8 +1067,14 @@ Updates a set of current, live nodes in one transactional operation and returns the number of rows changed. A selector is mandatory: provide `candidates`, `where`, one or more independent `exists` relationship predicates, or the explicit `all: true` acknowledgement. `candidates` accepts a query created by -the same Store (or transaction) that selects one concrete node kind; its root -node ids are intersected with any other selectors in the same atomic write. +the same Store (or transaction) whose source resolves to exactly one concrete +node kind; its root node ids are intersected with any other selectors in the +same atomic write. `from(kind)` is polymorphic by default, so a candidate query +over a kind that has subclasses or `equivalentTo` kinds resolves to several +kinds and is refused with `ConfigurationError` (`details.code` +`SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED`); pass +`{ expansion: "exact" }` to `from()` to select only the named kind. A kind with +no subclasses or equivalents resolves to itself either way. Candidate queries must contain concrete predicate values and select rows directly. TypeGraph refuses `param()` references because `updateWhere()` has no binding argument, and refuses `groupBy()` / `having()` because replacing an @@ -1106,14 +1128,24 @@ await store.nodes.Person.updateWhere({ }); ``` -#### `delete(id)` +#### `delete(id, options?)` -Soft-deletes a node. +Soft-deletes a node. A no-op for a node that is absent or already deleted. ```typescript -store.nodes.Person.delete(id: NodeId): Promise; +store.nodes.Person.delete( + id: NodeId, + options?: { expect: NodeDeletePlan }, +): Promise; ``` +`expect` takes a plan from `planDelete(id)` and refuses the delete with +`NodeDeletePlanMismatchError`, before any write, when the plan the delete +computes for itself no longer matches it or the node is no longer live. Only a +composition whole's delete plans under a per-graph lock; see +[Previewing a delete](/ontology#previewing-a-delete) for what each isolation +level does with a write that commits concurrently. + #### `hardDelete(id)` Permanently deletes a node. This is irreversible and should be used carefully. @@ -1395,6 +1427,17 @@ store.nodes.Person.bulkGetOrCreateByConstraint( >; ``` +Items whose constraint matches an existing node are decided together before +the batch writes anything: every attachment verdict a stated `partOf` can +reach (counting the attachments of the rows the batch will create), and — +with `ifExists: "update"` or on a resurrection — the property update's +validation and unique-key probes, a restored row's disjointness and identity +checks. A refusal from a matched item therefore leaves nothing written, +including when you catch it on `tx` inside `store.transaction(...)`. Unmatched +items are created afterwards; see +[`partOf` on get-or-create is a postcondition](/ontology#partof-on-get-or-create-is-a-postcondition) +for what a new row can still refuse after the matched items wrote. + #### `findByConstraint(constraintName, props)` Looks up a node by a named uniqueness constraint without creating. @@ -2118,6 +2161,24 @@ affected: - Rows-affected fidelity is intentionally out of scope for this first version; a future extension could ask backends to return row counts. +On an identity-enabled graph, `writes.identity` counts `tx.identity`'s writers +the same way: `sameAssertions`, `differentAssertions`, and `retractions`, plus +`total` (their sum — the count of ledger truth rows the transaction produced). +`writes.identity.transitions` sits beside `total`, not inside it: it is the +number of identity transition-log notes the transaction's flush wrote, an +annotation of the writes above rather than a fourth kind of write. It is +always `0` when identity is disabled, when the store was opened without +`history: true`, and on a `tx.measure()` scoped receipt — transitions are +counted at commit-flush time, once per transaction, never per scope. + +```typescript +const outcome = await store.transactionWithReceipt(async (tx) => { + await tx.identity.assertSame(alice, bob); +}); +outcome.receipt.writes.identity; +// { sameAssertions: 1, differentAssertions: 0, retractions: 0, transitions: 1, total: 1 } +``` + When the store was created with `{ history: true }` and the transaction flushed captured writes or an explicit revision request, `receipt.recorded` is the recorded commit instant allocated for this store's graph by this transaction. It @@ -2188,6 +2249,128 @@ a per-transaction flush concern, unknowable mid-transaction. Plain The scoped history context still exposes `requestRecordedRevision()`; its request belongs to the outer transaction, so only the outer receipt contains the instant. +##### Cascaded parts in receipts and hooks + +Deleting a [composition](/ontology#composition) whole cascades through its +live parts — see [Composition Cascade](/limitations#composition-cascade) for +what the cascade itself does and does not do. Both the delete's +`onOperationEnd` hook context (see [Observability Hooks](#observability-hooks)) +and every transaction receipt that covers the delete carry the parts it +removed, as `cascadedParts`: one `{ kind, id, whole, viaEdgeKind }` entry per +part, naming the whole it was directly a part of and the composition edge kind +that bound it, taken from the same plan the cascade executed, in the order the +cascade deleted them — **leaf-first, then +deterministically by kind, then id**. Leaf-first is the part of that order with +meaning: a part is always named before the whole it belongs to. Two sibling +parts of one whole have no order between them to respect, so they are sorted +rather than left in the order the cascade's reads returned them — which is what +lets you compare `cascadedParts` for equality across runs and backends. + +```typescript +import { + createStore, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { z } from "zod"; + +const Album = defineNode("Album", { schema: z.object({ title: z.string() }) }); +const Track = defineNode("Track", { schema: z.object({ title: z.string() }) }); +const trackOf = defineEdge("trackOf"); + +const graph = defineGraph({ + id: "music", + nodes: { Album: { type: Album }, Track: { type: Track } }, + edges: { + trackOf: { type: trackOf, from: [Track], to: [Album], cardinality: "one" }, + }, + ontology: [partOf(Track, Album, { via: trackOf })], +}); + +const store = createStore(graph, backend, { + hooks: { + onOperationStart: (ctx) => { + // cascadedParts is never present here — the cascade has not been + // planned when the operation begins. + console.log("start", ctx.kind, ctx.cascadedParts); + }, + onOperationEnd: (ctx) => { + console.log("end", ctx.kind, ctx.cascadedParts); + }, + }, +}); + +const album = await store.nodes.Album.create({ title: "Origins" }); +const trackOne = await store.nodes.Track.create( + { title: "Intro" }, + { partOf: { whole: album } }, +); +const trackTwo = await store.nodes.Track.create( + { title: "Outro" }, + { partOf: { whole: album } }, +); + +const albumTwo = await store.nodes.Album.create({ title: "Reissue" }); +const trackThree = await store.nodes.Track.create( + { title: "Bonus" }, + { partOf: { whole: albumTwo } }, +); + +const { receipt, result } = await store.transactionWithReceipt(async (tx) => { + const scope = await tx.measure(async (scoped) => { + // "end" fires exactly once, for the Album delete — the two cascaded + // Track deletes are not separate operations. + await scoped.nodes.Album.delete(album.id); + }); + // Issued through the outer `tx`, outside the measured scope: this + // cascade belongs only to the transaction's own receipt, not to `scope`. + await tx.nodes.Album.delete(albumTwo.id); + return scope; +}); + +result.receipt.cascadedParts; +// [ +// { kind: "Track", id: trackOne.id, whole: { kind: "Album", id: album.id }, viaEdgeKind: "trackOf" }, +// { kind: "Track", id: trackTwo.id, whole: { kind: "Album", id: album.id }, viaEdgeKind: "trackOf" }, +// ] +result.receipt.writes.nodes; +// { Album: 1 } — the scope's own delete call. The two cascaded Track +// deletes never went through `scoped.nodes.Track.*`, so they are not write +// intents and are not folded into this count. + +receipt.cascadedParts; +// every cascade the transaction ran, scoped and outer alike: each delete's own +// closure (leaf-first, then by kind and id), concatenated in the order the +// deletes ran: +// [ +// { kind: "Track", id: trackOne.id, whole: { kind: "Album", id: album.id }, viaEdgeKind: "trackOf" }, +// { kind: "Track", id: trackTwo.id, whole: { kind: "Album", id: album.id }, viaEdgeKind: "trackOf" }, +// { kind: "Track", id: trackThree.id, whole: { kind: "Album", id: albumTwo.id }, viaEdgeKind: "trackOf" }, +// ] +receipt.writes.nodes; // { Album: 2 } +``` + +`onOperationStart` never carries `cascadedParts` — the cascade has not run +yet. `onOperationEnd` carries it on every node delete, present but empty when +the whole has no live parts. A `tx.measure()` scope's receipt sees a cascade +only for a delete issued **through that scoped context**; the same delete +issued through the outer `tx` while the scope is open counts in the +transaction's own receipt instead, following the same attribution rule as +`scope.receipt.writes` (see +[Scoped receipts: `tx.measure()`](#scoped-receipts-txmeasure)). + +A cascaded part is never a write intent, in either place it is reported. +`receipt.writes.nodes` counts only the whole's own `delete` call, and the +hook context's `entity` / `kind` / `id` fields describe only that same call. +Cascaded parts are reported as refs beside those counters, never folded into +them. + +To see what a delete will cascade through before running it, call +`nodes..planDelete(id)`; see +[Previewing a delete](/ontology#previewing-a-delete). + #### Rollback and error propagation If the callback throws, the transaction is rolled back and the error re-throws to the @@ -2322,6 +2505,18 @@ store.clear(options?: { preserveContributionMaterializations?: boolean }): Promi ``` Wrapped in a transaction when the backend supports it. Does not affect other graphs sharing the same backend. + +Every bundled backend can preserve the markers. A custom backend does so through its optional +`clearGraphPreservingContributionMaterializations` member. When that member is missing: + +- `store.clear()` with the option omitted clears through the backend's `clearGraph`, so whether the + markers survive is that backend's own `clearGraph` behavior. +- `store.clear({ preserveContributionMaterializations: true })` is refused with + `UnsupportedBackendCapabilityError` before anything is deleted. Stating `true` asks for a + guarantee, so call it this way when your code depends on the markers surviving. +- `store.clear({ preserveContributionMaterializations: false })` clears through `clearGraph` as on + any backend. + To verify what remains afterward without reading TypeGraph's physical tables, use [`inspectGraphStorage(store)`](/multiple-graphs#inspectgraphstoragestore). @@ -2696,10 +2891,13 @@ Each window accepts `direction: "out" | "in" | "both"`; when omitted it inherits global direction. Ranking is partitioned by the oriented source endpoint, so bidirectional windows have an unambiguous top N for each endpoint. -Inside `batchOnce()`, the callback's batch-scoped `read.subgraph()` method accepts the same options and produces -the same result as `store.subgraph()`, but compiles hydration and traversal into one embeddable -statement. Both forms share the same validation, traversal, projection plan, and result assembly; -only their physical execution strategy differs. +Inside `batchOnce()`, the callback's batch-scoped `read.subgraph()` method accepts the same options, +except `composition`, and produces the same result as `store.subgraph()`, but compiles hydration and +traversal into one embeddable statement. A composition closure is a second walk read in its own +statement, so `read.subgraph()` refuses `composition` with `ConfigurationError` +(`SUBGRAPH_COMPOSITION_ONE_STATEMENT_UNSUPPORTED`) before the batch runs any statement. Both forms +share the same validation, traversal, projection plan, and result assembly; only their physical +execution strategy differs. Under the hood the traversal is a `WITH RECURSIVE` CTE and all filtering and hydration happen in the database. Direct `subgraph()` calls use a backend-tuned fixed cost: 2 statements on SQLite @@ -2721,14 +2919,15 @@ store.subgraph( | Option | Type | Default | Description | |--------|------|---------|-------------| | `edges` | `readonly EK[]` | *(required)* | Edge kinds to follow during traversal | -| `maxDepth` | `number` | `10` | Integer traversal depth from root, from 0 through `MAX_EXPLICIT_RECURSIVE_DEPTH` (1000); larger values are rejected | -| `includeKinds` | `readonly NK[]` | all kinds | Node kinds to include in the result. Other kinds are traversed through but omitted from output | +| `maxDepth` | `number` | `10` | Integer traversal depth from root for the `edges` walk, from 0 through `MAX_EXPLICIT_RECURSIVE_DEPTH` (1000); larger values are rejected. It does not bound the `composition` closure | +| `includeKinds` | `readonly NK[]` | all kinds | Node kinds to include in the result. Other kinds are traversed through but omitted from output. Kinds are exact: a subclass or `equivalentTo` kind is included only when listed | | `excludeRoot` | `boolean` | `false` | Exclude the root node from the result | | `direction` | `"out" \| "both"` | `"out"` | `"out"` follows edges in their defined direction; `"both"` treats edges as undirected | -| `cyclePolicy` | `"prevent" \| "allow"` | `"prevent"` | Whether to detect and skip cycles during traversal | -| `temporalMode` | `TemporalMode` | `graph.defaults.temporalMode` | Filter applied to both nodes and edges along the traversal — same semantics as `store.query()` and collection reads | +| `cyclePolicy` | `"prevent" \| "allow"` | `"prevent"` | Whether to detect and skip cycles during the `edges` walk. The `composition` closure needs no policy: it stops at the nodes it has already visited | +| `temporalMode` | `TemporalMode` | `graph.defaults.temporalMode` | Filter applied to nodes, and to the edges of the `edges` walk — same semantics as `store.query()` and collection reads. Composition edges follow ownership at the read's instant instead — see [Composition export](#composition-export) | | `asOf` | `string` (ISO-8601) | *(none)* | Snapshot timestamp, required when `temporalMode: "asOf"` | | `project` | `{ nodes?, edges? }` | *(none)* | Per-kind field projection — see [Projection](#subgraph-projection) below | +| `composition` | `boolean \| { via? }` | `false` | Close the root over its declared composition parts (`true`), or over one realizing edge (`{ via }`) — see [Composition export](#composition-export) below | **Result:** @@ -2832,6 +3031,90 @@ const neighborhood = await store.subgraph(skill, { }); ``` +#### Composition export + +`composition: true` closes the root over its declared `partOf`/`hasPart` +parts — the whole-plus-parts export unit — in addition to whatever `edges` +already lists. It walks every composition edge kind transitively under the +root's kind (see [Composition](/ontology#composition) in the ontology +guide), regardless of orientation, so a mix of `part -> whole` and `whole -> +part` (`has_*`) edges in the same chain is exported in one call: + +```typescript +const sg = await store.subgraph(episode, { + edges: [], + composition: true, +}); + +// sg.nodes includes the Episode and every Segment transitively part of it, +// reached through whichever composition edge kind realizes each level — +// no need to name segmentOf/episodeOf/... by hand. +``` + +The closure follows what the root **owns**, not what its edges' validity +windows show: the composition rows that bind a part to its whole — the same +membership the delete cascade counts. An ended +`population: "one"` edge still binds its part, because a `one` binding holds +for the row's whole life, and an ended `population: "oneActive"` edge, the +history a `reparent` leaves behind, binds nothing. Such an ended `"one"` edge +is returned in `edges` with its `validTo`. The read's temporal mode sets the +instant the closure is evaluated at, and still decides which nodes are +visible: + +- A current read, and an `includeEnded` or `includeTombstones` read, closes + over what the root owns now. Those two modes widen what the `edges` walk + and node visibility admit, never what the root owns, so they do not + re-attach a part through an ended `"oneActive"` edge or a deleted one. +- `store.asOf(t)` closes over what the root owned at `t`: an edge that + existed at `t` and, for `"oneActive"`, had not yet ended. A `"one"` edge + that ended before `t` still binds at `t`. +- A recorded read (`store.asOfRecorded(...)`) closes over the ownership its + pinned revision recorded. + +The membership rule decides which **edges** the closure follows; **nodes** +still follow the read's temporal filter at every hop. A part node the read +cannot see — its own validity window ended, or not yet started — is not +returned, and neither is anything reachable only through it, even though the +whole still owns them and a delete would remove them. The export is the +owned unit as the read sees it, not a delete preview: use +[`planDelete`](/ontology#previewing-a-delete) to see what a delete removes. + +The `edges` list you name keeps the read's validity window. When a kind is in +both lists, an edge is returned if either admits it. + +`composition: { via }` closes over one realizing edge instead of all of them: +pass the edge's type or its kind string. `via` follows the same rule as +[`parts({ via })`](/queries/recursive#composition-shortcuts-parts-and-wholes): +it must realize a pair at the root's kind itself. An edge that realizes a pair +only further down the tree is refused with `ConfigurationError` +(`COMPOSITION_VIA_NOT_DECLARED`), and `details.declaredVia` lists the edges +that would work. + +This is set-level, not per-root-kind-required: a root whose kind declares +no composition parts contributes nothing extra and the read still runs +normally. A graph that declares no composition relation at all cannot +honor the option meaningfully and throws `ConfigurationError` +(`COMPOSITION_NO_PARTS_DECLARED`) rather than silently running as if +`composition` were absent. + +`tx.subgraph()` honors `composition` the same way inside a transaction, +reading through the transaction's session. The batch-scoped +`read.subgraph()` inside `batchOnce()` refuses it (see +[Subgraph Extraction](#subgraph-extraction)). + +The composition edge kinds a read adds are not part of the `edges` list the +call names, so `composition: true` widens the result's edge-kind type to the +graph's whole edge-kind union: every edge kind the read can return is +reachable through `edges` and the `indexSubgraph()` lookups, and no kind +outside the graph's own edges ever appears. A `{ via }` selection adds exactly +its one realizing edge kind. With `composition` absent or `false`, the kind +type is the `edges` list you named, exactly as before. (A `composition` whose value is +only known to be a `boolean` widens too — the conservative reading.) The +matching `project.edges` keys widen with it, so a `composition: true` call can +shrink the payload of the composition edges it receives; without +`composition: true`, projecting an edge kind outside `edges` stays a +compile-time error. + #### Subgraph Projection By default, `subgraph()` returns fully hydrated nodes and edges. The `project` option lets you @@ -3041,6 +3324,39 @@ const collection = store.getNodeCollection(edge.fromKind); const node = await collection?.getById(edge.fromId); ``` +#### `store.getNodeByRef(reference)` + +Reads the node a `{ kind, id }` reference names through the same node collection +`getNodeCollectionOrThrow(kind)` returns. It returns `undefined` when no node with that +id exists under that kind, and rejects with `KindNotFoundError` when the kind is not +registered. The kind is matched exactly: a reference naming a supertype with a subclass +node's id resolves to `undefined`. + +The result is typed by the reference. A reference whose `kind` is one of the graph's +node kinds resolves to that kind's node. A reference whose `kind` is `string`, such as +the endpoint of an edge whose declared kind has subclasses, resolves to the union of the +graph's node types plus the base `Node` of a kind added at runtime. + +```typescript +const [employment] = await store.edges.employs.findFrom(acme); +// employment.toKind is string: the declared Person endpoint admits Employee rows. +if (employment.toKind === "Employee") { + // Narrowing the reference first resolves straight to Node. + const employee = await store.getNodeByRef({ + kind: employment.toKind, + id: employment.toId, + }); +} +// Without narrowing: Node | Node | ... | Node +const target = await store.getNodeByRef({ + kind: employment.toKind, + id: employment.toId, +}); +``` + +Transactions (`tx.getNodeByRef`) resolve through the transaction's own collections, and +views (`view.getNodeByRef`, including recorded-time views) read at their pinned coordinate. + #### `store.getEdgeCollection(kind)` Returns the [`DynamicEdgeCollection`](/types#dynamicedgecollection) for the given kind, or @@ -3141,7 +3457,11 @@ store.search.rebuildFulltext(nodeKind?, options?): Promise…` snippet per hit. | +| `expansion` | `"exact" \| "subclasses"` | `"exact"` | `"subclasses"` also searches `subClassOf` descendant and `equivalentTo` kinds and widens the hit's `kind` and `NodeId` brand. | #### `store.search.hybrid(nodeKind, options)` @@ -3174,6 +3495,7 @@ with Reciprocal Rank Fusion. Requires both `vectorSearch` and | `fulltext.includeSnippets` | `boolean` | `false` | Return snippets per fulltext sub-hit. | | `fusion.method` | `"rrf"` | `"rrf"` | Fusion method. | | `fusion.k` | `number` | `60` | RRF constant. | +| `expansion` | `"exact" \| "subclasses"` | `"exact"` | Applies to both legs; `"subclasses"` widens the hit's `kind` and `NodeId` brand as for `fulltext`. | | `fusion.weights.vector` | `number` | `1` | Bias toward the vector retriever. | | `fusion.weights.fulltext` | `number` | `1` | Bias toward the fulltext retriever. | @@ -3259,13 +3581,18 @@ coordinate: | `view.query()` | a pinned query builder with a **sealed** temporal axis — `.temporal(...)` throws | | `view.subgraph(root, options)` | pinned | | `view.reachable` / `canReach` / `shortestPath` / `neighbors` / `degree` | pinned | -| `view.nodes`: `findByConstraint` / `bulkFindByConstraint` / `bulkFindByIndex` | current-only reads: delegate on a `"current"` view; reject on any temporal pin | +| `view.nodes`: `findByConstraint` / `bulkFindByConstraint` / `bulkFindByIndex` / `planDelete` | current-only reads: delegate on a `"current"` view; reject on any temporal pin | | `view.search` reads (`fulltext` / `vector` / `hybrid`) | delegate to the live search on a `"current"` view; reject on any other pin | | `view.search.rebuildFulltext()` | rejected on every view (maintenance write) | | `view.mode` / `view.asOf` | the pinned coordinate | The algorithm and `subgraph` option objects are the same as on the live `Store` -**minus** `temporalMode` / `asOf`, which the pin supplies. +**minus** `temporalMode` / `asOf`, which the pin supplies. Stating one anyway (possible from untyped code) is refused with +`ConfigurationError` `STORE_VIEW_SEALED_COORDINATE` on every view, `"current"` included; the +heterogeneous `bulkFindEdgesFrom` / `bulkFindEdgesTo` reads, the edge collections' +`bulkFindFrom` / `bulkFindTo`, and the collections' point reads (`getById`, `getByIds`, +`find`, `count`, `findFrom`, `findTo`, `findByEndpoints`) refuse the same way when a +coordinate is stated in the position the live collection takes its temporal options. `view.query()` is a **capability-safe** pinned read context: the returned query builder seeds the view's coordinate and seals the temporal axis, so calling @@ -3360,9 +3687,11 @@ Query hooks describe SQL statements submitted by Store read APIs, not logical AP backend-internal setup statements. Fluent queries, `batchOnce()`, `neighbors()`, `countNeighbors()`, and `subgraph()` all use this observed execution path. A logical read that submits more than one statement fires one start/end pair per statement: direct `subgraph()` emits -two pairs on SQLite and three on PostgreSQL, while `tx.subgraph()` and the same subgraph embedded in -`batchOnce()` emit one. A fluent query that retries with a different projection likewise fires a -pair for each statement it submits. +two pairs on SQLite and three on PostgreSQL, including at a recorded coordinate +(`store.asOfRecorded(...).subgraph()`), while `tx.subgraph()` and the same subgraph embedded in +`batchOnce()` emit one. A `subgraph()` with `composition` emits four on both backends (the `edges` +closure, the composition closure, then nodes and edges), inside a transaction too. A fluent +query that retries with a different projection likewise fires a pair for each statement it submits. ### `StoreHooks` @@ -3370,6 +3699,7 @@ Configuration for observability callbacks: ```typescript import type { + CompositionNodeRef, HookContext, QueryHookContext, OperationHookContext, @@ -3409,10 +3739,19 @@ type OperationHookContext = HookContext & entity: "node" | "edge"; kind: string; id: string; + /** + * The composition parts a whole's delete cascaded through, leaf-first, each + * with the whole it was directly a part of and the edge kind that bound + * it. Present (and possibly empty) on `onOperationEnd` + * for a node delete; absent on `onOperationStart`, which fires before the + * cascade is planned, and on every non-delete operation. See + * [Cascaded parts in receipts and hooks](#cascaded-parts-in-receipts-and-hooks). + */ + cascadedParts?: readonly CompositionCascadedPart[]; }>; ``` -> **Note:** Batch operations (`bulkCreate`, `bulkInsert`, `bulkUpsertById`, `bulkDelete`) skip per-item +> **Note:** Batch operations (`bulkCreate`, `bulkInsert`, `bulkUpsertById`, `bulkDelete`, `bulkReparent`) skip per-item operation hooks for throughput, and the set-based bulk hooks (`onBulkOperationStart` / `onBulkOperationEnd`) do not stand in for them — those fire only for node `updateWhere`, so a batch method emits no hook events at all, neither per-item nor bulk. Call the single-item method to observe each write. Query hooks still fire normally. diff --git a/apps/docs/src/content/docs/semantic-search.md b/apps/docs/src/content/docs/semantic-search.md index 143ae6872..4f24c7b6b 100644 --- a/apps/docs/src/content/docs/semantic-search.md +++ b/apps/docs/src/content/docs/semantic-search.md @@ -551,11 +551,19 @@ the silent half — the combination that cannot be served — is closed here. ### Scoped facade search: filters, pagination, subclasses `store.search.vector` (and `fulltext` / `hybrid`) accept a `where` -predicate, an `offset`, and `includeSubClasses` — all compiled into the +predicate, an `offset`, and `expansion` — all compiled into the search statement itself, so the engine ranks only eligible rows. A filter never costs you results: you get `limit` hits whenever `limit` matching nodes exist (on libSQL DiskANN, subject to the over-fetch bound above). +`expansion` on the facade stays **opt-in** and defaults to +`"exact"` — a deliberate asymmetry with the query builder's `from()`/`to()`, +which default to `expansion: "subclasses"`. Searching across kinds also +requires every expanded kind to share one declared metric (see below), so +flipping the facade's default would need its own migration knob; see +[Fulltext Search ▸ Options reference](/fulltext-search#options-reference) +for the full rationale, which applies identically here. + ```typescript // Top 10 most similar *published* documents, second page. const hits = await store.search.vector("Document", { @@ -567,21 +575,30 @@ const hits = await store.search.vector("Document", { }); // Search a kind and all of its subClassOf descendants; per-kind results -// merge into one globally ordered ranking. Kinds that don't declare the -// embedding field are skipped. +// merge into one globally ordered ranking. Kinds that omit the embedding +// field hold no vector there and are skipped; a kind that declares it with +// an embedding() tag that differs from its parent's is refused +// (SEARCH_EXPANSION_KIND_UNINDEXED), because its vectors are not indexed +// with the rest and the search would silently miss them. const acrossKinds = await store.search.vector("Content", { fieldPath: "embedding", queryEmbedding, limit: 10, - includeSubClasses: true, + expansion: "subclasses", }); ``` +An expanded search types its hits like the `from()` alias it expands to: +`acrossKinds[0].node` keeps `Content`'s properties, while its `kind` is +`string` and its `NodeId` brand widens whenever the ontology can return +another kind's row. See +[Fulltext Search ▸ Options reference](/fulltext-search#options-reference). + The `where` predicate is compiled by the same query compiler as `store.query()` — property predicates behave identically, use the same declared indexes, and apply the same current-read semantics (tombstoned nodes and nodes outside their validity window never rank). Kinds expanded -via `includeSubClasses` must share one declared metric: scores from +via `expansion: "subclasses"` must share one declared metric: scores from different metrics cannot merge into one ranking (and a per-call `metric` cannot bridge the gap — each kind's storage is validated against its declared metric), so mixed-metric expansions throw; search those kinds diff --git a/apps/docs/src/content/docs/troubleshooting.md b/apps/docs/src/content/docs/troubleshooting.md index 14508f9e2..641260eea 100644 --- a/apps/docs/src/content/docs/troubleshooting.md +++ b/apps/docs/src/content/docs/troubleshooting.md @@ -244,10 +244,16 @@ store.query().from("Person", "p1").traverse("knows", "e").to("Person", "p2"); .from("Person", "p") // Must match defineNode("Person", ...) ``` -2. **Missing includeSubClasses:** When querying a superclass +2. **`expansion: "exact"` narrowed the query:** Querying a superclass + returns its subtype rows by default. If you (or + `queryDefaults.expansion` on the store) explicitly narrowed with + `expansion: "exact"`, only exact-kind rows come back: ```typescript - .from("Content", "c", { includeSubClasses: true }) + // Returns Content rows AND every subClassOf(*, Content) row by default + .from("Content", "c") + // Narrows back to exact-kind Content rows only + .from("Content", "c", { expansion: "exact" }) ``` 3. **Strict predicate:** Check your filters aren't too restrictive diff --git a/apps/docs/src/content/docs/types.md b/apps/docs/src/content/docs/types.md index 4c3a1a476..ec1fe2d14 100644 --- a/apps/docs/src/content/docs/types.md +++ b/apps/docs/src/content/docs/types.md @@ -261,10 +261,16 @@ const company = await store.nodes.Company.getById(asNodeId(rows[ })) ``` -### `TypedEdgeCollection` +### `TypedEdgeCollection` A type-safe edge collection derived from the edge registration. This is what -`store.edges.*` returns. With array-valued `to`, each source type can connect to +`store.edges.*` returns, with `G` the store's graph. In `G`, each declared +endpoint reads back as `SubsumedNodeType` (its `kind` and `NodeId` widen +when `G`'s ontology gives the kind subclasses or equivalent kinds) and endpoint +arguments take an `EdgeEndpointRef`, which also admits the kinds in +`AssignableNodeKinds` and polymorphic query rows; see +[typed edge collections under subsumption](/ontology#subsumption-type-inheritance). +Named without `G`, the declared kinds are taken exactly. With array-valued `to`, each source type can connect to each target type. With a [source-dependent target map](/core-concepts#source-dependent-targets), typed writes preserve the allowed pairs instead of accepting independent endpoint unions. @@ -523,21 +529,25 @@ type Cardinality = "many" | "one" | "unique" | "oneActive"; | `"unique"` | At most one edge per source-target pair | | `"oneActive"` | At most one active edge (`validTo` is `undefined`) per source node | -### `InferenceType` +### `TargetCardinality` -Controls how ontology relationships affect queries. +Controls how many edges of a type can point AT one target node, independent +of `Cardinality`, which bounds the edges leaving one source. ```typescript -type InferenceType = - | "subsumption" // Query for X includes subclass instances - | "hierarchy" // Enables broader/narrower traversal - | "substitution" // Can substitute equivalent types - | "constraint" // Validation rules - | "composition" // Part-whole navigation - | "association" // Discovery/recommendation - | "none"; // No automatic inference +type TargetCardinality = "many" | "one" | "oneActive"; ``` +| Value | Description | +|-------|-------------| +| `"many"` | No limit on edges (default) | +| `"one"` | At most one edge per target node | +| `"oneActive"` | At most one active edge (`validTo` is `undefined`) per target node | + +There is no `"unique"` target cardinality: pair uniqueness is a property of +the `(source, target)` pair, already declared from the source side by +`Cardinality`'s `"unique"` value. + ## Query Types ### `VariableLengthSpec` @@ -548,6 +558,7 @@ Configuration for variable-length (recursive) traversals. type VariableLengthSpec = Readonly<{ minDepth: number; // Minimum hops (default: 1) maxDepth: number; // Maximum hops (-1 = unlimited) + exhaustive?: boolean; // No hop ceiling; needs cyclePolicy "prevent" and maxDepth -1 cyclePolicy: "prevent" | "allow"; // Cycle handling mode pathAlias?: string; // Column alias for projected path depthAlias?: string; // Column alias for projected depth diff --git a/packages/benchmarks/README.md b/packages/benchmarks/README.md index f3997b6e1..e2c461762 100644 --- a/packages/benchmarks/README.md +++ b/packages/benchmarks/README.md @@ -53,7 +53,7 @@ code: `replyOf`'s target is polymorphic (a Comment replies to either a root Post or another Comment), which TypeGraph resolves with a never-instantiated ontological `Message` supertype (`subClassOf(Post, Message)` / `subClassOf(Comment, Message)`) so the -reply-chain walk can use `includeSubClasses` — Neo4j and LadybugDB don't +reply-chain walk can use `expansion: "subclasses"` — Neo4j and LadybugDB don't need this, since multi-label nodes / multi-pair relationship tables handle polymorphic endpoints natively. diff --git a/packages/benchmarks/package.json b/packages/benchmarks/package.json index 220568ffd..1832ff62a 100644 --- a/packages/benchmarks/package.json +++ b/packages/benchmarks/package.json @@ -27,6 +27,14 @@ "bench:write:file": "tsx src/write-bench.ts --storage=file", "prebench:write:postgres": "pnpm --filter @nicia-ai/typegraph build", "bench:write:postgres": "tsx src/write-bench.ts --backend=postgres", + "prebench:acyclicity": "pnpm --filter @nicia-ai/typegraph build", + "bench:acyclicity": "tsx src/acyclicity-bench.ts", + "prebench:acyclicity:file": "pnpm --filter @nicia-ai/typegraph build", + "bench:acyclicity:file": "tsx src/acyclicity-bench.ts --storage=file", + "prebench:acyclicity:postgres": "pnpm --filter @nicia-ai/typegraph build", + "bench:acyclicity:postgres": "tsx src/acyclicity-bench.ts --backend=postgres", + "prebench:acyclicity:contention": "pnpm --filter @nicia-ai/typegraph build", + "bench:acyclicity:contention": "tsx src/acyclicity-bench.ts --backend=postgres --contention", "prebench:bulk-load": "pnpm --filter @nicia-ai/typegraph build", "bench:bulk-load": "tsx src/bulk-load-investigation.ts", "prebench:bulk-load:postgres": "pnpm --filter @nicia-ai/typegraph build", diff --git a/packages/benchmarks/src/acyclicity-bench.ts b/packages/benchmarks/src/acyclicity-bench.ts new file mode 100644 index 000000000..b0e35b343 --- /dev/null +++ b/packages/benchmarks/src/acyclicity-bench.ts @@ -0,0 +1,774 @@ +/** + * Benchmark gate for edge acyclicity: prices the acyclicity + * reachability probe against the `cardinality: "one"` fence baseline and + * an unconstrained `many` baseline, on chain-append, chain-prepend, and + * forest shapes. + * + * Run: + * pnpm --filter @nicia-ai/typegraph-benchmarks bench:acyclicity + * pnpm --filter @nicia-ai/typegraph-benchmarks bench:acyclicity:file + * POSTGRES_URL=... pnpm --filter @nicia-ai/typegraph-benchmarks bench:acyclicity:postgres + * POSTGRES_URL=... pnpm --filter @nicia-ai/typegraph-benchmarks bench:acyclicity:contention + * + * The default and `:file`/`:postgres` invocations also print an + * `EXPLAIN`/`EXPLAIN QUERY PLAN` reading of the probe statement itself, + * asserting `typegraph_edges_from_idx` coverage rather than assuming it. + * `:contention` is a separate mode: *W* ∈ {2, 4, 8, 16} real + * PostgreSQL connections appending to a shared acyclic relation for 30s + * each, reporting aggregate throughput, p99 latency, and the fraction of + * time spent waiting on the per-graph advisory lock — it requires + * `--backend=postgres` (PGlite/SQLite cannot exhibit genuine contention) and + * is not run as part of the ordinary latency invocations above. + * + * Scope note: wide-DAG and diamond-lattice shapes are NOT implemented here; chain-append, chain-prepend, + * and forest are, at 10^4 and 10^5 edges. Report-only, no guardrails, + * matching write-bench's stance. Whether acyclicity ships as-is is decided + * from the PostgreSQL numbers. + */ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import Database from "better-sqlite3"; +import { drizzle as drizzleBetterSqlite3 } from "drizzle-orm/better-sqlite3"; +import { drizzle as drizzleNodePostgres } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type GraphBackend, + type Store, +} from "@nicia-ai/typegraph"; +import { createLocalSqliteBackend } from "@nicia-ai/typegraph/adapters/drizzle/sqlite/local"; +import { + createSqliteBackend, + generateSqliteMigrationSQL, +} from "@nicia-ai/typegraph/adapters/drizzle/sqlite"; +import { + createPostgresBackend, + generatePostgresMigrationSQL, +} from "@nicia-ai/typegraph/adapters/drizzle/postgres"; +import { z } from "zod"; + +import { getPostgresUrl, type PerfBackend, type SqliteStorage } from "./config"; +import { writeHistoryEntry } from "./history"; +import { type LatencyRecord } from "./measurements"; +import { formatMs, median, nowMs, percentile } from "./utils"; + +const WARMUP_ITERATIONS = 1; +const SAMPLE_ITERATIONS = 7; +const SIZES = [10_000, 100_000] as const; + +const Task = defineNode("Task", { schema: z.object({}) }); +/** The unconstrained baseline: no probe, no fence. */ +const plainMany = defineEdge("plainMany", { schema: z.object({}) }); +/** The fence-only baseline: `checkCardinalityConstraint`, no reachability walk. */ +const cardinalityOne = defineEdge("cardinalityOne", { schema: z.object({}) }); +/** The subject: the fence plus the recursive reachability probe. */ +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "acyclicity_bench", + nodes: { Task: { type: Task } }, + edges: { + plainMany: { type: plainMany, from: [Task], to: [Task] }, + cardinalityOne: { + type: cardinalityOne, + from: [Task], + to: [Task], + cardinality: "one", + }, + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + }, +}); + +type BenchStore = Store; + +type BackendHandle = Readonly<{ + backend: GraphBackend; + /** + * Refreshes planner statistics after a bulk seed. Both engines plan the + * fenced claim statements from table statistics; a 10^5-row seed followed + * by a measured insert on stale statistics measures a mis-planned + * statement, not the probe (PostgreSQL chose a nested-loop anti-join with + * the edge id as a join filter over the whole graph — minutes per insert). + * Production databases reach the same state through autovacuum / ANALYZE. + */ + refreshStatistics: () => Promise; + close: () => Promise; +}>; + +async function buildBackend( + kind: PerfBackend, + sqliteStorage: SqliteStorage, +): Promise { + if (kind === "sqlite") { + const tempDir = + sqliteStorage === "file" ? + mkdtempSync(join(tmpdir(), "typegraph-acyclicity-bench-")) + : undefined; + const { backend } = createLocalSqliteBackend( + tempDir === undefined ? {} : { path: join(tempDir, "bench.db") }, + ); + return { + backend, + refreshStatistics: async () => { + if (backend.executeDdl !== undefined) + await backend.executeDdl("ANALYZE"); + }, + close: async () => { + await backend.close(); + if (tempDir !== undefined) + rmSync(tempDir, { recursive: true, force: true }); + }, + }; + } + const pool = new Pool({ connectionString: getPostgresUrl() }); + const backend = createPostgresBackend(drizzleNodePostgres(pool)); + return { + backend, + refreshStatistics: async () => { + await pool.query("ANALYZE"); + }, + close: () => pool.end(), + }; +} + +/** + * Seeds edges the way each kind can afford. `plainMany` (no claim, no fence) + * takes chunked `bulkCreate`; the two fenced kinds take one sequential + * `create` per edge. For `dependsOn` that avoids the O(CHUNK^2) in-batch + * acyclicity probe over a contiguous chain run. For + * `cardinalityOne` it avoids the atomic claim program's N-arm statements + * (`buildDeleteStaleAtomicEdgeClaims` / `buildAcquireAtomicEdgeClaims`), whose + * PostgreSQL executor cost grows super-linearly in the chunk size — a + * 2000-row chunk ran for minutes at more than 2 GB of backend memory before + * the OOM killer took it (tracked as a follow-up issue). Sequential creates + * are also the realistic way an application grows a fenced relation, so this + * is the representative seeding cost, not a benchmark-only shortcut. + */ +async function seedEdges( + store: BenchStore, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + edges: readonly Readonly<{ + from: Readonly<{ kind: "Task"; id: string }>; + to: Readonly<{ kind: "Task"; id: string }>; + }>[], +): Promise { + if (edgeKind !== "plainMany") { + for (const edge of edges) { + await store.edges[edgeKind].create(edge.from, edge.to); + } + return; + } + // Chunked so one bulkCreate statement never exceeds a reasonable bind + // budget at 10^5 edges. + const CHUNK = 2000; + for (let start = 0; start < edges.length; start += CHUNK) { + await store.edges[edgeKind].bulkCreate(edges.slice(start, start + CHUNK)); + } +} + +/** + * Every `Task` id, in a chain: `task-0 -> task-1 -> ... -> task-{size-1}`, + * seeded through {@link seedEdges}. + */ +async function seedChain( + store: BenchStore, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + size: number, +): Promise { + const nodes = await store.nodes.Task.bulkCreate( + Array.from({ length: size }, () => ({ props: {} })), + ); + const ids = nodes.map((node) => node.id); + await seedEdges( + store, + edgeKind, + Array.from({ length: nodes.length - 1 }, (_unused, index) => ({ + from: nodes[index]!, + to: nodes[index + 1]!, + })), + ); + return ids; +} + +/** + * `count` disjoint 4-node trees, each with a `root` this function returns: + * a branching root with two children for `plainMany`/`dependsOn`, or (since + * `cardinality: "one"` permits at most one outgoing edge per source, which + * rules out a branching root) a 4-node linear chain for `cardinalityOne` — same + * node count and walk depth, the only shape that edge kind's fence allows. + */ +async function seedForest( + store: BenchStore, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + treeCount: number, +): Promise { + const nodesPerTree = 4; + const nodes = await store.nodes.Task.bulkCreate( + Array.from({ length: treeCount * nodesPerTree }, () => ({ props: {} })), + ); + const edges: { from: (typeof nodes)[number]; to: (typeof nodes)[number] }[] = + []; + const roots: string[] = []; + for (let tree = 0; tree < treeCount; tree += 1) { + const base = tree * nodesPerTree; + const root = nodes[base]!; + roots.push(root.id); + if (edgeKind === "cardinalityOne") { + edges.push({ from: root, to: nodes[base + 1]! }); + edges.push({ from: nodes[base + 1]!, to: nodes[base + 2]! }); + edges.push({ from: nodes[base + 2]!, to: nodes[base + 3]! }); + } else { + edges.push({ from: root, to: nodes[base + 1]! }); + edges.push({ from: root, to: nodes[base + 2]! }); + edges.push({ from: nodes[base + 1]!, to: nodes[base + 3]! }); + } + } + await seedEdges(store, edgeKind, edges); + return roots; +} + +async function measureInsert( + run: () => Promise, +): Promise< + Readonly<{ median: number; p95: number; samples: readonly number[] }> +> { + for (let index = 0; index < WARMUP_ITERATIONS; index += 1) await run(); + const samples: number[] = []; + for (let index = 0; index < SAMPLE_ITERATIONS; index += 1) { + const start = nowMs(); + await run(); + samples.push(nowMs() - start); + } + return { median: median(samples), p95: percentile(samples, 0.95), samples }; +} + +async function benchChainAppend( + backendHandle: BackendHandle, + size: number, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + latencies: LatencyRecord, +): Promise { + const [store] = await createStoreWithSchema(graph, backendHandle.backend); + const ids = await seedChain(store, edgeKind, size); + await backendHandle.refreshStatistics(); + // Rolls forward each iteration: a `cardinality: "one"` edge allows only + // one outgoing edge per source, so re-appending from the SAME tail on + // every sample would refuse from the second iteration on. + let tail = { kind: "Task" as const, id: ids[ids.length - 1]! }; + const label = `acyclicity:${edgeKind}:chain-append:${String(size)}`; + const { + median: medianMs, + p95, + samples, + } = await measureInsert(async () => { + const fresh = await store.nodes.Task.create({}); + await store.edges[edgeKind].create(tail, fresh); + tail = { kind: "Task", id: fresh.id }; + }); + latencies.set(label, { median: medianMs, p95, samples }); + console.log( + `${label.padEnd(48)} ${formatMs(medianMs).padStart(8)} p95 ${formatMs(p95).padStart(8)}`, + ); +} + +async function benchChainPrepend( + backendHandle: BackendHandle, + size: number, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + latencies: LatencyRecord, +): Promise { + const [store] = await createStoreWithSchema(graph, backendHandle.backend); + const ids = await seedChain(store, edgeKind, size); + await backendHandle.refreshStatistics(); + const head = { kind: "Task" as const, id: ids[0]! }; + const label = `acyclicity:${edgeKind}:chain-prepend:${String(size)}`; + const { + median: medianMs, + p95, + samples, + } = await measureInsert(async () => { + const fresh = await store.nodes.Task.create({}); + await store.edges[edgeKind].create(fresh, head); + }); + latencies.set(label, { median: medianMs, p95, samples }); + console.log( + `${label.padEnd(48)} ${formatMs(medianMs).padStart(8)} p95 ${formatMs(p95).padStart(8)}`, + ); +} + +async function benchForest( + backendHandle: BackendHandle, + size: number, + edgeKind: "plainMany" | "cardinalityOne" | "dependsOn", + latencies: LatencyRecord, +): Promise { + const treeCount = Math.max(1, Math.floor(size / 4)); + const [store] = await createStoreWithSchema(graph, backendHandle.backend); + const roots = await seedForest(store, edgeKind, treeCount); + await backendHandle.refreshStatistics(); + // A fresh node above an EXISTING tree's root: the walk from that root + // must traverse the whole (small, ~4-node) tree before concluding "not + // found", and — this is the point of the shape — must not touch any of + // the other disjoint trees to do it. `from` is always a brand-new node, + // so cardinality: "one" never refuses a repeat. + const label = `acyclicity:${edgeKind}:forest:${String(size)}`; + const { + median: medianMs, + p95, + samples, + } = await measureInsert(async () => { + const fresh = await store.nodes.Task.create({}); + const root = { kind: "Task" as const, id: roots[0]! }; + await store.edges[edgeKind].create(fresh, root); + }); + latencies.set(label, { median: medianMs, p95, samples }); + console.log( + `${label.padEnd(48)} ${formatMs(medianMs).padStart(8)} p95 ${formatMs(p95).padStart(8)}`, + ); +} + +// ============================================================ +// Fourth reading: EXPLAIN (ANALYZE, BUFFERS) / EXPLAIN QUERY +// PLAN of the reachability probe itself, asserting index coverage rather +// than assuming it. +// ============================================================ + +/** + * The system indexes the recursive term's `(graph_id, [kind,] from_kind, + * from_id)` seek may use. SQLite picks `typegraph_edges_from_idx`; PostgreSQL + * prefers `typegraph_edges_cardinality_idx`, which leads with the same three + * columns plus `kind` and is the tighter match for a single-kind relation. + * Either is the per-hop seek the probe's cost model assumes; what must NOT + * appear is a whole-partition scan (`typegraph_edges_kind_idx`, a heap scan, + * or SQLite's `MATERIALIZE candidates`). + */ +const EXPECTED_ACYCLICITY_INDEXES = [ + "typegraph_edges_from_idx", + "typegraph_edges_cardinality_idx", +] as const; + +function seekIndexNamed(planText: string): string | undefined { + return EXPECTED_ACYCLICITY_INDEXES.find((name) => planText.includes(name)); +} + +type CapturedStatement = Readonly<{ sql: string; params: readonly unknown[] }>; + +/** + * A bring-your-own-connection SQLite backend (public + * `@nicia-ai/typegraph/adapters/drizzle/sqlite`) whose driver-level + * `client.prepare` is patched to record every statement — the same idiom + * `tests/lock-fence-test-utils.ts` uses, reimplemented here so this package + * depends on no typegraph test file. `better-sqlite3`'s synchronous session + * never routes a query through Drizzle's own session object, tx-scoped or + * not, so patching `prepare` alone sees everything. + */ +function buildCapturingSqliteBackend(): Readonly<{ + backend: GraphBackend; + statements: CapturedStatement[]; + close: () => Promise; +}> { + const client = new Database(":memory:"); + client.exec(generateSqliteMigrationSQL()); + const statements: CapturedStatement[] = []; + const originalPrepare = client.prepare.bind(client); + client.prepare = ((sqlText: string) => { + const statement = originalPrepare(sqlText); + const originalAll = statement.all.bind(statement); + statement.all = (...params: unknown[]) => { + statements.push({ sql: sqlText, params }); + return originalAll(...params); + }; + return statement; + }) as typeof client.prepare; + const backend = createSqliteBackend(drizzleBetterSqlite3(client)); + return { + backend, + statements, + close: () => { + client.close(); + return Promise.resolve(); + }, + }; +} + +/** + * A bring-your-own-connection PostgreSQL backend (public + * `@nicia-ai/typegraph/adapters/drizzle/postgres`) whose Drizzle `logger` + * records every statement. The acyclicity probe always runs inside the + * per-graph write-fence transaction, which routes through Drizzle's own + * session (`postgres.ts`'s tx-scoped execution adapter), so the logger alone + * — no driver-level `pool.query` patch — sees it. + */ +function buildCapturingPostgresBackend(pool: Pool): Readonly<{ + backend: GraphBackend; + statements: CapturedStatement[]; +}> { + const statements: CapturedStatement[] = []; + const db = drizzleNodePostgres(pool, { + logger: { + logQuery(query: string, params: unknown[]): void { + statements.push({ sql: query, params }); + }, + }, + }); + return { backend: createPostgresBackend(db), statements }; +} + +/** The captured probe statement, or `undefined` if none was issued. */ +function findAcyclicityProbeStatement( + statements: readonly CapturedStatement[], +): CapturedStatement | undefined { + return statements.find( + (statement) => + statement.sql.includes("WITH RECURSIVE") && + statement.sql.includes("ancestry"), + ); +} + +/** + * Runs the reachability probe once against a chain of `size` edges (the + * chain-prepend shape: the walk from `head` traverses the whole chain, the + * realistic worst case for the recursive term), captures its exact SQL text + * and bound parameters, and re-issues it as an `EXPLAIN` on the SAME + * connection. Prints the plan and reports whether it names + * one of {@link EXPECTED_ACYCLICITY_INDEXES} — report-only, matching this lane's + * stance elsewhere, but printed prominently: a silent heap scan here means + * every other number in this file is priced against the wrong plan. + */ +async function explainAcyclicityProbe( + backendKind: PerfBackend, + size: number, +): Promise { + if (backendKind === "sqlite") { + const { backend, statements, close } = buildCapturingSqliteBackend(); + try { + const [store] = await createStoreWithSchema(graph, backend); + const ids = await seedChain(store, "dependsOn", size); + if (backend.executeDdl !== undefined) await backend.executeDdl("ANALYZE"); + const fresh = await store.nodes.Task.create({}); + const head = { kind: "Task" as const, id: ids[0]! }; + statements.splice(0); + await store.edges.dependsOn.create(fresh, head); + + const probe = findAcyclicityProbeStatement(statements); + if (probe === undefined) { + console.log( + `acyclicity:explain:sqlite:${String(size)} NO PROBE STATEMENT CAPTURED`, + ); + return; + } + // Re-run EXPLAIN QUERY PLAN on the SAME backend connection the probe + // itself ran on, not a fresh one, so the plan reflects the actual + // populated database. + if (backend.executeRaw === undefined) { + console.log( + `acyclicity:explain:sqlite:${String(size)} backend exposes no executeRaw`, + ); + return; + } + const plan = await backend.executeRaw>( + `EXPLAIN QUERY PLAN ${probe.sql}`, + probe.params, + ); + const planText = plan + .map((row) => Object.values(row).join(" ")) + .join("\n"); + const seekIndex = seekIndexNamed(planText); + console.log( + `acyclicity:explain:sqlite:${String(size)} ${seekIndex === undefined ? "DOES NOT NAME A SEEK INDEX — see plan below" : `USES ${seekIndex}`}`, + ); + if (seekIndex === undefined) console.log(planText); + } finally { + await close(); + } + return; + } + + // PostgreSQL: a fresh pool, migrated once, capturing statements through + // Drizzle's logger (see {@link buildCapturingPostgresBackend}). + const pool = new Pool({ connectionString: getPostgresUrl() }); + try { + await pool.query(generatePostgresMigrationSQL()); + const { backend, statements } = buildCapturingPostgresBackend(pool); + const [store] = await createStoreWithSchema(graph, backend); + const ids = await seedChain(store, "dependsOn", size); + await pool.query("ANALYZE"); + const fresh = await store.nodes.Task.create({}); + const head = { kind: "Task" as const, id: ids[0]! }; + statements.splice(0); + await store.edges.dependsOn.create(fresh, head); + + const probe = findAcyclicityProbeStatement(statements); + if (probe === undefined) { + console.log( + `acyclicity:explain:postgres:${String(size)} NO PROBE STATEMENT CAPTURED`, + ); + return; + } + const result = await pool.query(`EXPLAIN (ANALYZE, BUFFERS) ${probe.sql}`, [ + ...probe.params, + ]); + const planText = (result.rows as readonly Record[]) + .map((row) => Object.values(row).join(" ")) + .join("\n"); + const seekIndex = seekIndexNamed(planText); + const isIndexOnlyScan = /Index Only Scan/i.test(planText); + console.log( + `acyclicity:explain:postgres:${String(size)} ${ + seekIndex === undefined ? + "DOES NOT NAME A SEEK INDEX — see plan below" + : `USES ${seekIndex}${isIndexOnlyScan ? " (index-only scan)" : " (NOT an index-only scan — see plan below)"}` + }`, + ); + if (seekIndex === undefined || !isIndexOnlyScan) console.log(planText); + } finally { + await pool.end(); + } +} + +// ============================================================ +// Contention run (PostgreSQL only, real server — PGlite is +// single-connection and cannot overlap). Not run by this lane's SQLite +// invocations; wired behind `--contention --backend=postgres`. +// ============================================================ + +const CONTENTION_WRITER_COUNTS = [2, 4, 8, 16] as const; +const CONTENTION_DURATION_MS = 30_000; +const CONTENTION_SEED_EDGES = 100_000; +/** `pg_advisory_xact_lock`'s wait event, per `pg_stat_activity.wait_event`. */ +const ADVISORY_LOCK_WAIT_EVENT = "advisory"; + +type ContentionResult = Readonly<{ + writers: number; + throughputPerSecond: number; + p99Ms: number; + waitFraction: number; +}>; + +/** + * One writer's loop for the duration of the run: append a fresh node onto + * the END of its own private chain (so writers never contend on the SAME + * two-node cycle — the point is to price FENCE HOLD TIME, not + * refusal handling), recording each insert's latency. + */ +async function runContentionWriter( + store: BenchStore, + edgeKind: "cardinalityOne" | "dependsOn", + headId: string, + deadline: number, +): Promise<{ count: number; latenciesMs: number[] }> { + let tail = { kind: "Task" as const, id: headId }; + const latenciesMs: number[] = []; + let count = 0; + while (nowMs() < deadline) { + const fresh = await store.nodes.Task.create({}); + const startedAt = nowMs(); + await store.edges[edgeKind].create(tail, fresh); + latenciesMs.push(nowMs() - startedAt); + tail = { kind: "Task", id: fresh.id }; + count += 1; + } + return { count, latenciesMs }; +} + +/** Fraction of `pg_stat_activity` samples, taken once per second, waiting on the advisory lock. */ +async function sampleAdvisoryWaitFraction( + pool: Pool, + deadline: number, +): Promise { + let waiting = 0; + let total = 0; + while (nowMs() < deadline) { + const result = await pool.query<{ wait_event: string | null }>( + "SELECT wait_event FROM pg_stat_activity WHERE state = 'active'", + ); + total += result.rows.length; + waiting += result.rows.filter( + (row) => row.wait_event === ADVISORY_LOCK_WAIT_EVENT, + ).length; + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + return total === 0 ? 0 : waiting / total; +} + +/** + * *W* writer connections, each appending to its OWN chain of the given edge + * kind on the same graph, for {@link CONTENTION_DURATION_MS}. Every writer + * of `dependsOn` contends for the SAME per-graph advisory lock regardless of + * which chain it appends to (`edgeWriteNeedsConstraintFence` fences the + * whole graph, not a chain), so this measures exactly the fence-hold-time + * question the contention run asks: how much of it is the probe. + */ +async function runContentionLevel( + edgeKind: "cardinalityOne" | "dependsOn", + writerCount: number, +): Promise { + const pools = Array.from( + { length: writerCount }, + () => new Pool({ connectionString: getPostgresUrl(), max: 2 }), + ); + const samplerPool = new Pool({ connectionString: getPostgresUrl() }); + try { + await pools[0]!.query(generatePostgresMigrationSQL()); + const stores = pools.map((pool) => + createStoreWithSchema( + graph, + createPostgresBackend(drizzleNodePostgres(pool)), + ), + ); + const resolvedStores = await Promise.all(stores); + // Each writer gets its own disjoint chain, seeded up front so the + // measured loop is pure append cost, matching `benchChainAppend`. The + // chains are sized so the relation's TOTAL population reaches + // the target 10^5 edges before the timed contention window opens — + // an append is O(1) regardless of chain length in this design (the + // walk from a fresh leaf finds no out-edges), so this sizing is about + // matching the stated population, not stressing the probe itself. + const perWriterSeedSize = Math.max( + 1, + Math.floor(CONTENTION_SEED_EDGES / writerCount), + ); + const heads = await Promise.all( + resolvedStores.map(async ([store]) => { + const ids = await seedChain(store, edgeKind, perWriterSeedSize); + return ids[ids.length - 1]!; + }), + ); + + const deadline = nowMs() + CONTENTION_DURATION_MS; + const [writerResults, waitFraction] = await Promise.all([ + Promise.all( + resolvedStores.map(async ([store], index) => + runContentionWriter(store, edgeKind, heads[index]!, deadline), + ), + ), + sampleAdvisoryWaitFraction(samplerPool, deadline), + ]); + const allLatencies = writerResults.flatMap((result) => result.latenciesMs); + const totalCount = writerResults.reduce( + (sum, result) => sum + result.count, + 0, + ); + return { + writers: writerCount, + throughputPerSecond: totalCount / (CONTENTION_DURATION_MS / 1000), + p99Ms: percentile(allLatencies, 0.99), + waitFraction, + }; + } finally { + await Promise.all(pools.map((pool) => pool.end())); + await samplerPool.end(); + } +} + +async function runContentionSuite(): Promise { + console.log( + `\nD-7 contention run: W writers appending to a shared ${String(CONTENTION_SEED_EDGES)}-edge acyclic relation for ${String(CONTENTION_DURATION_MS / 1000)}s each.\n`, + ); + const baseline: ContentionResult[] = []; + const subject: ContentionResult[] = []; + for (const writerCount of CONTENTION_WRITER_COUNTS) { + const cardinalityOneResult = await runContentionLevel( + "cardinalityOne", + writerCount, + ); + baseline.push(cardinalityOneResult); + console.log( + `contention:cardinalityOne:writers=${String(writerCount)} throughput=${cardinalityOneResult.throughputPerSecond.toFixed(1)}/s p99=${formatMs(cardinalityOneResult.p99Ms)} waitFraction=${cardinalityOneResult.waitFraction.toFixed(2)}`, + ); + const dependsOnResult = await runContentionLevel("dependsOn", writerCount); + subject.push(dependsOnResult); + console.log( + `contention:dependsOn:writers=${String(writerCount)} throughput=${dependsOnResult.throughputPerSecond.toFixed(1)}/s p99=${formatMs(dependsOnResult.p99Ms)} waitFraction=${dependsOnResult.waitFraction.toFixed(2)}`, + ); + } + + const eightWriterBaseline = baseline.find((result) => result.writers === 8); + const eightWriterSubject = subject.find((result) => result.writers === 8); + if (eightWriterBaseline !== undefined && eightWriterSubject !== undefined) { + const ratio = + eightWriterSubject.throughputPerSecond / + eightWriterBaseline.throughputPerSecond; + console.log( + `\nD-7: 8-writer dependsOn throughput is ${(ratio * 100).toFixed(1)}% of the cardinalityOne baseline (criterion: >= 70%).`, + ); + } +} + +async function main(argv: readonly string[]): Promise { + const backendKind: PerfBackend = + argv.includes("--backend=postgres") ? "postgres" : "sqlite"; + const sqliteStorage: SqliteStorage = + argv.includes("--storage=file") ? "file" : "memory"; + + // A separate mode from the latency lane above — 30s per writer + // count is far too slow to run inline with every invocation, and the + // measurement only means anything on a real PostgreSQL server (PGlite + // cannot overlap two writers). + if (argv.includes("--contention")) { + if (backendKind !== "postgres") { + throw new Error( + "--contention requires --backend=postgres: PGlite/SQLite are " + + "single-connection and cannot exhibit genuine write contention.", + ); + } + await runContentionSuite(); + return; + } + + console.log( + `TypeGraph acyclicity bench (backend=${backendKind}${backendKind === "sqlite" ? `, storage=${sqliteStorage}` : ""}, warmup=${WARMUP_ITERATIONS}, samples=${SAMPLE_ITERATIONS})`, + ); + + const latencies: LatencyRecord = new Map(); + for (const size of SIZES) { + for (const edgeKind of [ + "plainMany", + "cardinalityOne", + "dependsOn", + ] as const) { + for (const shape of [benchChainAppend, benchChainPrepend, benchForest]) { + const handle = await buildBackend(backendKind, sqliteStorage); + try { + await shape(handle, size, edgeKind, latencies); + } finally { + await handle.close(); + } + } + } + } + + const historyPath = writeHistoryEntry({ + backend: backendKind, + ...(backendKind === "sqlite" ? { sqliteStorage } : {}), + scale: 1, + userCount: 0, + latencies, + }); + console.log(`\nappended run to ${historyPath}`); + + console.log("\nIndex-coverage reading:"); + for (const size of SIZES) { + await explainAcyclicityProbe(backendKind, size); + } + + console.log( + "\nD-7 ship criteria (read manually against the numbers above):" + + '\n - acyclic insert p95 within ~3x the cardinality:"one" insert p95 at 10^5 edges on the forest shape, on both engines.' + + '\n - 8-writer PostgreSQL contention run keeps aggregate throughput within ~30% of the cardinality:"one" run: run separately with `--backend=postgres --contention` (30s per writer count, not run as part of this invocation).' + + "\nFail either => the maintained ancestor set needs to be designed before acyclicity ships. This lane does not decide it; the PostgreSQL numbers do.", + ); +} + +await main(process.argv.slice(2)); diff --git a/packages/benchmarks/src/real/engines/ladybug.ts b/packages/benchmarks/src/real/engines/ladybug.ts index ee1dd8f4b..4a5ef8b26 100644 --- a/packages/benchmarks/src/real/engines/ladybug.ts +++ b/packages/benchmarks/src/real/engines/ladybug.ts @@ -6,7 +6,7 @@ * * Unlike the TypeGraph schema (`../schema/snb-graph`), which models the * polymorphic Message supertype (Post | Comment) through an ontological - * `includeSubClasses` workaround, LadybugDB's DDL supports multi-pair + * `expansion: "subclasses"` workaround, LadybugDB's DDL supports multi-pair * `CREATE REL TABLE` natively (`HasCreator(FROM Post TO Person, FROM Comment * TO Person)`, `ReplyOf(FROM Comment TO Post, FROM Comment TO Comment)`), so * no workaround is needed here — but MATCH/CREATE on the Post/Comment split @@ -483,7 +483,7 @@ async function createQueries(conn: Connection): Promise { // ReplyOf can only continue through Comment nodes (Post has no outgoing // ReplyOf edge), so this always resolves the unique root Post without // needing the ancestor-chain-plus-depth workaround `./typegraph-queries.ts` - // uses for its ontological `includeSubClasses` traversal. + // uses for its ontological subclass-expansion traversal. const rootWalkStatement = await conn.prepare( `MATCH (c:Comment {id: $id})-[:ReplyOf*1..${ROOT_WALK_MAX_HOPS}]->(root:Post) RETURN DISTINCT root.id AS id;`, ); diff --git a/packages/benchmarks/src/real/engines/typegraph-queries.ts b/packages/benchmarks/src/real/engines/typegraph-queries.ts index 744658299..950afd492 100644 --- a/packages/benchmarks/src/real/engines/typegraph-queries.ts +++ b/packages/benchmarks/src/real/engines/typegraph-queries.ts @@ -112,7 +112,7 @@ export function createSnbQueries(store: SnbStore): SnbQueries { // Reply-chain root walk: replyOf is polymorphic (Comment -> Post | Comment), // so the recursive traversal targets the `Message` ontological supertype - // via includeSubClasses and returns the WHOLE ancestor chain with depth; + // via expansion: "subclasses" and returns the WHOLE ancestor chain with depth; // the root is the max-depth row (a Post, since Post has no outgoing // replyOf edge and the walk cannot continue past it). const replyOfAncestors = store @@ -126,7 +126,7 @@ export function createSnbQueries(store: SnbStore): SnbQueries { cyclePolicy: "prevent", depth: "d", }) - .to("Message", "root", { includeSubClasses: true }) + .to("Message", "root", { expansion: "subclasses" }) .select((ctx) => ({ id: ctx.root.id, depth: ctx.d })) .prepare(); diff --git a/packages/benchmarks/src/real/schema/snb-graph.ts b/packages/benchmarks/src/real/schema/snb-graph.ts index 9a02be88e..994fef25b 100644 --- a/packages/benchmarks/src/real/schema/snb-graph.ts +++ b/packages/benchmarks/src/real/schema/snb-graph.ts @@ -9,7 +9,7 @@ * costs zero storage). It exists solely so the `replyOf` reply chain, whose * target is polymorphic (a Comment replies to either a Post or another * Comment), can be walked with a single recursive query via - * `includeSubClasses`, instead of hand-rolled per-kind SQL. `Forum`'s + * the `expansion` option, instead of hand-rolled per-kind SQL. `Forum`'s * moderator is a plain `moderatorId` field rather than a fifth edge kind — * the plan enumerates exactly four edge kinds, and IS6's "moderator of a * forum" is a single point read by id either way. @@ -155,7 +155,7 @@ export const snbGraph = defineGraph({ containerOf: { type: containerOf, from: [Forum], to: [Post] }, // `Message` must be listed alongside its concrete subclasses here (not // just declared via `ontology` below) for `.to("Message", alias, { - // includeSubClasses: true })` to type-check — matching how + // expansion: "subclasses" })` to type-check — matching how // `examples/03-subclass-hierarchy.ts` lists a supertype directly in an // edge's `to` array next to its subclasses. replyOf: { type: replyOf, from: [Comment], to: [Post, Comment, Message] }, diff --git a/packages/eslint-config/base.mjs b/packages/eslint-config/base.mjs index 3fa91331b..e1e015352 100644 --- a/packages/eslint-config/base.mjs +++ b/packages/eslint-config/base.mjs @@ -11,6 +11,7 @@ import tseslint from "typescript-eslint"; * Common ignore patterns for all packages */ export const ignores = [ + ".declaration-emit/**", ".stryker-tmp/**", ".wrangler/**", "coverage/**", diff --git a/packages/typegraph/eslint.config.mjs b/packages/typegraph/eslint.config.mjs index 44a052015..c63f2f66b 100644 --- a/packages/typegraph/eslint.config.mjs +++ b/packages/typegraph/eslint.config.mjs @@ -315,9 +315,9 @@ export const DIALECT_LITERAL_EXEMPTIONS = [ sites: 1, }, { - file: "src/store/store.ts", + file: "src/store/recorded-capture.ts", reason: - "Passes ownsWriteLock=true only for SQLite's BEGIN IMMEDIATE transactions, which already hold the write lock recorded-clock allocation would otherwise re-acquire — the one boolean flag this preflight path threads through.", + "transactionOwnsSqliteWriteLock is the one owner of whether a transaction handed to createRecordedTransactionScope already holds a SQLite write lock (its ownsWriteLock parameter) — every caller unable to prove that structurally (store.ts's schema-commit preflight, schema-transition.ts's own) calls this instead of re-spelling the dialect comparison inline.", permanent: true, sites: 1, }, diff --git a/packages/typegraph/etc/api-surface-exceptions.json b/packages/typegraph/etc/api-surface-exceptions.json index 882fc45a3..adcf5dd89 100644 --- a/packages/typegraph/etc/api-surface-exceptions.json +++ b/packages/typegraph/etc/api-surface-exceptions.json @@ -1,4 +1,7468 @@ [ + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CommonOperationStrategy", + "member": "buildCountEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`CommonOperationStrategy` is carried by the bundled engine profiles only: `strategy` is not a derivable profile key and `SqlEngineProfile.assembly` is sealed, so no external code authors or supplies one and a member change is not an authoring break (the type reads as author-facing but is not; see #672). `buildCountEdgesAtEndpoint` replaces `buildCountEdgesFrom` so the same builder counts either endpoint for target-side cardinality. Declared in .changeset/edge-kind-integrity.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CommonOperationStrategy", + "member": "buildReadEdgeClaimIncumbents", + "kind": "required-member-added", + "reason": "`CommonOperationStrategy` is carried by the bundled engine profiles only: `strategy` is not a derivable profile key and `SqlEngineProfile.assembly` is sealed, so no external code authors or supplies one and a member change is not an authoring break (the type reads as author-facing but is not; see #672). `buildReadEdgeClaimIncumbents` reads the live incumbent on each composition axis after the batch claim lock, rendered from the same competing-live-edge predicate the single-row claim guard reads, so a batch claim over an attachment that holds no claim row refuses with the incumbent's id instead of attaching the part to a second whole. The composition claim it guards is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CommonOperationStrategy", + "member": "buildContendedCompositionEdgeRowAudit", + "kind": "required-member-added", + "reason": "`CommonOperationStrategy` is carried by the bundled engine profiles only: `strategy` is not a derivable profile key and `SqlEngineProfile.assembly` is sealed, so no external code authors or supplies one and a member change is not an authoring break (the type reads as author-facing but is not; see #672). The composition claim's constraint-fence audit statement joins the bundled builders. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CommonOperationStrategy", + "member": "buildMisassignedEdgeEndpointAudit", + "kind": "required-member-added", + "reason": "`CommonOperationStrategy` is carried by the bundled engine profiles only: `strategy` is not a derivable profile key and `SqlEngineProfile.assembly` is sealed, so no external code authors or supplies one and a member change is not an authoring break (the type reads as author-facing but is not; see #672). The endpoint-kind fence audit statement joins the bundled builders. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CommonOperationStrategy", + "member": "buildCountEdgesFrom", + "kind": "member-removed", + "reason": "`CommonOperationStrategy` is carried by the bundled engine profiles only: `strategy` is not a derivable profile key and `SqlEngineProfile.assembly` is sealed, so no external code authors or supplies one and a member change is not an authoring break (the type reads as author-facing but is not; see #672). Replaced by `buildCountEdgesAtEndpoint`, which counts either endpoint for target-side cardinality. Declared in .changeset/edge-kind-integrity.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-engine.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-libsql.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-sqlite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-backend.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeOptions", + "member": "description", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-core.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "formatVersion", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "digest", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "mode", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "target", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "anchors", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "proposed", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "writes", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "guards", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "review", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanArtifactV1", + "member": "provenance", + "kind": "member-removed", + "reason": "`MERGE_PLAN_FORMAT_VERSION` is now `2`: the artifact carries `review.compositionOrphans`, `parseMergePlanArtifact` reports a stored version-1 artifact as `unsupported-version`, and the type is renamed `MergePlanArtifactV1` -> `MergePlanArtifactV2` (the version-agnostic `MergePlanArtifact` alias is unchanged). The checker reports the rename as one declaration lost and one gained. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MergePlanReview", + "member": "compositionOrphans", + "kind": "required-member-added", + "reason": "`MergePlanReview` gains `compositionOrphans`, the review section a version-2 plan artifact carries. The library produces the review inside the artifact; a consumer reads it. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ensureSchema(options)", + "member": "preloaded", + "kind": "member-removed", + "reason": "`ensureSchema`'s inline `preloaded` options literal is extracted, member for member, into the named `EnsureSchemaPreloadedOptions` type; the parameter's shape is byte-identical and `preloaded` stays optional. The checker does not inventory type-reference bodies, so it reads the extraction as a removal.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ensureSchema(options)", + "member": "preloaded.activeRow", + "kind": "member-removed", + "reason": "`ensureSchema`'s inline `preloaded` options literal is extracted, member for member, into the named `EnsureSchemaPreloadedOptions` type; the parameter's shape is byte-identical and `preloaded` stays optional. The checker does not inventory type-reference bodies, so it reads the extraction as a removal.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "ensureSchema(options)", + "member": "preloaded.storedSchema", + "kind": "member-removed", + "reason": "`ensureSchema`'s inline `preloaded` options literal is extracted, member for member, into the named `EnsureSchemaPreloadedOptions` type; the parameter's shape is byte-identical and `preloaded` stays optional. The checker does not inventory type-reference bodies, so it reads the extraction as a removal.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-schema.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CardinalityErrorDetails", + "member": "direction", + "kind": "required-member-added", + "reason": "`CardinalityErrorDetails` is the `details` payload the library attaches to a cardinality error; nothing outside the library constructs one. It gains required `direction`, `toKind` and `toId` so a target-axis violation names the endpoint it refused. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CardinalityErrorDetails", + "member": "toKind", + "kind": "required-member-added", + "reason": "`CardinalityErrorDetails` is the `details` payload the library attaches to a cardinality error; nothing outside the library constructs one. It gains required `direction`, `toKind` and `toId` so a target-axis violation names the endpoint it refused. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CardinalityErrorDetails", + "member": "toId", + "kind": "required-member-added", + "reason": "`CardinalityErrorDetails` is the `details` payload the library attaches to a cardinality error; nothing outside the library constructs one. It gains required `direction`, `toKind` and `toId` so a target-axis violation names the endpoint it refused. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeOptions", + "member": "description", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "ClaimEdgeCardinalityParams", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "ContendedEdgeRow", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "CountEdgesFromParams", + "member": "graphId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "CountEdgesFromParams", + "member": "edgeKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromKind", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "CountEdgesFromParams", + "member": "fromId", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "CountEdgesFromParams", + "member": "activeOnly", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "EdgeCardinalityDeclaration", + "member": "cardinality", + "kind": "member-removed", + "reason": "`cardinality` was not dropped: it moved, together with the new `direction: \"source\" | \"target\"` discriminant, into the shared `EdgeCardinalityAxisRef` member of an intersection, and the type still carries it. The checker does not inventory type-reference bodies, so it reads the move as a removal and never sees the `direction` addition. The real change — the added `direction` discriminant on the claim params, the declaration and the contended row — is declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "GraphBackend", + "member": "countEdgesAtEndpoint", + "kind": "required-member-added", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "GraphBackend", + "member": "countEdgesFrom", + "kind": "member-removed", + "reason": "`GraphBackend.countEdgesFrom` is replaced by `countEdgesAtEndpoint`, whose params carry an `endpoint: \"from\" | \"to\"` discriminant and the endpoint identity as `endpointKind`/`endpointId` so the target axis can be counted; a custom backend renames its implementation and reads the discriminant. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "IdentityWriteSummary", + "member": "transitions", + "kind": "required-member-added", + "reason": "`IdentityWriteSummary` is the per-transaction identity write count the library attaches to a transaction receipt; a consumer reads it and never constructs one. It gains `transitions`, counted beside (never inside) `total`. Declared in .changeset/identity-reconciliation.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKinds", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgeKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionExistence", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint; it gains the `compositionExistence` accessor for the new `existence` axis on a composition pair. Declared in .changeset/composition.md under \"Breaking\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPairsBetween", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPairVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the two composition pair readers that replace `getCompositionEdge`'s silent code-point-first match — `compositionPairsBetween` for every declared pair and `compositionPairVia` for the pair a known realizing edge holds. The replacement is declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnder", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPartSide", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionRelation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOver", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "isCompositionEdge", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "isCompositionPart", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "isCompositionWhole", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the composition-relation accessors the registry now owns. The composition relation and its registry ownership are declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "edgeKindFacts", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `edgeKindFacts` reader, the resolved endpoint facts of an edge registration that a traversal's `to()` / `toDynamic()` target is now checked against. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "ManagedEdgeCreatePlan", + "member": "cardinalityClaim", + "kind": "member-removed", + "reason": "`ManagedEdgeCreatePlan.cardinalityClaim` becomes `cardinalityClaims: readonly ClaimEdgeCardinalityParams[]` so a two-axis plan carries both claims; a command port applies every entry or returns `unsupported` for the `\"cardinalityClaim\"` dimension. Declared in .changeset/edge-kind-integrity.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "MetaEdgeProperties", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "MetaEdgeProperties", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "MetaEdgeProperties", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "MetaEdgeProperties", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "MetaEdgeProperties", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "NodeCollection", + "member": "reparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`) and is never hand-implemented by an external consumer; it gains `reparent`, which moves a composition part to a new whole under one per-graph fence. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "NodeCollection", + "member": "bulkReparent", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `bulkReparent`, which runs `reparent` for many parts in one transaction. The changeset tells a hand-written implementation of the interface to add both `reparent` and `bulkReparent`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "NodeCollection", + "member": "planDelete", + "kind": "required-member-added", + "reason": "`NodeCollection` is built by the library (`createStore`); it gains `planDelete`, which reads the delete's own plan with every write skipped. The changeset tells a hand-written implementation of the interface to add `reparent`, `bulkReparent` and `planDelete`. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilder", + "member": "parts", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilder", + "member": "wholes", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilderConfig", + "member": "defaultExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilderState", + "member": "startExpansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilderState", + "member": "startRequestedKinds", + "kind": "required-member-added", + "reason": "the list-source expansion makes the start alias's expanded kinds differ from the kinds the source named, so the builder's internal state carries `startRequestedKinds` beside `startKinds`, and a set-update candidate refusal advises from the named kinds rather than the resolved axis. `QueryBuilderState` is not exported from any entrypoint; it is the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilderState", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryStart", + "member": "expansion", + "kind": "required-member-added", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryStart", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the query builder's subclass-expansion axis moves from a boolean `includeSubClasses` to a resolved `AliasExpansionAxis`, so the builder's internal state carries `startExpansion` / `expansion` and its config carries the store-level `defaultExpansion`, and a supertype alias resolves to `PolymorphicNodeType`. None of `QueryBuilderConfig`, `QueryBuilderState`, `QueryStart` or `PolymorphicNodeType` is exported from any entrypoint; they are the builder's internal state, reached only because the builder class constructors appear in the report. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "ResolvedSqlTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SearchScopeOptions", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "`search()`'s `includeSubClasses?: boolean` is replaced by `expansion?: \"exact\" | \"subclasses\"`, the same option name and values the query builder states, so one vocabulary covers both. The default is unchanged (`\"exact\"` — the search facade is not polymorphic by default), and `includeSubClasses: true` becomes `expansion: \"subclasses\"`. Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SerializedMetaEdge", + "member": "transitive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SerializedMetaEdge", + "member": "symmetric", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SerializedMetaEdge", + "member": "reflexive", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SerializedMetaEdge", + "member": "inverse", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SerializedMetaEdge", + "member": "inference", + "kind": "member-removed", + "reason": "custom `metaEdge()`, `MetaEdgeOptions` and the public `InferenceType` union are removed, and `MetaEdgeProperties` / `SerializedMetaEdge` narrow to their remaining members after the inference flags (`transitive`, `symmetric`, `reflexive`, `inverse`, `inference`) are dropped. Declared in .changeset/remove-custom-meta-edge.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionRetentionTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SqlSchema", + "member": "identityTransitionsTable", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`. `SqlTableNames` gains both as OPTIONAL override keys, so nothing a consumer passes to `createSqlSchema` changes; only the resolved `ResolvedSqlTableNames` and the `SqlSchema` the factory returns carry them as required, and the library is their sole producer. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "batchPointRead", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "identityContext", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "detachDeletedImportedIdentityNode", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityTransitionPageAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "identityTransitionRetentionAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "importIdentityTransitionsAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TransactionReceipt", + "member": "cascadedParts", + "kind": "required-member-added", + "reason": "`TransactionReceipt` is produced by `store.transactionWithReceipt`, never authored by a consumer; it gains the always-present `cascadedParts` refs naming the parts a whole's delete cascade took with it (empty when the transaction deleted no whole). Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TransactionRuntime", + "member": "deleteNodeWithPolicy", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `deleteNodeWithPolicy` so merge apply deletes through the transaction port. The checker reaches it contravariantly only because the transaction object itself appears in parameter position.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TransactionRuntime", + "member": "reviveNode", + "kind": "required-member-added", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It gains `reviveNode` so reopening a provenance fact's currency revives the node through the same owner that folds it back into its identity class. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TransactionRuntime", + "member": "runNodeOperationHooks", + "kind": "member-removed", + "reason": "`TransactionRuntime` is the `@internal`, symbol-keyed transaction runtime port; it is not exported from any entrypoint, so no external consumer can name or author one. It loses `runNodeOperationHooks`, whose only caller, the provenance fact close and reopen path, now runs through `deleteNodeWithPolicy` and `reviveNode` under the transaction's buffered hooks. The checker reaches it contravariantly only because the transaction object itself appears in parameter position. The behavior is declared in .changeset/identity-write-path-consistency.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TraversalBuilder", + "member": "toKindSet", + "kind": "required-member-added", + "reason": "`parts()` / `wholes()` are new navigation methods on the library-produced query builder, and `TraversalBuilder` gains the `toKindSet` the composition traversal resolves against. A consumer calls the builder `store.query()` returns and never implements one, and `TraversalBuilder` is not exported from any entrypoint. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassAncestors", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "subClassDescendants", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "broaderClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "narrowerClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "equivalenceSets", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "iriToKind", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "relatedKinds", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "disjointPairs", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "partOfClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "hasPartClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeInverses", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplicationsClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry.constructor(closures)", + "member": "edgeImplyingClosure", + "kind": "member-removed", + "reason": "the `KindRegistry` constructor's inline closures object literal is extracted, member for member, into the named `RegistryClosures` type — the checker reports the same members once as removed from the inline literal and once as added to the new named type, with nothing gained or lost between them. Neither `KindRegistry` nor `RegistryClosures` is exported from any entrypoint, and `buildKindRegistry` is the only producer of both.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilder.from(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "QueryBuilder.fromDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TraversalBuilder.to(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TraversalBuilder.toDynamic(options)", + "member": "includeSubClasses", + "kind": "member-removed", + "reason": "the two booleans `includeSubClasses` / `includeNarrower` are replaced by one `expansion: \"exact\" | \"subclasses\" | \"narrower\"` option on `from()`/`to()`/`fromDynamic()`/`toDynamic()`, so an alias states exactly one expansion axis and can no longer name a contradiction. A caller passes `{ expansion: \"exact\" }` where it passed `{ includeSubClasses: false }` and `{ expansion: \"subclasses\" }` where it passed `{ includeSubClasses: true }`; omitting the argument, `{}` and an explicit `undefined` all still mean \"take the store default\". Declared in .changeset/typed-subsumption.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "PostgresTableNames", + "member": "identityTransitions", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`, and a working copy allocates a physical name for each. This entrypoint reaches `PostgresTableNames` only as the names the working-copy manager hands to a caller's `connect` callback and as the `Partial` a caller may pass for `sourceTableNames`: the library is the sole producer of the complete record, so nothing a consumer authors changes. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "PostgresTableNames", + "member": "identityTransitionRetention", + "kind": "required-member-added", + "reason": "the identity transition log adds two relations, `typegraph_identity_transitions` and `typegraph_identity_transition_retention`, and a working copy allocates a physical name for each. This entrypoint reaches `PostgresTableNames` only as the names the working-copy manager hands to a caller's `connect` callback and as the `Partial` a caller may pass for `sourceTableNames`: the library is the sole producer of the complete record, so nothing a consumer authors changes. Declared in .changeset/identity-reconciliation.md under \"Breaking changes\".", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionEdgePopulation", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the `compositionEdgePopulation` reader, the population a realizing edge kind's own pair declares, which the acyclicity relation and a move's retirement read. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionPartKindsUnderVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "KindRegistry", + "member": "compositionWholeKindsOverVia", + "kind": "required-member-added", + "reason": "`KindRegistry` is built by the library (`buildKindRegistry`) and is not exported from any entrypoint, so no external consumer implements or constructs one; it gains the by-`via` narrowing of the composition closure readers that `parts()` / `wholes()` with a `via` option resolve their target kinds from. Declared in .changeset/composition.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsWithoutSearchableField", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsWithoutSearchableField` replaces `hasSearchableField` so a `$fulltext.matches()` refusal names the kinds that leave an alias without the searchable field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SchemaIntrospector", + "member": "kindsDivergingFromSubsumingDeclaration", + "kind": "required-member-added", + "reason": "`SchemaIntrospector` is built by the library (`schemaIntrospectorFor` / `createSchemaIntrospector`) for the query builder and the `store.search()` facade and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `kindsDivergingFromSubsumingDeclaration` names the kinds whose `searchable()` / `embedding()` tag differs from the subsuming kind's declaration, so a vector expansion refuses them instead of silently skipping their unindexed vectors. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "SchemaIntrospector", + "member": "hasSearchableField", + "kind": "member-removed", + "reason": "`SchemaIntrospector` is built by the library (`createSchemaIntrospector`) for the query builder and is not exported from any entrypoint, so no external consumer implements, constructs or reads one; `hasSearchableField` is replaced by `kindsWithoutSearchableField`, which answers the same question and names the kinds lacking the field. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "applyImportedNodeWindowEnd", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreRuntime", + "member": "readIdentityRestoreBaselineAtTarget", + "kind": "required-member-added", + "reason": "`StoreRuntime` is the `@internal`, symbol-keyed store runtime port documented as absent from the public `Store` contract; it is not exported from any entrypoint, so no external consumer can name or author one. The checker reaches it contravariantly only because `Store` itself appears in parameter position on public functions such as `applyMergePlan`, which over-approximates a hand-authored `Store`-shaped value.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "StoreCore", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`StoreCore` is the member list of the `Store` the library constructs (`createStore`, `createStoreWithSchema`, the adapter factories); consumers receive a store and never author one, so an added method is additive (the checker reaches it only because `Store` appears in parameter position on public functions such as `applyMergePlan`). `getNodeByRef` resolves a `{ kind, id }` reference, such as a widened edge endpoint, through the node collection lookup. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "TransactionCollections", + "member": "getNodeByRef", + "kind": "required-member-added", + "reason": "`TransactionCollections` is part of the transaction context the library hands to a `store.transaction` callback; consumers receive it and never author one, so an added method is additive. `tx.getNodeByRef` mirrors `store.getNodeByRef` through the transaction's own collections. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-adapters-drizzle-postgres-working-copy.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-graph-merge.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-interchange.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-postgres-pglite.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-profiler.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-provenance.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph-sqlite-local.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, + { + "entrypoint": "typegraph.api.md", + "declaration": "PolymorphicNodeType", + "member": "kind", + "kind": "required-member-added", + "reason": "`PolymorphicNodeType` is the node type a subsumption-affected reference is branded with (`SubsumedNodeReference`), so it is reached only as the type argument of a `NodeId` brand inside `GraphNodeReferenceInput`. `BulkFindEdgesFromResult.source` / `BulkFindEdgesToResult.target` now echo the caller's reference as that input type, which makes the checker reach it at input polarity; no consumer authors a `PolymorphicNodeType` value (they author `{ kind, id }` references whose brand the library computes), and `kind` is a member every `NodeType` already carries. Declared in .changeset/typed-subsumption.md.", + "issue": "#637" + }, { "entrypoint": "typegraph-adapters-drizzle-engine.api.md", "declaration": "CommonOperationStrategy", diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-engine.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-engine.api.md index 5a9345c24..b5a3d4481 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-engine.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-engine.api.md @@ -277,15 +277,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -464,7 +464,7 @@ type CommonOperationStrategy = Readonly<{ buildHardDeleteEdge: (params: HardDeleteEdgeParams) => SQL; buildHardDeleteEdgesBatch: (params: DeleteEdgesBatchParams) => SQL; buildHardDeleteEdgesByNode: (graphId: string, nodeKind: string, nodeId: string) => SQL; - buildCountEdgesFrom: (params: CountEdgesFromParams) => SQL; + buildCountEdgesAtEndpoint: (params: CountEdgesAtEndpointParams) => SQL; buildEdgeExistsBetween: (params: EdgeExistsBetweenParams) => SQL; buildFindEdgesConnectedTo: (params: FindEdgesConnectedToParams) => SQL; buildFindNodesByKind: (params: FindNodesByKindParams) => SQL; @@ -498,15 +498,18 @@ type CommonOperationStrategy = Readonly<{ buildCheckUnique: (params: CheckUniqueParams) => SQL; buildCheckUniqueBatch: (params: CheckUniqueBatchParams) => SQL; buildLockEdgeClaims: (entries: readonly ClaimEdgeCardinalityParams[], timestamp: string) => SQL; + buildReadEdgeClaimIncumbents: (entries: readonly ClaimEdgeCardinalityParams[]) => readonly SQL[]; buildLockEdgeClaimGuarded: (params: ClaimEdgeCardinalityParams, timestamp: string) => SQL; buildTakeOverEdgeClaim: (params: ClaimEdgeCardinalityParams, timestamp: string) => SQL; buildTakeOverEdgeClaimGuarded: (params: ClaimEdgeCardinalityParams, timestamp: string) => SQL; buildPurgeEdgeClaims: (params: PurgeEdgeClaimsParams) => SQL; buildContendedUniqueRowAudit: (graphId: string, constraintNames: readonly string[]) => SQL; - buildContendedEdgeRowAudit: (graphId: string, cardinality: ConstrainedCardinality, edgeKinds: readonly string[]) => SQL; + buildContendedEdgeRowAudit: (graphId: string, ref: EdgeCardinalityAxisRef, edgeKinds: readonly string[]) => SQL; + buildContendedCompositionEdgeRowAudit: (graphId: string, ref: EdgeCardinalityAxisRef, holders: CompositionClaimScope["holders"], reportedEdgeKinds: readonly string[]) => SQL; buildDisjointOverlapAudit: (graphId: string, kinds: readonly [string, string]) => SQL; + buildMisassignedEdgeEndpointAudit: (graphId: string, edgeKind: string, allowedPairs: readonly (readonly [string, string])[]) => SQL; buildGetActiveSchema: (graphId: string) => SQL; - buildLockSchemaVersionAndGraphWrite?: (params: SchemaWriteFenceParams, advisoryLockNamespace: string, fenceSql: FenceSql) => SQL; + buildLockSchemaVersionAndGraphWrite?: (params: SchemaGraphWriteFenceParams, advisoryLockNamespace: string, fenceSql: FenceSql) => SQL; buildInsertSchema: (params: InsertSchemaParams, timestamp: string) => SQL; buildGetSchemaVersion: (graphId: string, version: number) => SQL; buildSetActiveSchema: (graphId: string, version: number) => Readonly<{ @@ -554,25 +557,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + // @public type ConstrainedCardinality = Exclude; +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -719,6 +748,16 @@ type CopyGraphTemplateContributionMarkersSqlParams = Readonly<{ templateSchemaHash: string; }>; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -730,15 +769,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -748,6 +779,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public (undocumented) @@ -765,6 +797,8 @@ type CreateBaseSchemaMembersDeps = Readonly<{ revisionChangesTableDdl?: string; revisionChangesIndexDdl?: readonly string[]; graphIdOrderIndexDdl?: readonly string[]; + identityTransitionsTableDdl?: readonly string[]; + identityTransitionRetentionTableDdl?: string; }>; // @public (undocumented) @@ -2906,7 +2940,7 @@ function createPostgresTables(names?: Partial, options?: Cre }; dialect: "pg"; }>; - readonly uniques: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityTransitions: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -2927,8 +2961,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - nodeKind: drizzle_orm_pg_core.PgColumn<{ - name: "node_kind"; + transitionId: drizzle_orm_pg_core.PgColumn<{ + name: "transition_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2944,59 +2978,59 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - constraintName: drizzle_orm_pg_core.PgColumn<{ - name: "constraint_name"; + recordedRevision: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + recordedAt: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - nodeId: drizzle_orm_pg_core.PgColumn<{ - name: "node_id"; + validAt: drizzle_orm_pg_core.PgColumn<{ + name: "valid_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - concreteKind: drizzle_orm_pg_core.PgColumn<{ - name: "concrete_kind"; + cause: drizzle_orm_pg_core.PgColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3012,32 +3046,25 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - deletedAt: drizzle_orm_pg_core.PgColumn<{ - name: "deleted_at"; + classKind: drizzle_orm_pg_core.PgColumn<{ + name: "class_kind"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly edgeClaims: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + classId: drizzle_orm_pg_core.PgColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3053,14 +3080,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - axis: drizzle_orm_pg_core.PgColumn<{ - name: "axis"; + priorClassKind: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3070,14 +3097,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + priorClassId: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3087,31 +3114,31 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - edgeId: drizzle_orm_pg_core.PgColumn<{ - name: "edge_id"; + assertionIds: drizzle_orm_pg_core.PgColumn<{ + name: "assertion_ids"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + decision: drizzle_orm_pg_core.PgColumn<{ + name: "decision"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: true; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3121,21 +3148,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly fences: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + txId: drizzle_orm_pg_core.PgColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3145,14 +3165,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - generation: drizzle_orm_pg_core.PgColumn<{ - name: "generation"; + restoredAt: drizzle_orm_pg_core.PgColumn<{ + name: "restored_at"; tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; - notNull: true; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3165,32 +3185,32 @@ function createPostgresTables(names?: Partial, options?: Cre }; dialect: "pg"; }>; - readonly baseSchemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { - installation: drizzle_orm_pg_core.PgColumn<{ - name: "installation"; + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - version: drizzle_orm_pg_core.PgColumn<{ - name: "version"; + prunedBeforeRevision: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_before_revision"; tableName: string; dataType: "number"; - columnType: "PgInteger"; + columnType: "PgBigInt53"; data: number; driverParam: string | number; notNull: true; @@ -3203,8 +3223,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + prunedAt: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -3223,7 +3243,7 @@ function createPostgresTables(names?: Partial, options?: Cre }; dialect: "pg"; }>; - readonly schemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly uniques: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -3244,25 +3264,25 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - version: drizzle_orm_pg_core.PgColumn<{ - name: "version"; + nodeKind: drizzle_orm_pg_core.PgColumn<{ + name: "node_kind"; tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - schemaHash: drizzle_orm_pg_core.PgColumn<{ - name: "schema_hash"; + constraintName: drizzle_orm_pg_core.PgColumn<{ + name: "constraint_name"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3278,49 +3298,66 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - schemaDoc: drizzle_orm_pg_core.PgColumn<{ - name: "schema_doc"; + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; tableName: string; - dataType: "json"; - columnType: "PgJsonb"; - data: unknown; - driverParam: unknown; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - createdAt: drizzle_orm_pg_core.PgColumn<{ - name: "created_at"; + nodeId: drizzle_orm_pg_core.PgColumn<{ + name: "node_id"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - isActive: drizzle_orm_pg_core.PgColumn<{ - name: "is_active"; + concreteKind: drizzle_orm_pg_core.PgColumn<{ + name: "concrete_kind"; tableName: string; - dataType: "boolean"; - columnType: "PgBoolean"; - data: boolean; - driverParam: boolean; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; - hasDefault: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + deletedAt: drizzle_orm_pg_core.PgColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; @@ -3332,12 +3369,12 @@ function createPostgresTables(names?: Partial, options?: Cre }; dialect: "pg"; }>; - readonly graphTemplates: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly edgeClaims: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { - templateId: drizzle_orm_pg_core.PgColumn<{ - name: "template_id"; + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3353,8 +3390,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - schemaHash: drizzle_orm_pg_core.PgColumn<{ - name: "schema_hash"; + axis: drizzle_orm_pg_core.PgColumn<{ + name: "axis"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3370,49 +3407,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - schemaDoc: drizzle_orm_pg_core.PgColumn<{ - name: "schema_doc"; - tableName: string; - dataType: "json"; - columnType: "PgJsonb"; - data: unknown; - driverParam: unknown; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - createdAt: drizzle_orm_pg_core.PgColumn<{ - name: "created_at"; - tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly indexMaterializations: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - indexName: drizzle_orm_pg_core.PgColumn<{ - name: "index_name"; + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3428,8 +3424,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + edgeId: drizzle_orm_pg_core.PgColumn<{ + name: "edge_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3445,25 +3441,32 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - entity: drizzle_orm_pg_core.PgColumn<{ - name: "entity"; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - kind: drizzle_orm_pg_core.PgColumn<{ - name: "kind"; + }; + dialect: "pg"; + }>; + readonly fences: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3479,25 +3482,32 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - signature: drizzle_orm_pg_core.PgColumn<{ - name: "signature"; + generation: drizzle_orm_pg_core.PgColumn<{ + name: "generation"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - schemaVersion: drizzle_orm_pg_core.PgColumn<{ - name: "schema_version"; + }; + dialect: "pg"; + }>; + readonly baseSchemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + installation: drizzle_orm_pg_core.PgColumn<{ + name: "installation"; tableName: string; dataType: "number"; columnType: "PgInteger"; @@ -3513,14 +3523,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - materializedAt: drizzle_orm_pg_core.PgColumn<{ - name: "materialized_at"; + version: drizzle_orm_pg_core.PgColumn<{ + name: "version"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: false; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3530,8 +3540,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - lastAttemptedAt: drizzle_orm_pg_core.PgColumn<{ - name: "last_attempted_at"; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -3547,14 +3557,21 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - lastError: drizzle_orm_pg_core.PgColumn<{ - name: "last_error"; + }; + dialect: "pg"; + }>; + readonly schemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3564,14 +3581,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - buildingSince: drizzle_orm_pg_core.PgColumn<{ - name: "building_since"; + version: drizzle_orm_pg_core.PgColumn<{ + name: "version"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: false; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3581,14 +3598,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - claimToken: drizzle_orm_pg_core.PgColumn<{ - name: "claim_token"; + schemaHash: drizzle_orm_pg_core.PgColumn<{ + name: "schema_hash"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3598,66 +3615,66 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly contributionMaterializations: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + schemaDoc: drizzle_orm_pg_core.PgColumn<{ + name: "schema_doc"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - logicalName: drizzle_orm_pg_core.PgColumn<{ - name: "logical_name"; + createdAt: drizzle_orm_pg_core.PgColumn<{ + name: "created_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - owner: drizzle_orm_pg_core.PgColumn<{ - name: "owner"; + isActive: drizzle_orm_pg_core.PgColumn<{ + name: "is_active"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "boolean"; + columnType: "PgBoolean"; + data: boolean; + driverParam: boolean; notNull: true; - hasDefault: false; + hasDefault: true; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - tableName: drizzle_orm_pg_core.PgColumn<{ - name: "table_name"; + }; + dialect: "pg"; + }>; + readonly graphTemplates: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + templateId: drizzle_orm_pg_core.PgColumn<{ + name: "template_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3673,8 +3690,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - signature: drizzle_orm_pg_core.PgColumn<{ - name: "signature"; + schemaHash: drizzle_orm_pg_core.PgColumn<{ + name: "schema_hash"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3690,14 +3707,14 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - materializedAt: drizzle_orm_pg_core.PgColumn<{ - name: "materialized_at"; + schemaDoc: drizzle_orm_pg_core.PgColumn<{ + name: "schema_doc"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: false; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3707,8 +3724,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - lastAttemptedAt: drizzle_orm_pg_core.PgColumn<{ - name: "last_attempted_at"; + createdAt: drizzle_orm_pg_core.PgColumn<{ + name: "created_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -3724,14 +3741,21 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - lastError: drizzle_orm_pg_core.PgColumn<{ - name: "last_error"; + }; + dialect: "pg"; + }>; + readonly indexMaterializations: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + indexName: drizzle_orm_pg_core.PgColumn<{ + name: "index_name"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3741,13 +3765,6 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly kindRemovals: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { graphId: drizzle_orm_pg_core.PgColumn<{ name: "graph_id"; tableName: string; @@ -3765,8 +3782,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - kindName: drizzle_orm_pg_core.PgColumn<{ - name: "kind_name"; + entity: drizzle_orm_pg_core.PgColumn<{ + name: "entity"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3782,8 +3799,25 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - entity: drizzle_orm_pg_core.PgColumn<{ - name: "entity"; + kind: drizzle_orm_pg_core.PgColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + signature: drizzle_orm_pg_core.PgColumn<{ + name: "signature"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3816,8 +3850,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - removedAt: drizzle_orm_pg_core.PgColumn<{ - name: "removed_at"; + materializedAt: drizzle_orm_pg_core.PgColumn<{ + name: "materialized_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -3867,10 +3901,44 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; + buildingSince: drizzle_orm_pg_core.PgColumn<{ + name: "building_since"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + claimToken: drizzle_orm_pg_core.PgColumn<{ + name: "claim_token"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; }; dialect: "pg"; }>; - readonly reconciliationMarkers: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly contributionMaterializations: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -3891,32 +3959,25 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - reconciledToVersion: drizzle_orm_pg_core.PgColumn<{ - name: "reconciled_to_version"; + logicalName: drizzle_orm_pg_core.PgColumn<{ + name: "logical_name"; tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly fulltext: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + owner: drizzle_orm_pg_core.PgColumn<{ + name: "owner"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3932,8 +3993,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - nodeKind: drizzle_orm_pg_core.PgColumn<{ - name: "node_kind"; + tableName: drizzle_orm_pg_core.PgColumn<{ + name: "table_name"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3949,8 +4010,8 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - nodeId: drizzle_orm_pg_core.PgColumn<{ - name: "node_id"; + signature: drizzle_orm_pg_core.PgColumn<{ + name: "signature"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3966,8 +4027,66 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - content: drizzle_orm_pg_core.PgColumn<{ - name: "content"; + materializedAt: drizzle_orm_pg_core.PgColumn<{ + name: "materialized_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lastAttemptedAt: drizzle_orm_pg_core.PgColumn<{ + name: "last_attempted_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lastError: drizzle_orm_pg_core.PgColumn<{ + name: "last_error"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly kindRemovals: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3983,11 +4102,11 @@ function createPostgresTables(names?: Partial, options?: Cre identity: undefined; generated: undefined; }, {}, {}>; - language: drizzle_orm_pg_core.PgColumn<{ - name: "language"; + kindName: drizzle_orm_pg_core.PgColumn<{ + name: "kind_name"; tableName: string; - dataType: "custom"; - columnType: "PgCustomColumn"; + dataType: "string"; + columnType: "PgText"; data: string; driverParam: string; notNull: true; @@ -3995,36 +4114,64 @@ function createPostgresTables(names?: Partial, options?: Cre isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - pgColumnBuilderBrand: "PgCustomColumnBuilderBrand"; - }>; - tsv: drizzle_orm_pg_core.PgColumn<{ - name: "tsv"; + }, {}, {}>; + entity: drizzle_orm_pg_core.PgColumn<{ + name: "entity"; tableName: string; - dataType: "custom"; - columnType: "PgCustomColumn"; + dataType: "string"; + columnType: "PgText"; data: string; driverParam: string; notNull: true; - hasDefault: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaVersion: drizzle_orm_pg_core.PgColumn<{ + name: "schema_version"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; enumValues: undefined; baseColumn: never; identity: undefined; - generated: { - type: "always"; - }; - }, {}, { - pgColumnBuilderBrand: "PgCustomColumnBuilderBrand"; - }>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + generated: undefined; + }, {}, {}>; + removedAt: drizzle_orm_pg_core.PgColumn<{ + name: "removed_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lastAttemptedAt: drizzle_orm_pg_core.PgColumn<{ + name: "last_attempted_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -4035,34 +4182,514 @@ function createPostgresTables(names?: Partial, options?: Cre isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + lastError: drizzle_orm_pg_core.PgColumn<{ + name: "last_error"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly reconciliationMarkers: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + reconciledToVersion: drizzle_orm_pg_core.PgColumn<{ + name: "reconciled_to_version"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly fulltext: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + nodeKind: drizzle_orm_pg_core.PgColumn<{ + name: "node_kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + nodeId: drizzle_orm_pg_core.PgColumn<{ + name: "node_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + content: drizzle_orm_pg_core.PgColumn<{ + name: "content"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + language: drizzle_orm_pg_core.PgColumn<{ + name: "language"; + tableName: string; + dataType: "custom"; + columnType: "PgCustomColumn"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + pgColumnBuilderBrand: "PgCustomColumnBuilderBrand"; + }>; + tsv: drizzle_orm_pg_core.PgColumn<{ + name: "tsv"; + tableName: string; + dataType: "custom"; + columnType: "PgCustomColumn"; + data: string; + driverParam: string; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: { + type: "always"; + }; + }, {}, { + pgColumnBuilderBrand: "PgCustomColumnBuilderBrand"; + }>; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly fulltextTableName: string; +}; + +// @public (undocumented) +type CreatePostgresTablesOptions = Readonly<{ + indexes?: readonly IndexDeclaration[] | undefined; + physicalIndexName?: ((index: RelationalIndexDeclaration) => string) | undefined; +}>; + +// @public +export function createSqlBackend(profile: SqlEngineProfile): AdapterBackend; + +// @public +function createSqliteTables(names?: Partial, options?: CreateSqliteTablesOptions): { + readonly nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + props: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "props"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly edges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly fulltextTableName: string; -}; - -// @public (undocumented) -type CreatePostgresTablesOptions = Readonly<{ - indexes?: readonly IndexDeclaration[] | undefined; - physicalIndexName?: ((index: RelationalIndexDeclaration) => string) | undefined; -}>; - -// @public -export function createSqlBackend(profile: SqlEngineProfile): AdapterBackend; - -// @public -function createSqliteTables(names?: Partial, options?: CreateSqliteTablesOptions): { - readonly nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + }, {}, { + length: number | undefined; + }>; + toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4080,8 +4707,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + toId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4099,8 +4726,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + props: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "props"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4118,14 +4745,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - props: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "props"; + matchIdentityName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "match_identity_name"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -4137,23 +4764,25 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; + matchIdentityKey: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "match_identity_key"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: true; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; + }, {}, { + length: number | undefined; + }>; validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "valid_from"; tableName: string; @@ -4252,12 +4881,12 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; - readonly edges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4275,8 +4904,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4313,8 +4942,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_kind"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4332,8 +4961,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_id"; + props: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "props"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4351,14 +4980,31 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_kind"; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -4370,8 +5016,27 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - toId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_id"; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4389,8 +5054,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - props: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "props"; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4408,8 +5073,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - matchIdentityName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "match_identity_name"; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4427,14 +5092,48 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - matchIdentityKey: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "match_identity_key"; + recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_from"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_to"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + op: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "op"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -4446,8 +5145,25 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4465,8 +5181,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + meta: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "meta"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4484,8 +5200,15 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + }; + dialect: "sqlite"; + }>; + readonly recordedEdges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4503,8 +5226,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4522,14 +5245,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -4541,15 +5264,27 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly recordedNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4567,8 +5302,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4586,8 +5321,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4605,8 +5340,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + toId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4643,23 +5378,6 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "valid_from"; tableName: string; @@ -4866,29 +5584,10 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; - readonly recordedEdges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedClock: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; - tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, { - length: number | undefined; - }>; graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "graph_id"; tableName: string; @@ -4908,27 +5607,25 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + revision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4946,8 +5643,15 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_kind"; + }; + dialect: "sqlite"; + }>; + readonly revisionOrigins: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4965,8 +5669,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_id"; + origin: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "origin"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4984,8 +5688,15 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_kind"; + }; + dialect: "sqlite"; + }>; + readonly revisionChanges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + entryId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entry_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -4993,7 +5704,7 @@ function createSqliteTables(names?: Partial, options?: CreateS driverParam: string; notNull: true; hasDefault: false; - isPrimaryKey: false; + isPrimaryKey: true; isAutoincrement: false; hasRuntimeDefault: false; enumValues: [string, ...string[]]; @@ -5003,8 +5714,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - toId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_id"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5022,84 +5733,42 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - props: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "props"; + revision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, { - length: number | undefined; - }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; - tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, { - length: number | undefined; - }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; - tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + }, {}, {}>; + complete: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "complete"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + }, {}, {}>; + entity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entity"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5117,14 +5786,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5136,42 +5805,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_from"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_to"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - op: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "op"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5189,31 +5824,21 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - txId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "tx_id"; + }; + dialect: "sqlite"; + }>; + readonly identityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5225,14 +5850,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - meta: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "meta"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5244,15 +5869,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly recordedClock: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + relation: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "rel"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5270,25 +5888,27 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - revision: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "revision"; + aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_kind"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_at"; + }, {}, { + length: number | undefined; + }>; + aId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5306,15 +5926,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly revisionOrigins: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5332,8 +5945,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - origin: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "origin"; + bId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5351,15 +5964,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly revisionChanges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - entryId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entry_id"; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5367,7 +5973,7 @@ function createSqliteTables(names?: Partial, options?: CreateS driverParam: string; notNull: true; hasDefault: false; - isPrimaryKey: true; + isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; enumValues: [string, ...string[]]; @@ -5377,14 +5983,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5396,48 +6002,52 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - revision: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "revision"; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - complete: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "complete"; + }, {}, { + length: number | undefined; + }>; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; - dataType: "boolean"; - columnType: "SQLiteBoolean"; - data: boolean; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - entity: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entity"; + }, {}, { + length: number | undefined; + }>; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5449,14 +6059,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5468,14 +6078,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5490,10 +6100,29 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; - readonly identityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedIdentityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "graph_id"; tableName: string; @@ -5715,15 +6344,123 @@ function createSqliteTables(names?: Partial, options?: CreateS isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_from"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_to"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + op: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "op"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_kind"; + }, {}, {}>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5741,8 +6478,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_id"; + meta: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "meta"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5763,29 +6500,10 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; - readonly recordedIdentityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityClosure: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; - tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, { - length: number | undefined; - }>; graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "graph_id"; tableName: string; @@ -5805,8 +6523,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + memberKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "member_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5824,8 +6542,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - relation: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "rel"; + memberId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "member_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5843,8 +6561,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_kind"; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5862,8 +6580,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - aId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_id"; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5881,8 +6599,15 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_kind"; + }; + dialect: "sqlite"; + }>; + readonly identitySeparation: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5900,8 +6625,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - bId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_id"; + classKeyLow: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_key_low"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5919,8 +6644,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + classKeyHigh: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_key_high"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5938,14 +6663,21 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + }; + dialect: "sqlite"; + }>; + readonly identityTransitions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -5957,8 +6689,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + transitionId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "transition_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -5976,33 +6708,31 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + recordedRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6014,14 +6744,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_kind"; + validAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6033,14 +6763,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_id"; + cause: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6052,42 +6782,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_from"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_to"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - op: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "op"; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -6105,31 +6801,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - txId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "tx_id"; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6141,8 +6820,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - meta: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "meta"; + priorClassKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -6160,21 +6839,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly identityClosure: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + priorClassId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6186,8 +6858,8 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - memberKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "member_kind"; + assertionIds: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "assertion_ids"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -6205,14 +6877,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - memberId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "member_id"; + decision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "decision"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6224,14 +6896,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_kind"; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6243,14 +6915,14 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - classId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_id"; + restoredAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "restored_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -6265,7 +6937,7 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; - readonly identitySeparation: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -6288,27 +6960,25 @@ function createSqliteTables(names?: Partial, options?: CreateS }, {}, { length: number | undefined; }>; - classKeyLow: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_key_low"; + prunedBeforeRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_before_revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - classKeyHigh: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_key_high"; + }, {}, {}>; + prunedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7512,9 +8182,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -7588,11 +8267,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -7717,6 +8402,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -7794,7 +8482,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -7847,6 +8535,7 @@ type FenceSql = Readonly<{ export type FenceStatements = FenceSql & Readonly<{ acquireKeyed: (namespace: string, key: string | number) => SqlFragment; acquireKeyedWithIsolation: (namespace: string, key: string | number) => SqlFragment; + acquireKeyedSnapshotFence?: ((namespace: string, key: string | number) => SqlFragment) | undefined; isolationFact: () => SqlFragment; }>; @@ -8054,7 +8743,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -8099,9 +8788,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -8298,6 +8988,12 @@ type GraphTemplateTableNames = Readonly<{ contributionMaterializations: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -8391,6 +9087,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -8509,9 +9207,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -8586,8 +9281,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -8598,6 +9297,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -8669,7 +9369,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -8693,6 +9393,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -8847,6 +9557,8 @@ type PostgresTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -8940,6 +9652,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -9070,6 +9783,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -9086,6 +9801,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -9149,11 +9870,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -9161,11 +9884,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -9191,7 +9909,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedResourceDeclaration = Readonly<{ @@ -9336,6 +10054,8 @@ type SqliteTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -9381,6 +10101,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -9435,6 +10157,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-pglite.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-pglite.api.md index 96ee14efb..34c944529 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-pglite.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-pglite.api.md @@ -127,15 +127,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -203,22 +203,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -327,6 +356,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -338,15 +377,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -356,6 +387,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -2429,6 +2461,309 @@ function createPostgresTables(names?: Partial, options?: Cre }; dialect: "pg"; }>; + readonly identityTransitions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + transitionId: drizzle_orm_pg_core.PgColumn<{ + name: "transition_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedRevision: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_revision"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedAt: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + validAt: drizzle_orm_pg_core.PgColumn<{ + name: "valid_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + cause: drizzle_orm_pg_core.PgColumn<{ + name: "cause"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + classKind: drizzle_orm_pg_core.PgColumn<{ + name: "class_kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + classId: drizzle_orm_pg_core.PgColumn<{ + name: "class_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priorClassKind: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + priorClassId: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + assertionIds: drizzle_orm_pg_core.PgColumn<{ + name: "assertion_ids"; + tableName: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + decision: drizzle_orm_pg_core.PgColumn<{ + name: "decision"; + tableName: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + txId: drizzle_orm_pg_core.PgColumn<{ + name: "tx_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + restoredAt: drizzle_orm_pg_core.PgColumn<{ + name: "restored_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly identityTransitionRetention: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + prunedBeforeRevision: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_before_revision"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + prunedAt: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; readonly uniques: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; @@ -3675,9 +4010,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -3751,11 +4095,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -3855,6 +4205,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -3932,7 +4285,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -4185,7 +4538,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4230,9 +4583,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4410,6 +4764,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -4500,6 +4860,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -4608,9 +4970,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -4665,8 +5024,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -4677,6 +5040,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -4754,7 +5118,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -4778,6 +5142,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -4920,6 +5294,8 @@ type PostgresTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -4957,6 +5333,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5059,6 +5436,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -5122,11 +5505,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5134,11 +5519,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5164,7 +5544,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5258,6 +5638,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5312,6 +5694,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-working-copy.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-working-copy.api.md index c3dbc96db..fcac397a8 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-working-copy.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres-working-copy.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -395,8 +420,8 @@ type BatchOnceOptions = Readonly<{ // @public type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -428,16 +453,17 @@ type BranchId = string & Readonly<{ readonly __brand: "BranchId"; }>; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -447,7 +473,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -459,7 +485,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -620,15 +646,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -804,6 +830,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -812,6 +947,12 @@ type ConditionalExpressionNode = Readonly<{ otherwise: DatabaseExpression; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -821,13 +962,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -836,14 +998,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -967,11 +1129,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -979,11 +1151,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -995,15 +1177,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1013,6 +1187,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1035,7 +1210,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1086,13 +1261,25 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1198,6 +1385,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1471,6 +1669,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1485,9 +1690,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1503,18 +1708,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1522,18 +1727,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1558,37 +1772,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1645,9 +1859,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1682,17 +1896,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1708,9 +1931,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1721,13 +1944,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1761,6 +1987,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1769,12 +1998,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1817,8 +2060,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1849,6 +2094,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1906,6 +2154,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1984,6 +2242,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1992,6 +2254,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2352,6 +2619,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2450,7 +2720,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2904,7 +3174,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2949,9 +3219,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -3114,12 +3385,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3151,9 +3422,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3162,6 +3438,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3170,6 +3449,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3364,6 +3652,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3374,6 +3671,36 @@ type IdentityFacade = IdentityReadFacade & Readonly<{ retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -3408,6 +3735,34 @@ type IdentityReadFacade = Readonly<{ // @public type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3420,12 +3775,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3446,6 +3851,7 @@ type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3569,9 +3975,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InitialQueryBuilder = QueryBuilder; @@ -3662,7 +4065,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3677,6 +4080,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -3720,8 +4131,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3732,6 +4147,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3761,6 +4177,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -3771,25 +4190,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -3800,6 +4219,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -3831,6 +4251,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -3897,6 +4320,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -3930,7 +4359,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3956,6 +4385,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -4043,14 +4475,19 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -4093,7 +4530,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4153,12 +4590,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4181,19 +4614,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4202,12 +4637,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4232,10 +4664,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4265,9 +4697,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4275,8 +4733,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4388,6 +4847,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4395,6 +4877,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4553,10 +5046,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4564,6 +5085,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4572,16 +5096,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; // @public -type OperationHookContext = HookContext & Readonly<{ +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; + +// @public +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4614,7 +5146,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -4685,6 +5217,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -4706,6 +5244,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -4724,6 +5267,8 @@ export type PostgresTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -4823,6 +5368,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -4940,21 +5495,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -4984,6 +5557,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -5010,6 +5584,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -5018,6 +5593,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -5029,8 +5605,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -5074,7 +5651,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -5101,6 +5678,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5171,6 +5749,19 @@ type RecordedForkPoint = Readonly<{ base: BaseVersion; }>; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5222,13 +5813,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5356,6 +5947,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5497,6 +6105,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5511,7 +6121,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5522,6 +6132,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public type RowProps = string | Readonly>; @@ -5581,6 +6204,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5601,6 +6225,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5631,7 +6260,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5668,11 +6298,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5758,11 +6393,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5770,11 +6407,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5800,7 +6432,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5879,6 +6511,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -5943,6 +6581,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -5971,6 +6613,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -5988,6 +6632,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -6004,6 +6650,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -6039,6 +6690,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -6065,12 +6717,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6158,6 +6810,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6166,11 +6819,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6325,6 +6983,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6346,6 +7013,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6380,7 +7064,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6393,10 +7077,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6465,33 +7161,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6558,7 +7254,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6598,6 +7294,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6631,13 +7332,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6651,13 +7353,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -6693,6 +7444,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -6759,6 +7513,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -6769,9 +7524,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -6787,7 +7542,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -6797,15 +7552,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -6819,34 +7590,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -6866,13 +7652,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -7115,6 +7901,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7240,6 +8032,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7261,6 +8054,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres.api.md index 557690ff2..a3fe6cf8b 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-postgres.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-postgres.api.md @@ -131,15 +131,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -207,22 +207,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -331,6 +360,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -342,15 +381,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -360,6 +391,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -2433,7 +2465,7 @@ export function createPostgresTables(names?: Partial, option }; dialect: "pg"; }>; - readonly uniques: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityTransitions: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -2454,8 +2486,8 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - nodeKind: drizzle_orm_pg_core.PgColumn<{ - name: "node_kind"; + transitionId: drizzle_orm_pg_core.PgColumn<{ + name: "transition_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2471,59 +2503,59 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - constraintName: drizzle_orm_pg_core.PgColumn<{ - name: "constraint_name"; + recordedRevision: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + recordedAt: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - nodeId: drizzle_orm_pg_core.PgColumn<{ - name: "node_id"; + validAt: drizzle_orm_pg_core.PgColumn<{ + name: "valid_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - concreteKind: drizzle_orm_pg_core.PgColumn<{ - name: "concrete_kind"; + cause: drizzle_orm_pg_core.PgColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2539,32 +2571,25 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - deletedAt: drizzle_orm_pg_core.PgColumn<{ - name: "deleted_at"; + classKind: drizzle_orm_pg_core.PgColumn<{ + name: "class_kind"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly edgeClaims: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + classId: drizzle_orm_pg_core.PgColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2580,14 +2605,14 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - axis: drizzle_orm_pg_core.PgColumn<{ - name: "axis"; + priorClassKind: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2597,14 +2622,14 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + priorClassId: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2614,31 +2639,31 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - edgeId: drizzle_orm_pg_core.PgColumn<{ - name: "edge_id"; + assertionIds: drizzle_orm_pg_core.PgColumn<{ + name: "assertion_ids"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + decision: drizzle_orm_pg_core.PgColumn<{ + name: "decision"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; - notNull: true; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2648,21 +2673,14 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly fences: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - key: drizzle_orm_pg_core.PgColumn<{ - name: "key"; + txId: drizzle_orm_pg_core.PgColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2672,14 +2690,14 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - generation: drizzle_orm_pg_core.PgColumn<{ - name: "generation"; + restoredAt: drizzle_orm_pg_core.PgColumn<{ + name: "restored_at"; tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; - notNull: true; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2692,32 +2710,32 @@ export function createPostgresTables(names?: Partial, option }; dialect: "pg"; }>; - readonly baseSchemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { - installation: drizzle_orm_pg_core.PgColumn<{ - name: "installation"; + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - version: drizzle_orm_pg_core.PgColumn<{ - name: "version"; + prunedBeforeRevision: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_before_revision"; tableName: string; dataType: "number"; - columnType: "PgInteger"; + columnType: "PgBigInt53"; data: number; driverParam: string | number; notNull: true; @@ -2730,8 +2748,8 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + prunedAt: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -2750,7 +2768,7 @@ export function createPostgresTables(names?: Partial, option }; dialect: "pg"; }>; - readonly schemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly uniques: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -2771,25 +2789,8 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - version: drizzle_orm_pg_core.PgColumn<{ - name: "version"; - tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - schemaHash: drizzle_orm_pg_core.PgColumn<{ - name: "schema_hash"; + nodeKind: drizzle_orm_pg_core.PgColumn<{ + name: "node_kind"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2805,66 +2806,42 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - schemaDoc: drizzle_orm_pg_core.PgColumn<{ - name: "schema_doc"; + constraintName: drizzle_orm_pg_core.PgColumn<{ + name: "constraint_name"; tableName: string; - dataType: "json"; - columnType: "PgJsonb"; - data: unknown; - driverParam: unknown; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - createdAt: drizzle_orm_pg_core.PgColumn<{ - name: "created_at"; + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - isActive: drizzle_orm_pg_core.PgColumn<{ - name: "is_active"; - tableName: string; - dataType: "boolean"; - columnType: "PgBoolean"; - data: boolean; - driverParam: boolean; - notNull: true; - hasDefault: true; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly graphTemplates: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - templateId: drizzle_orm_pg_core.PgColumn<{ - name: "template_id"; + nodeId: drizzle_orm_pg_core.PgColumn<{ + name: "node_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -2880,8 +2857,366 @@ export function createPostgresTables(names?: Partial, option identity: undefined; generated: undefined; }, {}, {}>; - schemaHash: drizzle_orm_pg_core.PgColumn<{ - name: "schema_hash"; + concreteKind: drizzle_orm_pg_core.PgColumn<{ + name: "concrete_kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + deletedAt: drizzle_orm_pg_core.PgColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly edgeClaims: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + axis: drizzle_orm_pg_core.PgColumn<{ + name: "axis"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + edgeId: drizzle_orm_pg_core.PgColumn<{ + name: "edge_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly fences: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + key: drizzle_orm_pg_core.PgColumn<{ + name: "key"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + generation: drizzle_orm_pg_core.PgColumn<{ + name: "generation"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly baseSchemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + installation: drizzle_orm_pg_core.PgColumn<{ + name: "installation"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + version: drizzle_orm_pg_core.PgColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly schemaVersions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + version: drizzle_orm_pg_core.PgColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaHash: drizzle_orm_pg_core.PgColumn<{ + name: "schema_hash"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaDoc: drizzle_orm_pg_core.PgColumn<{ + name: "schema_doc"; + tableName: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + createdAt: drizzle_orm_pg_core.PgColumn<{ + name: "created_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + isActive: drizzle_orm_pg_core.PgColumn<{ + name: "is_active"; + tableName: string; + dataType: "boolean"; + columnType: "PgBoolean"; + data: boolean; + driverParam: boolean; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly graphTemplates: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + templateId: drizzle_orm_pg_core.PgColumn<{ + name: "template_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaHash: drizzle_orm_pg_core.PgColumn<{ + name: "schema_hash"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -3682,9 +4017,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -3852,11 +4196,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -4220,6 +4570,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -4297,7 +4650,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -4693,7 +5046,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4738,9 +5091,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4918,6 +5272,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -5008,6 +5368,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -5116,9 +5478,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -5173,8 +5532,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -5185,6 +5548,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -5246,7 +5610,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -5270,6 +5634,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -5606,6 +5980,8 @@ export type PostgresTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -5643,6 +6019,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -6452,6 +6829,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -6626,11 +7009,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -6638,11 +7023,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -6668,7 +7048,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public export type SerializedResourceDeclaration = Readonly<{ @@ -6772,6 +7152,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -7925,49 +8307,284 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - revision: drizzle_orm_pg_core.PgColumn<{ - name: "revision"; + revision: drizzle_orm_pg_core.PgColumn<{ + name: "revision"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedAt: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly revisionOrigins: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + origin: drizzle_orm_pg_core.PgColumn<{ + name: "origin"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly revisionChanges: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + entryId: drizzle_orm_pg_core.PgColumn<{ + name: "entry_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: true; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + revision: drizzle_orm_pg_core.PgColumn<{ + name: "revision"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + complete: drizzle_orm_pg_core.PgColumn<{ + name: "complete"; + tableName: string; + dataType: "boolean"; + columnType: "PgBoolean"; + data: boolean; + driverParam: boolean; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + entity: drizzle_orm_pg_core.PgColumn<{ + name: "entity"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + kind: drizzle_orm_pg_core.PgColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + id: drizzle_orm_pg_core.PgColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "pg"; + }>; + readonly identityAssertions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + id: drizzle_orm_pg_core.PgColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + relation: drizzle_orm_pg_core.PgColumn<{ + name: "rel"; tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - recordedAt: drizzle_orm_pg_core.PgColumn<{ - name: "recorded_at"; + aKind: drizzle_orm_pg_core.PgColumn<{ + name: "a_kind"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly revisionOrigins: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + aId: drizzle_orm_pg_core.PgColumn<{ + name: "a_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -7983,8 +8600,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - origin: drizzle_orm_pg_core.PgColumn<{ - name: "origin"; + bKind: drizzle_orm_pg_core.PgColumn<{ + name: "b_kind"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8000,15 +8617,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly revisionChanges: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - entryId: drizzle_orm_pg_core.PgColumn<{ - name: "entry_id"; + bId: drizzle_orm_pg_core.PgColumn<{ + name: "b_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8016,7 +8626,7 @@ export const tables: { driverParam: string; notNull: true; hasDefault: false; - isPrimaryKey: true; + isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; enumValues: [string, ...string[]]; @@ -8024,31 +8634,31 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + validFrom: drizzle_orm_pg_core.PgColumn<{ + name: "valid_from"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - revision: drizzle_orm_pg_core.PgColumn<{ - name: "revision"; + validTo: drizzle_orm_pg_core.PgColumn<{ + name: "valid_to"; tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; - notNull: true; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8058,13 +8668,13 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - complete: drizzle_orm_pg_core.PgColumn<{ - name: "complete"; + createdAt: drizzle_orm_pg_core.PgColumn<{ + name: "created_at"; tableName: string; - dataType: "boolean"; - columnType: "PgBoolean"; - data: boolean; - driverParam: boolean; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; @@ -8075,31 +8685,48 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - entity: drizzle_orm_pg_core.PgColumn<{ - name: "entity"; + updatedAt: drizzle_orm_pg_core.PgColumn<{ + name: "updated_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - kind: drizzle_orm_pg_core.PgColumn<{ - name: "kind"; + deletedAt: drizzle_orm_pg_core.PgColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + endedByKind: drizzle_orm_pg_core.PgColumn<{ + name: "ended_by_kind"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8109,14 +8736,14 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - id: drizzle_orm_pg_core.PgColumn<{ - name: "id"; + endedById: drizzle_orm_pg_core.PgColumn<{ + name: "ended_by_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8129,10 +8756,27 @@ export const tables: { }; dialect: "pg"; }>; - readonly identityAssertions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly recordedIdentityAssertions: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { + historyId: drizzle_orm_pg_core.PgColumn<{ + name: "history_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; graphId: drizzle_orm_pg_core.PgColumn<{ name: "graph_id"; tableName: string; @@ -8366,7 +9010,109 @@ export const tables: { isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedFrom: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_from"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedTo: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_to"; + tableName: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + op: drizzle_orm_pg_core.PgColumn<{ + name: "op"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaVersion: drizzle_orm_pg_core.PgColumn<{ + name: "schema_version"; + tableName: string; + dataType: "number"; + columnType: "PgInteger"; + data: number; + driverParam: string | number; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + txId: drizzle_orm_pg_core.PgColumn<{ + name: "tx_id"; + tableName: string; + dataType: "string"; + columnType: "PgText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + meta: drizzle_orm_pg_core.PgColumn<{ + name: "meta"; + tableName: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; @@ -8374,12 +9120,12 @@ export const tables: { }; dialect: "pg"; }>; - readonly recordedIdentityAssertions: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityClosure: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { - historyId: drizzle_orm_pg_core.PgColumn<{ - name: "history_id"; + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8395,8 +9141,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + memberKind: drizzle_orm_pg_core.PgColumn<{ + name: "member_kind"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8412,8 +9158,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - id: drizzle_orm_pg_core.PgColumn<{ - name: "id"; + memberId: drizzle_orm_pg_core.PgColumn<{ + name: "member_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8429,8 +9175,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - relation: drizzle_orm_pg_core.PgColumn<{ - name: "rel"; + classKind: drizzle_orm_pg_core.PgColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8446,8 +9192,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - aKind: drizzle_orm_pg_core.PgColumn<{ - name: "a_kind"; + classId: drizzle_orm_pg_core.PgColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8463,8 +9209,15 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - aId: drizzle_orm_pg_core.PgColumn<{ - name: "a_id"; + }; + dialect: "pg"; + }>; + readonly identitySeparation: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8480,8 +9233,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - bKind: drizzle_orm_pg_core.PgColumn<{ - name: "b_kind"; + classKeyLow: drizzle_orm_pg_core.PgColumn<{ + name: "class_key_low"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8497,8 +9250,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - bId: drizzle_orm_pg_core.PgColumn<{ - name: "b_id"; + classKeyHigh: drizzle_orm_pg_core.PgColumn<{ + name: "class_key_high"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8514,47 +9267,54 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - validFrom: drizzle_orm_pg_core.PgColumn<{ - name: "valid_from"; + }; + dialect: "pg"; + }>; + readonly identityTransitions: drizzle_orm_pg_core.PgTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_pg_core.PgColumn<{ + name: "graph_id"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - validTo: drizzle_orm_pg_core.PgColumn<{ - name: "valid_to"; + transitionId: drizzle_orm_pg_core.PgColumn<{ + name: "transition_id"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; + dataType: "string"; + columnType: "PgText"; + data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - createdAt: drizzle_orm_pg_core.PgColumn<{ - name: "created_at"; + recordedRevision: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "date"; - columnType: "PgTimestamp"; - data: Date; - driverParam: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; @@ -8565,8 +9325,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_pg_core.PgColumn<{ - name: "updated_at"; + recordedAt: drizzle_orm_pg_core.PgColumn<{ + name: "recorded_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; @@ -8582,14 +9342,14 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - deletedAt: drizzle_orm_pg_core.PgColumn<{ - name: "deleted_at"; + validAt: drizzle_orm_pg_core.PgColumn<{ + name: "valid_at"; tableName: string; dataType: "date"; columnType: "PgTimestamp"; data: Date; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8599,14 +9359,14 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - endedByKind: drizzle_orm_pg_core.PgColumn<{ - name: "ended_by_kind"; + cause: drizzle_orm_pg_core.PgColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8616,59 +9376,25 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - endedById: drizzle_orm_pg_core.PgColumn<{ - name: "ended_by_id"; + classKind: drizzle_orm_pg_core.PgColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - recordedFrom: drizzle_orm_pg_core.PgColumn<{ - name: "recorded_from"; - tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - recordedTo: drizzle_orm_pg_core.PgColumn<{ - name: "recorded_to"; - tableName: string; - dataType: "number"; - columnType: "PgBigInt53"; - data: number; - driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - op: drizzle_orm_pg_core.PgColumn<{ - name: "op"; + classId: drizzle_orm_pg_core.PgColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8684,25 +9410,8 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - schemaVersion: drizzle_orm_pg_core.PgColumn<{ - name: "schema_version"; - tableName: string; - dataType: "number"; - columnType: "PgInteger"; - data: number; - driverParam: string | number; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - txId: drizzle_orm_pg_core.PgColumn<{ - name: "tx_id"; + priorClassKind: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "PgText"; @@ -8718,38 +9427,14 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - meta: drizzle_orm_pg_core.PgColumn<{ - name: "meta"; - tableName: string; - dataType: "json"; - columnType: "PgJsonb"; - data: unknown; - driverParam: unknown; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - }; - dialect: "pg"; - }>; - readonly identityClosure: drizzle_orm_pg_core.PgTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_pg_core.PgColumn<{ - name: "graph_id"; + priorClassId: drizzle_orm_pg_core.PgColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8759,48 +9444,48 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - memberKind: drizzle_orm_pg_core.PgColumn<{ - name: "member_kind"; + assertionIds: drizzle_orm_pg_core.PgColumn<{ + name: "assertion_ids"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - memberId: drizzle_orm_pg_core.PgColumn<{ - name: "member_id"; + decision: drizzle_orm_pg_core.PgColumn<{ + name: "decision"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; - notNull: true; + dataType: "json"; + columnType: "PgJsonb"; + data: unknown; + driverParam: unknown; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - classKind: drizzle_orm_pg_core.PgColumn<{ - name: "class_kind"; + txId: drizzle_orm_pg_core.PgColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "PgText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8810,19 +9495,19 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - classId: drizzle_orm_pg_core.PgColumn<{ - name: "class_id"; + restoredAt: drizzle_orm_pg_core.PgColumn<{ + name: "restored_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; @@ -8830,7 +9515,7 @@ export const tables: { }; dialect: "pg"; }>; - readonly identitySeparation: drizzle_orm_pg_core.PgTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_pg_core.PgTableWithColumns<{ name: string; schema: undefined; columns: { @@ -8851,36 +9536,36 @@ export const tables: { identity: undefined; generated: undefined; }, {}, {}>; - classKeyLow: drizzle_orm_pg_core.PgColumn<{ - name: "class_key_low"; + prunedBeforeRevision: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_before_revision"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "PgBigInt53"; + data: number; + driverParam: string | number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; }, {}, {}>; - classKeyHigh: drizzle_orm_pg_core.PgColumn<{ - name: "class_key_high"; + prunedAt: drizzle_orm_pg_core.PgColumn<{ + name: "pruned_at"; tableName: string; - dataType: "string"; - columnType: "PgText"; - data: string; + dataType: "date"; + columnType: "PgTimestamp"; + data: Date; driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; @@ -10034,6 +10719,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-libsql.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-libsql.api.md index f5b7e384e..e6e7e0813 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-libsql.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-libsql.api.md @@ -120,15 +120,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -196,22 +196,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -320,6 +349,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -331,15 +370,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -349,6 +380,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -2628,6 +2660,339 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; + readonly identityTransitions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + transitionId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "transition_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + recordedRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_revision"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + validAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cause: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cause"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + priorClassKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + priorClassId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + assertionIds: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "assertion_ids"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + decision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "decision"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + restoredAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "restored_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly identityTransitionRetention: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + prunedBeforeRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_before_revision"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + prunedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; readonly uniques: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; @@ -3797,9 +4162,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -3873,11 +4247,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -3977,6 +4357,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -4054,7 +4437,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -4307,7 +4690,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4352,9 +4735,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4532,6 +4916,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -4622,6 +5012,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -4730,9 +5122,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -4787,8 +5176,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -4799,6 +5192,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -4872,7 +5266,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -4896,6 +5290,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -5046,6 +5450,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5148,6 +5553,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -5211,11 +5622,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5223,11 +5636,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5253,7 +5661,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5334,6 +5742,8 @@ type SqliteTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -5376,6 +5786,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5430,6 +5842,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-local.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-local.api.md index 172a6e807..2f5451abe 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-local.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite-local.api.md @@ -126,15 +126,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -202,22 +202,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -326,6 +355,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -337,15 +376,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -355,6 +386,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -2634,6 +2666,339 @@ function createSqliteTables(names?: Partial, options?: CreateS }; dialect: "sqlite"; }>; + readonly identityTransitions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + transitionId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "transition_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + recordedRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_revision"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + validAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + cause: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cause"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + priorClassKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + priorClassId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + assertionIds: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "assertion_ids"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + decision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "decision"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + restoredAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "restored_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly identityTransitionRetention: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + prunedBeforeRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_before_revision"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + prunedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; readonly uniques: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; @@ -3806,9 +4171,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -3882,11 +4256,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -3986,6 +4366,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -4063,7 +4446,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -4316,7 +4699,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4361,9 +4744,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4541,6 +4925,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -4631,6 +5021,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -4739,9 +5131,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -4796,8 +5185,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -4808,6 +5201,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -4901,7 +5295,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -4925,6 +5319,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -5075,6 +5479,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5177,6 +5582,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -5240,11 +5651,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5252,11 +5665,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5282,7 +5690,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5363,6 +5771,8 @@ type SqliteTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -5405,6 +5815,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5459,6 +5871,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite.api.md b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite.api.md index 8c6a18330..9e1456016 100644 --- a/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite.api.md +++ b/packages/typegraph/etc/typegraph-adapters-drizzle-sqlite.api.md @@ -125,15 +125,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -201,22 +201,51 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -325,6 +354,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -336,15 +375,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -354,6 +385,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -2633,7 +2665,7 @@ export function createSqliteTables(names?: Partial, options?: }; dialect: "sqlite"; }>; - readonly uniques: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityTransitions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -2656,8 +2688,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - nodeKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "node_kind"; + transitionId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "transition_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2675,27 +2707,25 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - constraintName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "constraint_name"; + recordedRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - key: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "key"; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2713,8 +2743,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - nodeId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "node_id"; + validAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2732,8 +2762,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - concreteKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "concrete_kind"; + cause: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2751,14 +2781,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2770,15 +2800,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly edgeClaims: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2796,14 +2819,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - axis: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "axis"; + priorClassKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2815,14 +2838,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - key: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "key"; + priorClassId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2834,8 +2857,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - edgeId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "edge_id"; + assertionIds: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "assertion_ids"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2853,14 +2876,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + decision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "decision"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2872,21 +2895,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly fences: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - key: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "key"; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -2898,49 +2914,53 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - generation: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "generation"; + restoredAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "restored_at"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; + }, {}, { + length: number | undefined; + }>; }; dialect: "sqlite"; }>; - readonly baseSchemaVersions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - installation: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "installation"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; + }, {}, { + length: number | undefined; + }>; + prunedBeforeRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_before_revision"; tableName: string; dataType: "number"; columnType: "SQLiteInteger"; @@ -2956,8 +2976,8 @@ export function createSqliteTables(names?: Partial, options?: identity: undefined; generated: undefined; }, {}, {}>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + prunedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -2978,7 +2998,7 @@ export function createSqliteTables(names?: Partial, options?: }; dialect: "sqlite"; }>; - readonly schemaVersions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly uniques: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -3001,25 +3021,27 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; + nodeKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "node_kind"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - schemaHash: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_hash"; + }, {}, { + length: number | undefined; + }>; + constraintName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "constraint_name"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3037,8 +3059,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - schemaDoc: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_doc"; + key: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "key"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3056,8 +3078,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + nodeId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "node_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3075,32 +3097,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - isActive: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "is_active"; - tableName: string; - dataType: "boolean"; - columnType: "SQLiteBoolean"; - data: boolean; - driverParam: number; - notNull: true; - hasDefault: true; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - }; - dialect: "sqlite"; - }>; - readonly graphTemplates: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - templateId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "template_id"; + concreteKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "concrete_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3118,14 +3116,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - schemaHash: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_hash"; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3137,8 +3135,15 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - schemaDoc: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_doc"; + }; + dialect: "sqlite"; + }>; + readonly edgeClaims: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3156,8 +3161,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + axis: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "axis"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3175,15 +3180,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly indexMaterializations: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - indexName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "index_name"; + key: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "key"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3201,8 +3199,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + edgeId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "edge_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3220,8 +3218,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - entity: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entity"; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3239,8 +3237,15 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + }; + dialect: "sqlite"; + }>; + readonly fences: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + key: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "key"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3258,27 +3263,32 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - signature: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "signature"; + generation: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "generation"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_version"; + }, {}, {}>; + }; + dialect: "sqlite"; + }>; + readonly baseSchemaVersions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + installation: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "installation"; tableName: string; dataType: "number"; columnType: "SQLiteInteger"; @@ -3294,14 +3304,31 @@ export function createSqliteTables(names?: Partial, options?: identity: undefined; generated: undefined; }, {}, {}>; - materializedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "materialized_at"; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3313,8 +3340,15 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - lastAttemptedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "last_attempted_at"; + }; + dialect: "sqlite"; + }>; + readonly schemaVersions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3332,14 +3366,31 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - lastError: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "last_error"; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + schemaHash: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_hash"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3351,15 +3402,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly contributionMaterializations: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + schemaDoc: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_doc"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3377,8 +3421,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - logicalName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "logical_name"; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3396,8 +3440,32 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - owner: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "owner"; + isActive: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "is_active"; + tableName: string; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "sqlite"; + }>; + readonly graphTemplates: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + templateId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "template_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3415,8 +3483,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - tableName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "table_name"; + schemaHash: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_hash"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3434,8 +3502,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - signature: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "signature"; + schemaDoc: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_doc"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3453,14 +3521,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - materializedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "materialized_at"; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3472,8 +3540,15 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - lastAttemptedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "last_attempted_at"; + }; + dialect: "sqlite"; + }>; + readonly indexMaterializations: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + indexName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "index_name"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3491,14 +3566,14 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - lastError: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "last_error"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -3510,15 +3585,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly kindRemovals: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + entity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entity"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3536,8 +3604,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - kindName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind_name"; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3555,8 +3623,8 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - entity: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entity"; + signature: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "signature"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3591,8 +3659,8 @@ export function createSqliteTables(names?: Partial, options?: identity: undefined; generated: undefined; }, {}, {}>; - removedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "removed_at"; + materializedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "materialized_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -3651,7 +3719,7 @@ export function createSqliteTables(names?: Partial, options?: }; dialect: "sqlite"; }>; - readonly reconciliationMarkers: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly contributionMaterializations: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -3674,79 +3742,376 @@ export function createSqliteTables(names?: Partial, options?: }, {}, { length: number | undefined; }>; - reconciledToVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "reconciled_to_version"; + logicalName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "logical_name"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - }; - dialect: "sqlite"; - }>; - readonly fulltextTableName: string; -}; - -// @public (undocumented) -export type CreateSqliteTablesOptions = Readonly<{ - indexes?: readonly IndexDeclaration[] | undefined; -}>; - -// @public -type CreateVectorIndexParams = Readonly<{ - graphId: string; - nodeKind: string; - fieldPath: string; - dimensions: number; - metric: VectorMetric; - indexType: VectorIndexType; - indexParams?: Readonly<{ - m?: number; - efConstruction?: number; - lists?: number; - }>; - concurrent?: boolean; -}>; - -// @public -const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; - -// @public -type DatabaseExtensionName = (typeof DATABASE_EXTENSION_NAMES)[number]; - -// @public -type DeleteBehavior = "restrict" | "cascade" | "disconnect"; - -// @public -type DeleteEdgeParams = Readonly<{ - graphId: string; - id: string; - kind?: string; -}>; - -// @public -type DeleteEdgesBatchParams = Readonly<{ - graphId: string; - ids: readonly string[]; - kind?: string; -}>; - -// @public -type DeleteEmbeddingParams = Readonly<{ - graphId: string; - nodeKind: string; - nodeId: string; - fieldPath: string; + }, {}, { + length: number | undefined; + }>; + owner: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "owner"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + tableName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "table_name"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + signature: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "signature"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + materializedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "materialized_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + lastAttemptedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_attempted_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + lastError: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly kindRemovals: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + kindName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind_name"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + entity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entity"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + removedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "removed_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + lastAttemptedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_attempted_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + lastError: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "last_error"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly reconciliationMarkers: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + reconciledToVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "reconciled_to_version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + }; + dialect: "sqlite"; + }>; + readonly fulltextTableName: string; +}; + +// @public (undocumented) +export type CreateSqliteTablesOptions = Readonly<{ + indexes?: readonly IndexDeclaration[] | undefined; +}>; + +// @public +type CreateVectorIndexParams = Readonly<{ + graphId: string; + nodeKind: string; + fieldPath: string; + dimensions: number; + metric: VectorMetric; + indexType: VectorIndexType; + indexParams?: Readonly<{ + m?: number; + efConstruction?: number; + lists?: number; + }>; + concurrent?: boolean; +}>; + +// @public +const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; + +// @public +type DatabaseExtensionName = (typeof DATABASE_EXTENSION_NAMES)[number]; + +// @public +type DeleteBehavior = "restrict" | "cascade" | "disconnect"; + +// @public +type DeleteEdgeParams = Readonly<{ + graphId: string; + id: string; + kind?: string; +}>; + +// @public +type DeleteEdgesBatchParams = Readonly<{ + graphId: string; + ids: readonly string[]; + kind?: string; +}>; + +// @public +type DeleteEmbeddingParams = Readonly<{ + graphId: string; + nodeKind: string; + nodeId: string; + fieldPath: string; dimensions: number; metric: VectorMetric; indexType: VectorIndexType; @@ -3802,9 +4167,18 @@ type DurableEdgeBatchMembers = Readonly<{ }>; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -3982,11 +4356,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -4380,6 +4760,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -4457,7 +4840,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -4716,7 +5099,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4761,9 +5144,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4941,6 +5325,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -5031,6 +5421,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -5139,9 +5531,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InsertEdgeParams = Readonly<{ graphId: string; @@ -5196,8 +5585,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -5208,6 +5601,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -5269,7 +5663,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -5293,6 +5687,16 @@ type ManagedNodeCreatePlan = Readonly<{ // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NodeCreateCommand = Readonly<{ kind: "node.create"; @@ -5640,6 +6044,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -6513,6 +6918,12 @@ type RowProps = string | Readonly>; // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -6695,11 +7106,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -6707,11 +7120,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -6737,7 +7145,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public export type SerializedResourceDeclaration = Readonly<{ @@ -6845,6 +7253,8 @@ export type SqliteTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -6890,6 +7300,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -6936,11 +7348,206 @@ type TableContribution = Readonly<{ kind: "unsupported"; reason: string; }>; -}>; - -// @public -export const tables: { - readonly nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ +}>; + +// @public +export const tables: { + readonly nodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + props: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "props"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: true; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + }; + dialect: "sqlite"; + }>; + readonly edges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -6963,6 +7570,25 @@ export const tables: { }, {}, { length: number | undefined; }>; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; kind: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "kind"; tableName: string; @@ -6982,8 +7608,65 @@ export const tables: { }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + toId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7020,23 +7703,44 @@ export const tables: { }, {}, { length: number | undefined; }>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; + matchIdentityName: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "match_identity_name"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: true; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; + }, {}, { + length: number | undefined; + }>; + matchIdentityKey: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "match_identity_key"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "valid_from"; tableName: string; @@ -7135,10 +7839,29 @@ export const tables: { }; dialect: "sqlite"; }>; - readonly edges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "graph_id"; tableName: string; @@ -7158,6 +7881,25 @@ export const tables: { }, {}, { length: number | undefined; }>; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; id: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "id"; tableName: string; @@ -7177,8 +7919,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + props: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "props"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7196,14 +7938,31 @@ export const tables: { }, {}, { length: number | undefined; }>; - fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_kind"; + version: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "version"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -7215,8 +7974,27 @@ export const tables: { }, {}, { length: number | undefined; }>; - fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_id"; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7234,8 +8012,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_kind"; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7253,27 +8031,61 @@ export const tables: { }, {}, { length: number | undefined; }>; - toId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_id"; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; + tableName: string; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; + notNull: false; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: [string, ...string[]]; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, { + length: number | undefined; + }>; + recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_from"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - props: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "props"; + }, {}, {}>; + recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_to"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + op: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "op"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7291,27 +8103,25 @@ export const tables: { }, {}, { length: number | undefined; }>; - matchIdentityName: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "match_identity_name"; + schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_version"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - matchIdentityKey: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "match_identity_key"; + }, {}, {}>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7329,8 +8139,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + meta: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "meta"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7348,14 +8158,21 @@ export const tables: { }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + }; + dialect: "sqlite"; + }>; + readonly recordedEdges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -7367,8 +8184,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7386,8 +8203,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7405,14 +8222,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -7424,15 +8241,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly recordedNodes: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; + fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7450,8 +8260,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "from_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7469,8 +8279,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7488,8 +8298,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + toId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "to_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7526,23 +8336,6 @@ export const tables: { }, {}, { length: number | undefined; }>; - version: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "valid_from"; tableName: string; @@ -7749,12 +8542,12 @@ export const tables: { }; dialect: "sqlite"; }>; - readonly recordedEdges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedClock: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7772,27 +8565,25 @@ export const tables: { }, {}, { length: number | undefined; }>; - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + revision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7810,8 +8601,15 @@ export const tables: { }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + }; + dialect: "sqlite"; + }>; + readonly revisionOrigins: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7829,8 +8627,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - fromKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_kind"; + origin: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "origin"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7848,8 +8646,15 @@ export const tables: { }, {}, { length: number | undefined; }>; - fromId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "from_id"; + }; + dialect: "sqlite"; + }>; + readonly revisionChanges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + entryId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entry_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7857,7 +8662,7 @@ export const tables: { driverParam: string; notNull: true; hasDefault: false; - isPrimaryKey: false; + isPrimaryKey: true; isAutoincrement: false; hasRuntimeDefault: false; enumValues: [string, ...string[]]; @@ -7867,8 +8672,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - toKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_kind"; + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7886,8 +8691,42 @@ export const tables: { }, {}, { length: number | undefined; }>; - toId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "to_id"; + revision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "revision"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + complete: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "complete"; + tableName: string; + dataType: "boolean"; + columnType: "SQLiteBoolean"; + data: boolean; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + entity: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "entity"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7905,8 +8744,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - props: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "props"; + kind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7924,14 +8763,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -7943,14 +8782,21 @@ export const tables: { }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + }; + dialect: "sqlite"; + }>; + readonly identityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -7962,8 +8808,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -7981,8 +8827,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + relation: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "rel"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8000,14 +8846,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8019,42 +8865,46 @@ export const tables: { }, {}, { length: number | undefined; }>; - recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_from"; + aId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_id"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_to"; + }, {}, { + length: number | undefined; + }>; + bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_kind"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - op: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "op"; + }, {}, { + length: number | undefined; + }>; + bId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8072,31 +8922,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - txId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "tx_id"; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8108,8 +8941,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - meta: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "meta"; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8127,15 +8960,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly recordedClock: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8153,31 +8979,33 @@ export const tables: { }, {}, { length: number | undefined; }>; - revision: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "revision"; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_at"; + }, {}, { + length: number | undefined; + }>; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8189,21 +9017,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly revisionOrigins: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8215,14 +9036,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - origin: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "origin"; + endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8237,12 +9058,12 @@ export const tables: { }; dialect: "sqlite"; }>; - readonly revisionChanges: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly recordedIdentityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - entryId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entry_id"; + historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "history_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8250,7 +9071,7 @@ export const tables: { driverParam: string; notNull: true; hasDefault: false; - isPrimaryKey: true; + isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; enumValues: [string, ...string[]]; @@ -8279,42 +9100,27 @@ export const tables: { }, {}, { length: number | undefined; }>; - revision: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "revision"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - complete: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "complete"; + id: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "id"; tableName: string; - dataType: "boolean"; - columnType: "SQLiteBoolean"; - data: boolean; - driverParam: number; + dataType: "string"; + columnType: "SQLiteText"; + data: string; + driverParam: string; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: undefined; + enumValues: [string, ...string[]]; baseColumn: never; identity: undefined; generated: undefined; - }, {}, {}>; - entity: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "entity"; + }, {}, { + length: number | undefined; + }>; + relation: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "rel"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8332,8 +9138,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - kind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "kind"; + aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8351,8 +9157,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + aId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "a_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8370,15 +9176,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly identityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8396,8 +9195,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + bId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "b_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8415,8 +9214,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - relation: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "rel"; + validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_from"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8434,14 +9233,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_kind"; + validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_to"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8453,8 +9252,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - aId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_id"; + createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "created_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8472,8 +9271,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_kind"; + updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "updated_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8491,14 +9290,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - bId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_id"; + deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "deleted_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8510,14 +9309,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8529,8 +9328,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "ended_by_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8548,27 +9347,42 @@ export const tables: { }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_from"; + tableName: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; + notNull: true; + hasDefault: false; + isPrimaryKey: false; + isAutoincrement: false; + hasRuntimeDefault: false; + enumValues: undefined; + baseColumn: never; + identity: undefined; + generated: undefined; + }, {}, {}>; + recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_to"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + }, {}, {}>; + op: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "op"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8586,27 +9400,25 @@ export const tables: { }, {}, { length: number | undefined; }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "schema_version"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_kind"; + }, {}, {}>; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8624,8 +9436,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_id"; + meta: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "meta"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8646,29 +9458,10 @@ export const tables: { }; dialect: "sqlite"; }>; - readonly recordedIdentityAssertions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityClosure: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { - historyId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "history_id"; - tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: [string, ...string[]]; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, { - length: number | undefined; - }>; graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ name: "graph_id"; tableName: string; @@ -8688,8 +9481,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - id: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "id"; + memberKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "member_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8707,8 +9500,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - relation: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "rel"; + memberId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "member_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8726,8 +9519,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - aKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_kind"; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8745,8 +9538,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - aId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "a_id"; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8764,8 +9557,15 @@ export const tables: { }, {}, { length: number | undefined; }>; - bKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_kind"; + }; + dialect: "sqlite"; + }>; + readonly identitySeparation: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8783,8 +9583,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - bId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "b_id"; + classKeyLow: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_key_low"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8802,8 +9602,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - validFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_from"; + classKeyHigh: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_key_high"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8821,14 +9621,21 @@ export const tables: { }, {}, { length: number | undefined; }>; - validTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "valid_to"; + }; + dialect: "sqlite"; + }>; + readonly identityTransitions: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + name: string; + schema: undefined; + columns: { + graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "graph_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8840,8 +9647,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - createdAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "created_at"; + transitionId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "transition_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8859,33 +9666,31 @@ export const tables: { }, {}, { length: number | undefined; }>; - updatedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "updated_at"; + recordedRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - deletedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "deleted_at"; + }, {}, {}>; + recordedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "recorded_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8897,14 +9702,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - endedByKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_kind"; + validAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "valid_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8916,14 +9721,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - endedById: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "ended_by_id"; + cause: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "cause"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -8935,42 +9740,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - recordedFrom: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_from"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - recordedTo: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "recorded_to"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: true; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - op: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "op"; + classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -8988,31 +9759,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - schemaVersion: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "schema_version"; - tableName: string; - dataType: "number"; - columnType: "SQLiteInteger"; - data: number; - driverParam: number; - notNull: false; - hasDefault: false; - isPrimaryKey: false; - isAutoincrement: false; - hasRuntimeDefault: false; - enumValues: undefined; - baseColumn: never; - identity: undefined; - generated: undefined; - }, {}, {}>; - txId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "tx_id"; + classId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: false; + notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -9024,8 +9778,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - meta: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "meta"; + priorClassKind: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_kind"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -9043,21 +9797,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - }; - dialect: "sqlite"; - }>; - readonly identityClosure: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ - name: string; - schema: undefined; - columns: { - graphId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "graph_id"; + priorClassId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "prior_class_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -9069,8 +9816,8 @@ export const tables: { }, {}, { length: number | undefined; }>; - memberKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "member_kind"; + assertionIds: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "assertion_ids"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -9088,14 +9835,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - memberId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "member_id"; + decision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "decision"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -9107,14 +9854,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - classKind: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_kind"; + txId: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "tx_id"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -9126,14 +9873,14 @@ export const tables: { }, {}, { length: number | undefined; }>; - classId: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_id"; + restoredAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "restored_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; data: string; driverParam: string; - notNull: true; + notNull: false; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; @@ -9148,7 +9895,7 @@ export const tables: { }; dialect: "sqlite"; }>; - readonly identitySeparation: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ + readonly identityTransitionRetention: drizzle_orm_sqlite_core.SQLiteTableWithColumns<{ name: string; schema: undefined; columns: { @@ -9171,27 +9918,25 @@ export const tables: { }, {}, { length: number | undefined; }>; - classKeyLow: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_key_low"; + prunedBeforeRevision: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_before_revision"; tableName: string; - dataType: "string"; - columnType: "SQLiteText"; - data: string; - driverParam: string; + dataType: "number"; + columnType: "SQLiteInteger"; + data: number; + driverParam: number; notNull: true; hasDefault: false; isPrimaryKey: false; isAutoincrement: false; hasRuntimeDefault: false; - enumValues: [string, ...string[]]; + enumValues: undefined; baseColumn: never; identity: undefined; generated: undefined; - }, {}, { - length: number | undefined; - }>; - classKeyHigh: drizzle_orm_sqlite_core.SQLiteColumn<{ - name: "class_key_high"; + }, {}, {}>; + prunedAt: drizzle_orm_sqlite_core.SQLiteColumn<{ + name: "pruned_at"; tableName: string; dataType: "string"; columnType: "SQLiteText"; @@ -10282,6 +11027,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-backend.api.md b/packages/typegraph/etc/typegraph-backend.api.md index f7ddcdbf7..c18fb6ba0 100644 --- a/packages/typegraph/etc/typegraph-backend.api.md +++ b/packages/typegraph/etc/typegraph-backend.api.md @@ -1367,15 +1367,15 @@ export type CheckUniqueParams = Readonly<{ }>; // @public -export type ClaimEdgeCardinalityParams = Readonly<{ +export type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -1494,6 +1494,28 @@ export type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) export type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +export type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + // @public class ConfigurationError extends TypeGraphError { constructor(message: string, details?: Record, options?: { @@ -1502,22 +1524,29 @@ class ConfigurationError extends TypeGraphError { }); } +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public export type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -export type ContendedEdgeRow = Readonly<{ +export type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -1725,6 +1754,16 @@ export type ContributionRepopulationStats = Readonly<{ // @public export type ContributionScope = "deployment" | "graph"; +// @public +export type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public export type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -1736,15 +1775,7 @@ export type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -export type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1754,6 +1785,7 @@ export type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1981,9 +2013,18 @@ export type DurableEdgeBatchMembers = Readonly<{ }>; // @public -export type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +export type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -2057,11 +2098,17 @@ export type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +export type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -export type EdgeEntityReadBackend = Pick; +export type EdgeEntityReadBackend = Pick; // @public (undocumented) export type EdgeEntityWriteBackend = Pick; @@ -2265,6 +2312,9 @@ export type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2342,7 +2392,7 @@ export type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public export type ExtensionPropertyModifiers = Readonly<{ @@ -2416,6 +2466,7 @@ export type FenceSql = Readonly<{ type FenceStatements = FenceSql & Readonly<{ acquireKeyed: (namespace: string, key: string | number) => SqlFragment; acquireKeyedWithIsolation: (namespace: string, key: string | number) => SqlFragment; + acquireKeyedSnapshotFence?: ((namespace: string, key: string | number) => SqlFragment) | undefined; isolationFact: () => SqlFragment; }>; @@ -2664,7 +2715,7 @@ export type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2709,9 +2760,10 @@ export type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -2885,7 +2937,7 @@ export type GraphIdentityConfig = Readonly<{ export type GraphLifecycleBackend = Pick; // @public -export type GraphReadBackend = Pick; +export type GraphReadBackend = Pick; // @public type GraphTemplateRow = Readonly<{ @@ -2895,6 +2947,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +export type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public export type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -2991,6 +3049,8 @@ export type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -3099,9 +3159,6 @@ export type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -export type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public export type InListParameterOptions = Readonly<{ negated: boolean; @@ -3183,8 +3240,12 @@ export type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3195,6 +3256,7 @@ export type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3261,7 +3323,7 @@ export type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3315,6 +3377,16 @@ export type MigrateRecordedAnchorOptions = Readonly<{ mappingTableName?: string | undefined; }>; +// @public +export type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public export function missingRequiredExtras>(definition: D, verdict: BundleVerdictOf, operation: Op): readonly string[]; @@ -3445,6 +3517,9 @@ export function normalizeGraphCommandIsolation(value: unknown): GraphCommandIsol // @public export type NullCheckOp = "isNull" | "isNotNull"; +// @public +export function observesPostFenceCommits(acquisition: GraphWriteFenceAcquisition): boolean; + // @public export type OperationNames = D["operations"][number]["operation"]; @@ -3505,6 +3580,7 @@ export type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -3764,6 +3840,8 @@ export type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -3809,6 +3887,12 @@ export type SchemaCommitBackend = Pick Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; }>; // @public (undocumented) @@ -3872,11 +3956,13 @@ export type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -3884,11 +3970,6 @@ export type SerializedEdgeDef = Readonly<{ // @public export type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -3914,7 +3995,7 @@ export type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public export type SerializedSchema = Readonly<{ @@ -4028,6 +4109,8 @@ export type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -4229,6 +4312,9 @@ export type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public export type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; @@ -4247,7 +4333,7 @@ export type TransactionOptions = Readonly<{ }>; // @public -export type TransactionReadBackend = Readonly; +export type TransactionReadBackend = Readonly; // @public export type TrustedImportOptions = Readonly<{ @@ -4319,8 +4405,8 @@ export const UNBUNDLED_OPTIONAL_MEMBERS: { }; readonly adoptBaseSchema: { readonly kind: "reasoned"; - readonly reason: "Privileged deployment-wide physical-schema adoption. Bundled backends implement the versioned marker lifecycle; custom backends may omit it when they provision their own base relations."; - readonly accesses: 2; + readonly reason: "Privileged deployment-wide physical-schema adoption. Bundled backends implement the versioned marker lifecycle; custom backends may omit it when they provision their own base relations. Reopening a durable PostgreSQL working copy whose allocation predates the current base schema checks for it and adopts the allocation in place."; + readonly accesses: 4; }; readonly assertBaseSchemaCurrent: { readonly kind: "reasoned"; @@ -4354,8 +4440,8 @@ export const UNBUNDLED_OPTIONAL_MEMBERS: { }; readonly tableNames: { readonly kind: "reasoned"; - readonly reason: "Physical names read by the compiler and schema-checked reads. The optional schema-version binding is required only by checked reads; its absence refuses that operation."; - readonly accesses: 28; + readonly reason: "Physical names read by the compiler and schema-checked reads. The optional schema-version binding is required only by checked reads; its absence refuses that operation. Edge acyclicity adds three readers; the schema-commit tightening probe reads the proposed acyclic and composition relations through one of them."; + readonly accesses: 31; }; readonly fenceSql: { readonly kind: "reasoned"; @@ -4372,6 +4458,11 @@ export const UNBUNDLED_OPTIONAL_MEMBERS: { readonly reason: "Same schema-version write-fence family as commitSchemaVersionIfKindsEmpty."; readonly accesses: 4; }; + readonly setActiveVersionWithPreflight: { + readonly kind: "reasoned"; + readonly reason: "Same schema-version write-fence family as commitSchemaVersionWithPreflight; rollbackSchema refuses with the tightening capability error when it is absent, and a fixed-schema working copy refuses it when present."; + readonly accesses: 2; + }; readonly lockSchemaVersionForWrite: { readonly kind: "reasoned"; readonly reason: "Same family; also the one schema member on TransactionBackend, so bundling it would re-open the accessor's B-1 port-typing question for no pilot consumer."; @@ -4634,7 +4725,7 @@ export const UNBUNDLED_OPTIONAL_MEMBERS: { readonly kind: "deferred"; readonly workstream: "WS5b"; readonly bundle: "heterogeneousEndpointSetRead"; - readonly ceiling: 5; + readonly ceiling: 7; }; readonly fulltextSearch: { readonly kind: "deferred"; diff --git a/packages/typegraph/etc/typegraph-core.api.md b/packages/typegraph/etc/typegraph-core.api.md index b01b43930..6a2bff45e 100644 --- a/packages/typegraph/etc/typegraph-core.api.md +++ b/packages/typegraph/etc/typegraph-core.api.md @@ -49,6 +49,19 @@ export type Collation = "binary" | "caseInsensitive"; // @public export function compareRecordedInstants(left: RecordedInstant, right: RecordedInstant): -1 | 0 | 1; +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + // @public export function createExternalRef(table: T): (id: string) => ExternalRefValue; @@ -83,7 +96,7 @@ export type DefineEdgeOptions, From extends }>; // @public -export function defineGraph>, const TEdges extends Record, const TOntology extends readonly OntologyRelation[], const TIdentity extends GraphIdentityConfig | undefined = undefined>(config: GraphDefConfig): GraphDef, TOntology, TIdentity>; +export function defineGraph>, const TEdges extends Record, const TOntology extends readonly OntologyRelation[] = readonly [], const TIdentity extends GraphIdentityConfig | undefined = undefined>(config: GraphDefConfig): GraphDef, TOntology, TIdentity>; // @public export function defineNode>(name: K, options: DefineNodeOptions): NodeType; @@ -137,8 +150,10 @@ export type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -235,6 +250,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -312,7 +330,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -541,9 +559,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public export function isEdgeTargetMap(value: unknown): value is EdgeTargetMap; @@ -597,29 +612,11 @@ type MetaEdge = Readonly<{ properties: MetaEdgeProperties; }>; -// @public -export function metaEdge(name: K, options?: MetaEdgeOptions): MetaEdge; - // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; -// @public -export type MetaEdgeOptions = Readonly<{ - transitive?: boolean; - symmetric?: boolean; - reflexive?: boolean; - inverse?: string; - inference?: InferenceType; - description?: string; -}>; - // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -686,7 +683,7 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; // @public export function projectTargetKinds(to: EdgeTargets): readonly string[]; @@ -726,6 +723,11 @@ type ResolvedEmbeddingIndex = Readonly<{ // @public export function resolveGraphVectorSlots(graph: GraphDef): readonly VectorSlot[]; +// @public +export type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public export function searchable(options?: SearchableOptions): SearchableSchema; @@ -750,6 +752,9 @@ export type SearchableSchema = z.ZodString & Readonly<{ // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; +// @public +export type TargetCardinality = Exclude; + // @public export type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; diff --git a/packages/typegraph/etc/typegraph-graph-extension.api.md b/packages/typegraph/etc/typegraph-graph-extension.api.md index a529a8da3..cd3b746b2 100644 --- a/packages/typegraph/etc/typegraph-graph-extension.api.md +++ b/packages/typegraph/etc/typegraph-graph-extension.api.md @@ -9,6 +9,22 @@ import { z } from 'zod'; // @public (undocumented) const ALL_META_EDGE_NAMES: readonly ["subClassOf", "broader", "narrower", "relatedTo", "equivalentTo", "sameAs", "differentFrom", "disjointWith", "partOf", "hasPart", "inverseOf", "implies"]; +// @public +type Cardinality = "many" | "one" | "unique" | "oneActive"; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + // @public export const CURRENT_GRAPH_EXTENSION_VERSION: 1; @@ -42,6 +58,9 @@ export type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -140,7 +159,7 @@ export type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -193,7 +212,7 @@ export type ExtensionUniqueWhere = Readonly<{ }>; // @public -export const GRAPH_EXTENSION_ISSUE_CODES: readonly ["UNSUPPORTED_PROPERTY_TYPE", "INVALID_PROPERTY_REFINEMENT", "NESTED_ARRAY", "NESTED_OBJECT_TOO_DEEP", "INVALID_MODIFIER_TARGET", "INVALID_ENUM_VALUES", "INVALID_NUMBER_BOUNDS", "INVALID_LENGTH_BOUNDS", "INVALID_PATTERN", "INVALID_EMBEDDING_DIMENSIONS", "INVALID_SEARCHABLE_LANGUAGE", "RESERVED_PROPERTY_NAME", "INVALID_KIND_NAME", "DUPLICATE_KIND_NAME", "EMPTY_PROPERTIES", "EMPTY_FROM_OR_TO", "DUPLICATE_UNIQUE_CONSTRAINT", "EMPTY_UNIQUE_FIELDS", "DUPLICATE_UNIQUE_FIELD", "UNKNOWN_UNIQUE_FIELD", "INVALID_UNIQUE_WHERE_OP", "UNKNOWN_UNIQUE_WHERE_FIELD", "INVALID_ANNOTATION", "UNKNOWN_META_EDGE", "ONTOLOGY_CYCLE", "ONTOLOGY_SELF_LOOP", "ONTOLOGY_DISJOINT_CONFLICT", "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS", "DUPLICATE_ONTOLOGY_RELATION", "INVALID_DOCUMENT_SHAPE", "UNKNOWN_DOCUMENT_KEY", "UNSUPPORTED_STRING_FORMAT", "INVALID_INDEX_DECLARATION", "DUPLICATE_INDEX_NAME", "EMPTY_INDEX_FIELDS", "UNKNOWN_PROPERTY_KEY"]; +export const GRAPH_EXTENSION_ISSUE_CODES: readonly ["UNSUPPORTED_PROPERTY_TYPE", "INVALID_PROPERTY_REFINEMENT", "NESTED_ARRAY", "NESTED_OBJECT_TOO_DEEP", "INVALID_MODIFIER_TARGET", "INVALID_ENUM_VALUES", "INVALID_NUMBER_BOUNDS", "INVALID_LENGTH_BOUNDS", "INVALID_PATTERN", "INVALID_EMBEDDING_DIMENSIONS", "INVALID_SEARCHABLE_LANGUAGE", "RESERVED_PROPERTY_NAME", "INVALID_KIND_NAME", "DUPLICATE_KIND_NAME", "EMPTY_PROPERTIES", "EMPTY_FROM_OR_TO", "DUPLICATE_UNIQUE_CONSTRAINT", "EMPTY_UNIQUE_FIELDS", "DUPLICATE_UNIQUE_FIELD", "UNKNOWN_UNIQUE_FIELD", "INVALID_UNIQUE_WHERE_OP", "UNKNOWN_UNIQUE_WHERE_FIELD", "INVALID_ANNOTATION", "UNKNOWN_META_EDGE", "ONTOLOGY_CYCLE", "ONTOLOGY_SELF_LOOP", "ONTOLOGY_DISJOINT_CONFLICT", "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS", "DUPLICATE_ONTOLOGY_RELATION", "ONTOLOGY_COMPOSITION_VIA_REQUIRED", "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN", "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN", "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN", "INVALID_DOCUMENT_SHAPE", "UNKNOWN_DOCUMENT_KEY", "UNSUPPORTED_STRING_FORMAT", "INVALID_INDEX_DECLARATION", "DUPLICATE_INDEX_NAME", "EMPTY_INDEX_FIELDS", "UNKNOWN_PROPERTY_KEY"]; // @public export const GRAPH_EXTENSION_TOP_LEVEL_KEYS: readonly ["version", "annotations", "nodes", "edges", "ontology", "indexes"]; @@ -351,8 +370,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -363,6 +386,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -438,6 +462,9 @@ type Result = Readonly<{ error: E; }>; +// @public +type TargetCardinality = Exclude; + // @public class TypeGraphError extends Error { constructor(message: string, code: string, options: TypeGraphErrorOptions); diff --git a/packages/typegraph/etc/typegraph-graph-merge.api.md b/packages/typegraph/etc/typegraph-graph-merge.api.md index 20deacd34..ad0401881 100644 --- a/packages/typegraph/etc/typegraph-graph-merge.api.md +++ b/packages/typegraph/etc/typegraph-graph-merge.api.md @@ -15,6 +15,32 @@ const __identityAssertionId: unique symbol; // @public const __nodeId: unique symbol; +// @public +export type AcyclicityMergeConflictDetails = Readonly<{ + relation: string; + edges: readonly AcyclicityMergeConflictEdge[]; + [key: string]: unknown; +}>; + +// @public +export type AcyclicityMergeConflictEdge = Readonly<{ + edgeId: string; + edgeKind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + +// @public +export class AcyclicityMergeConflictError extends MergeError { + constructor(message: string, options?: MergeErrorOptions); + // (undocumented) + readonly code: "GRAPH_MERGE_ACYCLICITY_CONFLICT"; + // (undocumented) + readonly details: AcyclicityMergeConflictDetails; +} + // @public (undocumented) type AddedStoreReadKey = keyof AddedStoreReadsBoundary; @@ -25,8 +51,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +116,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -148,7 +185,7 @@ export type ApplyDurableMergePlanArgs(target: Store, input: MergePlanArtifact, options?: MergePlanApplyOptions>): Promise, MergeError>>; // @public -export function applyMergePlanInTransaction(target: Store, tx: TransactionContext>, input: MergePlanArtifact): Promise>; +export function applyMergePlanInTransaction(target: Store, tx: TransactionContext>, input: MergePlanArtifact, options?: MergePlanDecisionOptions): Promise>; // @public (undocumented) type ArithmeticExpressionNode = Readonly<{ @@ -204,6 +241,19 @@ export function asBaseVersion(value: string): BaseVersion; // @public export function asBranchId(value: string): BranchId; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -302,6 +352,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -309,7 +360,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -460,8 +511,8 @@ type BatchOnceOptions = Readonly<{ // @public export type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -518,6 +569,12 @@ export type BranchProvenance = Readonly<{ edgeIds: readonly EdgeId[]; }>; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public function buildProvenanceGraph(targetGraphId: string): Readonly<{ id: string; @@ -544,11 +601,7 @@ function buildProvenanceGraph(targetGraphId: string): Readonly<{ }; }; edges: {}; - ontology: readonly Readonly<{ - metaEdge: MetaEdge; - from: NodeType | AnyEdgeType | string; - to: NodeType | AnyEdgeType | string; - }>[]; + ontology: readonly []; identity: undefined; defaults: Readonly<{ onNodeDelete: DeleteBehavior; @@ -571,12 +624,7 @@ function buildProvenanceGraph(targetGraphId: string): Readonly<{ type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -586,7 +634,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -598,7 +646,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -643,7 +691,7 @@ export type CandidateDiagnostic = Readonly<{ reason?: "noComparableValues"; clusterDisposition?: "retained" | Readonly<{ kind: "excluded"; - reason: "diameter" | "baseAmbiguity"; + reason: ClusterExclusionReason; }>; }> | Readonly<{ evidence: Extract; }>; @@ -686,6 +734,27 @@ type CandidatePair = Readonly<{ sources: readonly MatchSource[]; }>; +// @public +export type CandidatePlanningScope = Readonly<{ + scope: "candidate"; +}> | WholeTargetPlanningScope; + +// @public +export class CandidatePlanningScopeError extends MergeError { + constructor(planningScope: WholeTargetPlanningScope); + // (undocumented) + readonly code: "GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE"; + // (undocumented) + readonly details: Readonly<{ + planningScope: WholeTargetPlanningScope; + }>; + // (undocumented) + protected static readonly errorCategory = "user"; +} + +// @public +export type CandidatePlanningScopeReason = "composition" | "acyclic-relation" | "target-side-cardinality" | "missing-active-source-read" | "missing-match-identity-read" | "missing-scoped-identity-reads" | "match-identity-owner-not-clone-visible" | "planned-evolution"; + // @public export type CandidateSource = Readonly<{ readonly id: string; @@ -711,6 +780,12 @@ export class CandidateWriteSetError extends MergeError { protected static readonly errorCategory = "user"; } +// @public +export type CandidateWriteSetPlan = Readonly<{ + plan: MergePlanArtifact; + planningScope: CandidatePlanningScope; +}>; + // @public export const CandidateWriteSetSchema: z.ZodObject<{ formatVersion: z.ZodLiteral<1>; @@ -769,6 +844,43 @@ export const CandidateWriteSetSchema: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>>; + transitions: z.ZodOptional; + recordedRevision: z.ZodNumber; + recordedAt: z.ZodISODateTime; + validAt: z.ZodISODateTime; + class: z.ZodObject<{ + kind: z.ZodString; + id: z.ZodString; + }, z.core.$strip>; + priorClass: z.ZodOptional>; + assertionIds: z.ZodArray; + decision: z.ZodOptional; + branchAncestry: z.ZodOptional>; + mergePlanDigest: z.ZodOptional; + reviewDigest: z.ZodOptional; + sourceId: z.ZodOptional; + }, z.core.$strict>>; + }, z.core.$strip>>>; + retention: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; @@ -916,15 +1028,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -963,6 +1075,9 @@ export function cloneWorkingCopyStrategy(makeBackend: MakeBa refreshStatistics?: false; }>): WorkingCopyStrategy; +// @public +export type ClusterExclusionReason = "diameter" | "baseAmbiguity" | "separation"; + // @public (undocumented) type CoalesceExpressionNode = Readonly<{ kind: "coalesce"; @@ -1069,6 +1184,11 @@ type ComparisonPredicate = Readonly<{ right: FieldRef | LiteralValue | LiteralValue[] | ParameterRef; }>; +// @public +export type CompatibleMergeReview = Extract>; + // @public (undocumented) type CompatibleRelationProjection = Readonly<{ [Key in keyof Fields]: DatabaseExpression[Key]>; @@ -1109,6 +1229,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public export function computeBaseVersion(store: Store): Promise; @@ -1129,6 +1358,12 @@ export type ConflictingValue = Readonly<{ value: JsonValue; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -1138,13 +1373,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -1153,14 +1409,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -1284,11 +1540,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -1296,11 +1562,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -1312,15 +1588,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1330,6 +1598,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1349,7 +1618,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1400,13 +1669,25 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1527,6 +1808,17 @@ type DerivedRelation = Readonly<{ // @public export function destroyDurableBranch(descriptor: DurableBranchDescriptor, strategy: DurableWorkingCopyStrategy): Promise>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1929,6 +2221,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1943,9 +2242,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1961,18 +2260,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1980,18 +2279,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -2016,37 +2324,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -2103,9 +2411,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -2140,17 +2448,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -2166,9 +2483,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -2179,13 +2496,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -2219,6 +2539,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -2227,12 +2550,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -2275,8 +2612,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -2307,6 +2646,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -2367,6 +2709,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -2468,6 +2820,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -2476,6 +2832,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2836,6 +3197,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2934,7 +3298,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -3411,7 +3775,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -3456,9 +3820,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -3635,12 +4000,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3672,9 +4037,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3683,6 +4053,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3691,6 +4064,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3888,6 +4270,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +export type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3898,13 +4289,48 @@ type IdentityFacade = IdentityReadFacade & Readonly<{ retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; }>; +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; +}>; + +// @public +export type IdentityMergeConflictCode = typeof MERGE_ERROR_CODES.identityConflict | typeof MERGE_ERROR_CODES.identitySeparationConflict; + // @public export class IdentityMergeConflictError extends MergeError { - constructor(message: string, options?: MergeErrorOptions); + constructor(message: string, options?: MergeErrorOptions & Readonly<{ + code?: IdentityMergeConflictCode; + }>); // (undocumented) - readonly code: "GRAPH_MERGE_IDENTITY_CONFLICT"; + readonly code: IdentityMergeConflictCode; } // @public @@ -3937,26 +4363,115 @@ type IdentityReadFacade = Readonly<{ }>; // @public -type IdentityRelation = "same" | "different"; +export type IdentityRelation = "same" | "different"; + +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + +// @public +type IdentitySamePathStep = Readonly<{ + from: IdentityNodeReference; + to: IdentityNodeReference; + via: Readonly<{ + type: "assertion"; + assertion: IdentityAssertion; + }> | Readonly<{ + type: "same-id-fold"; + }>; +}>; + +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + +// @public +type IdentityTableNames = Readonly<{ + identityAssertions: string; + recordedIdentityAssertions: string; + identityClosure: string; + identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public (undocumented) +export type IdentityTransferAssertion = Readonly<{ + id: string; + relation: "same" | "different"; + a: PlainNodeRef; + b: PlainNodeRef; + validFrom: string; + validTo?: string | undefined; + endedBy?: PlainNodeRef | undefined; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; // @public -type IdentitySamePathStep = Readonly<{ - from: IdentityNodeReference; - to: IdentityNodeReference; - via: Readonly<{ - type: "assertion"; - assertion: IdentityAssertion; - }> | Readonly<{ - type: "same-id-fold"; - }>; +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; }>; // @public -type IdentityTableNames = Readonly<{ - identityAssertions: string; - recordedIdentityAssertions: string; - identityClosure: string; - identitySeparation: string; +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3966,6 +4481,15 @@ type IdentityTraversalOption = G["identity"] extends GraphId includeIdentityMembers?: never; }>; +// @public +export type IdentityUnresolvedConflict = Readonly<{ + kind: "separation"; + a: EntityRef; + b: EntityRef; + assertionIds: readonly string[]; + source?: MatchSource | undefined; +}>; + // @public type IdentityValidityWindow = Readonly<{ validFrom?: string; @@ -3977,6 +4501,7 @@ type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -4100,9 +4625,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public (undocumented) const INGESTION_BRANCH_BRAND: unique symbol; @@ -4224,7 +4746,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -4257,6 +4779,14 @@ export class InvalidMergePlanError extends MergeError { protected static readonly errorCategory = "user"; } +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -4312,8 +4842,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -4324,6 +4858,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -4359,6 +4894,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -4369,25 +4907,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -4398,6 +4936,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -4429,6 +4968,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -4495,6 +5037,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -4528,7 +5076,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -4557,6 +5105,9 @@ type MapRowToMeta>, M extends Readonl // @public export function markDurableOperationDelivered(descriptor: DurableBranchDescriptor, strategy: DurableWorkingCopyStrategy, idempotencyKey: string): Promise>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -4710,6 +5261,8 @@ export const MERGE_ERROR_CODES: { readonly conflict: "GRAPH_MERGE_CONFLICT"; readonly constraintConflict: "GRAPH_MERGE_CONSTRAINT_CONFLICT"; readonly identityConflict: "GRAPH_MERGE_IDENTITY_CONFLICT"; + readonly identitySeparationConflict: "GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT"; + readonly acyclicityConflict: "GRAPH_MERGE_ACYCLICITY_CONFLICT"; readonly baseVersionMismatch: "GRAPH_MERGE_BASE_VERSION_MISMATCH"; readonly planCapability: "GRAPH_MERGE_PLAN_CAPABILITY"; readonly planInvalid: "GRAPH_MERGE_PLAN_INVALID"; @@ -4723,6 +5276,7 @@ export const MERGE_ERROR_CODES: { readonly candidateSource: "GRAPH_MERGE_CANDIDATE_SOURCE"; readonly evidence: "GRAPH_MERGE_EVIDENCE"; readonly candidateWriteSet: "GRAPH_MERGE_CANDIDATE_WRITE_SET"; + readonly candidatePlanningScope: "GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE"; readonly review: "GRAPH_MERGE_REVIEW"; readonly operation: "GRAPH_MERGE_OPERATION"; readonly operationRequest: "GRAPH_MERGE_OPERATION_REQUEST"; @@ -4730,6 +5284,7 @@ export const MERGE_ERROR_CODES: { readonly operationUnsupported: "GRAPH_MERGE_OPERATION_UNSUPPORTED"; readonly operationEvidence: "GRAPH_MERGE_OPERATION_EVIDENCE"; readonly operationUndelivered: "GRAPH_MERGE_OPERATION_UNDELIVERED"; + readonly compositionOrphan: "GRAPH_MERGE_COMPOSITION_ORPHAN"; }; // @public @@ -4747,20 +5302,28 @@ export const MERGE_OPTION_DEFAULTS: { export const MERGE_PLAN_DIGEST_ALGORITHM: "sha256"; // @public (undocumented) -export const MERGE_PLAN_FORMAT_VERSION: 1; +export const MERGE_PLAN_FORMAT_VERSION: 2; // @public -export const MERGE_REVIEW_FORMAT_VERSION: 1; +export const MERGE_REVIEW_FORMAT_VERSION: 3; -// @public (undocumented) -export const MERGE_REVIEW_FORMAT_VERSION_V1: 1; - -// @public (undocumented) -export const MERGE_REVIEW_FORMAT_VERSION_V2: 2; +// @public +export const MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED: 4; // @public export type MergeBranch = GraphBranch | IngestionBranch; +// @public +export class MergeCompositionOrphanError extends MergeError { + constructor(details: MergePlanCompositionOrphan); + // (undocumented) + readonly code: "GRAPH_MERGE_COMPOSITION_ORPHAN"; + // (undocumented) + readonly details: MergePlanCompositionOrphan; + // (undocumented) + protected static readonly errorCategory = "constraint"; +} + // @public export class MergeConflictError extends MergeError { constructor(message: string, options?: MergeErrorOptions); @@ -4809,7 +5372,7 @@ export class MergeError extends TypeGraphError { } // @public -type MergeErrorOptions = Readonly<{ +export type MergeErrorOptions = Readonly<{ details?: Record; suggestion?: string; cause?: unknown; @@ -4875,16 +5438,16 @@ export type MergePlanApplied = Readonly<{ }>; // @public -export type MergePlanApplyOptions = Readonly<{ +export type MergePlanApplyOptions = MergePlanDecisionOptions & Readonly<{ beforeApply?: (reads: MergePlanReadContext) => Promise; afterApply?: (tx: TransactionContext, applied: MergePlanApplied) => Promise; }>; // @public -export type MergePlanArtifact = MergePlanArtifactV1; +export type MergePlanArtifact = MergePlanArtifactV2; // @public (undocumented) -export type MergePlanArtifactV1 = Readonly<{ +export type MergePlanArtifactV2 = Readonly<{ formatVersion: typeof MERGE_PLAN_FORMAT_VERSION; digest: MergePlanDigest; mode: "snapshot" | "incremental"; @@ -4898,7 +5461,7 @@ export type MergePlanArtifactV1 = Readonly<{ }>; // @public (undocumented) -export type MergePlanArtifactV1Input = Omit; +export type MergePlanArtifactV2Input = Omit; // @public (undocumented) export type MergePlanBranchAnchor = Readonly<{ @@ -4913,7 +5476,7 @@ export type MergePlanCandidateDiagnostic = Readonly<{ reason?: "noComparableValues" | undefined; clusterDisposition?: "retained" | Readonly<{ kind: "excluded"; - reason: "diameter" | "baseAmbiguity"; + reason: ClusterExclusionReason; }> | undefined; }>; @@ -4932,6 +5495,21 @@ export class MergePlanCapabilityError extends MergeError { protected static readonly errorCategory = "user"; } +// @public +export type MergePlanCompositionOrphan = Readonly<{ + part: MergePlanEntityRef; + whole?: MergePlanEntityRef; + viaEdgeKind: string; + cause: "deleted" | "moved" | "unattached"; + branchId?: string; +}>; + +// @public +export type MergePlanDecisionOptions = Readonly<{ + review?: CompatibleMergeReview; + sourceId?: string; +}>; + // @public (undocumented) export type MergePlanDiagnostics = Readonly<{ entries: readonly MergePlanCandidateDiagnostic[]; @@ -4966,6 +5544,7 @@ export type MergePlanEdgeUpsert = Readonly<{ unsetProps: readonly string[]; validFrom?: string | null | undefined; validTo?: string | undefined; + clearValidTo?: true | undefined; }>; // @public (undocumented) @@ -5068,6 +5647,7 @@ export type MergePlanNodeUpsert = Readonly<{ unsetProps: readonly string[]; validFrom?: string | null | undefined; validTo?: string | undefined; + clearValidTo?: true | undefined; }>; // @public @@ -5128,7 +5708,9 @@ export type MergePlanReview = Readonly<{ baseAmbiguities: readonly JsonValue[]; provenanceRecords: readonly JsonValue[]; warnings: readonly string[]; + compositionOrphans: readonly MergePlanCompositionOrphan[]; diagnostics?: MergePlanDiagnostics | undefined; + identityConflicts?: readonly JsonValue[] | undefined; }>; // @public (undocumented) @@ -5215,11 +5797,12 @@ export type MergeReport = Readonly<{ graphId: string; count: number; }>; + identityConflicts: readonly IdentityUnresolvedConflict[]; }>; // @public export type MergeReviewArtifact = Readonly<{ - formatVersion: typeof MERGE_REVIEW_FORMAT_VERSION_V1 | typeof MERGE_REVIEW_FORMAT_VERSION_V2; + formatVersion: MergeReviewFormatVersion; kind: "candidate-write-set"; digest: MergePlanDigest; writeSet: CandidateWriteSet; @@ -5256,6 +5839,9 @@ export class MergeReviewError extends MergeError { protected static readonly errorCategory = "user"; } +// @public (undocumented) +type MergeReviewFormatVersion = (typeof SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS)[number]; + // @public export type MergeReviewPolicy = Readonly<{ id: string; @@ -5295,14 +5881,19 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public export type NamespaceFork = Readonly<{ store: Store; @@ -5361,7 +5952,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -5373,7 +5964,7 @@ type Node = Readonly<{ }> & Readonly>; // @public (undocumented) -const NODE_READ_NAMES: readonly ["getById", "getByIds", "find", "count", "findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const NODE_READ_NAMES: readonly ["getById", "getByIds", "find", "count", "findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const NODE_TYPE_BRAND: "__nodeType"; @@ -5424,12 +6015,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -5452,19 +6039,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -5473,12 +6062,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -5503,10 +6089,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -5536,9 +6122,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -5546,8 +6158,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -5659,6 +6272,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -5666,6 +6302,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -5847,10 +6494,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -5858,6 +6533,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -5866,7 +6544,10 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; // @public export function openProvenanceStore(target: Store): Promise>; @@ -5878,13 +6559,18 @@ export function openProvenanceStore(backend: GraphBackend, targetGraphId: string export function operateDurableBranch(descriptor: DurableBranchDescriptor, strategy: DurableWorkingCopyStrategy, request: DurableBranchOperationRequest): Promise>; // @public -type OperationHookContext = HookContext & Readonly<{ +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -5917,7 +6603,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -5992,7 +6678,13 @@ class Placeholder { } // @public -export function planCandidateWriteSet(args: PlanCandidateWriteSetArgs): Promise>; +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +export function planCandidateWriteSet(args: PlanCandidateWriteSetArgs): Promise>; // @public export type PlanCandidateWriteSetArgs = Readonly<{ @@ -6000,13 +6692,14 @@ export type PlanCandidateWriteSetArgs = Readonly<{ makeBackend: MakeBackend; writeSet: unknown; options?: Omit, "target">; + planningScope?: "candidate"; }>; // @public -export function planCandidateWriteSetForEvolution(args: PlanCandidateWriteSetForEvolutionArgs): Promise>; +export function planCandidateWriteSetForEvolution(args: PlanCandidateWriteSetForEvolutionArgs): Promise>; // @public -export type PlanCandidateWriteSetForEvolutionArgs = Omit, "target"> & Readonly<{ +export type PlanCandidateWriteSetForEvolutionArgs = Omit, "target" | "planningScope"> & Readonly<{ target: Store; evolutionPlan: EvolutionPlan; }>; @@ -6050,6 +6743,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -6098,6 +6796,16 @@ type PreparedQueryConfig = Readonly<{ // @public export function prepareNamespaceForkTarget(source: Store, targetBackend: GraphBackend): Promise; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -6277,21 +6985,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -6321,6 +7047,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -6347,6 +7074,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -6355,6 +7083,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -6366,8 +7095,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -6411,7 +7141,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -6438,6 +7168,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -6514,6 +7245,19 @@ export type RecordedForkPoint = Readonly<{ base: BaseVersion; }>; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -6565,13 +7309,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -6699,6 +7443,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -6865,6 +7626,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -6884,7 +7647,7 @@ export type ResolveMap = Readonly>; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -6895,6 +7658,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public export type Result = Readonly<{ success: true; @@ -6977,6 +7753,7 @@ export function scanDurableOperations Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -6997,6 +7774,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -7027,7 +7809,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -7064,11 +7847,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -7154,11 +7942,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -7166,11 +7956,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -7196,7 +7981,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -7305,6 +8090,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SourceResult = Readonly<{ pairs: readonly CandidatePair[]; @@ -7387,6 +8178,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -7415,6 +8210,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -7432,6 +8229,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -7455,6 +8254,11 @@ export class StaleMergePlanError extends MergeError { readonly code: string; } +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -7490,6 +8294,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -7516,12 +8321,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -7609,6 +8414,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -7617,11 +8423,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -7776,6 +8587,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -7797,6 +8617,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -7831,7 +8668,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -7844,10 +8681,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -7916,33 +8765,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -8009,7 +8858,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -8049,6 +8898,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -8082,13 +8936,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -8102,13 +8957,65 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + +// @public +const SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS: readonly [3, 4]; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -8144,6 +9051,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -8210,6 +9120,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -8220,9 +9131,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -8238,7 +9149,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -8248,15 +9159,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -8270,34 +9197,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -8317,13 +9259,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -8593,6 +9535,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -8746,6 +9694,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -8767,6 +9716,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; @@ -8971,6 +9922,12 @@ type WeightedShortestPathResult = Readonly<{ totalWeight: number; }>; +// @public +export type WholeTargetPlanningScope = Readonly<{ + scope: "whole-target"; + reason: CandidatePlanningScopeReason; +}>; + // @public type WidenBrandedIds = { readonly [K in keyof T]: T[K] extends (...args: infer A) => infer R ? (...args: { diff --git a/packages/typegraph/etc/typegraph-interchange.api.md b/packages/typegraph/etc/typegraph-interchange.api.md index 7d5fc2652..7119b3774 100644 --- a/packages/typegraph/etc/typegraph-interchange.api.md +++ b/packages/typegraph/etc/typegraph-interchange.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -390,8 +415,8 @@ type BatchOnceOptions = Readonly<{ // @public export type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -418,16 +443,17 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -437,7 +463,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -449,7 +475,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -610,15 +636,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -794,6 +820,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -808,10 +943,16 @@ export type ConflictStrategy = z.infer; // @public export const ConflictStrategySchema: z.ZodEnum<{ error: "error"; - update: "update"; skip: "skip"; + update: "update"; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -821,13 +962,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -836,14 +998,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -967,11 +1129,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -979,11 +1151,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -995,15 +1177,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1013,6 +1187,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1032,7 +1207,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1083,13 +1258,25 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1195,6 +1382,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1338,6 +1536,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1352,9 +1557,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1370,18 +1575,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1389,18 +1594,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1425,37 +1639,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1512,9 +1726,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1549,17 +1763,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1575,9 +1798,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1588,13 +1811,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1628,6 +1854,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1636,12 +1865,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1684,8 +1927,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1716,6 +1961,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1773,6 +2021,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1851,6 +2109,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1859,6 +2121,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2267,6 +2534,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2365,7 +2635,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2610,7 +2880,7 @@ type FindRowsAcrossKindsParams = Readonly<{ }>; // @public -export const FORMAT_VERSION: "2.0"; +export const FORMAT_VERSION: "3.0"; // @public type FulltextAccessor = Readonly<{ @@ -2822,7 +3092,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2867,9 +3137,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -3029,8 +3300,9 @@ export const GraphDataHeaderSchema: z.ZodObject<{ description: z.ZodOptional; }, z.core.$strip>], "type">; formatVersion: z.ZodEnum<{ - "2.0": "2.0"; + "3.0": "3.0"; "1.0": "1.0"; + "2.0": "2.0"; }>; exportedAt: z.ZodISODateTime; identity: z.ZodOptional; profile: z.ZodLiteral<"typegraph-identity-v1">; + retention: z.ZodOptional>; + hasTransitions: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; // @public export const GraphDataSchema: z.ZodObject<{ formatVersion: z.ZodEnum<{ - "2.0": "2.0"; + "3.0": "3.0"; "1.0": "1.0"; + "2.0": "2.0"; }>; exportedAt: z.ZodISODateTime; source: z.ZodDiscriminatedUnion<[z.ZodObject<{ @@ -3115,6 +3393,43 @@ export const GraphDataSchema: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>>; + transitions: z.ZodOptional; + recordedRevision: z.ZodNumber; + recordedAt: z.ZodISODateTime; + validAt: z.ZodISODateTime; + class: z.ZodObject<{ + kind: z.ZodString; + id: z.ZodString; + }, z.core.$strip>; + priorClass: z.ZodOptional>; + assertionIds: z.ZodArray; + decision: z.ZodOptional; + branchAncestry: z.ZodOptional>; + mergePlanDigest: z.ZodOptional; + reviewDigest: z.ZodOptional; + sourceId: z.ZodOptional; + }, z.core.$strict>>; + }, z.core.$strip>>>; + retention: z.ZodOptional>; }, z.core.$strip>>; }, z.core.$strip>; @@ -3151,12 +3466,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3199,8 +3514,9 @@ export const GraphInterchangeChunkSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ description: z.ZodOptional; }, z.core.$strip>], "type">; formatVersion: z.ZodEnum<{ - "2.0": "2.0"; + "3.0": "3.0"; "1.0": "1.0"; + "2.0": "2.0"; }>; exportedAt: z.ZodISODateTime; identity: z.ZodOptional; profile: z.ZodLiteral<"typegraph-identity-v1">; + retention: z.ZodOptional>; + hasTransitions: z.ZodOptional; }, z.core.$strip>>; }, z.core.$strip>; }, z.core.$strip>, z.ZodObject<{ @@ -3269,6 +3590,41 @@ export const GraphInterchangeChunkSchema: z.ZodDiscriminatedUnion<[z.ZodObject<{ id: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>>; +}, z.core.$strip>, z.ZodObject<{ + type: z.ZodLiteral<"identity-transitions">; + transitions: z.ZodArray; + recordedRevision: z.ZodNumber; + recordedAt: z.ZodISODateTime; + validAt: z.ZodISODateTime; + class: z.ZodObject<{ + kind: z.ZodString; + id: z.ZodString; + }, z.core.$strip>; + priorClass: z.ZodOptional>; + assertionIds: z.ZodArray; + decision: z.ZodOptional; + branchAncestry: z.ZodOptional>; + mergePlanDigest: z.ZodOptional; + reviewDigest: z.ZodOptional; + sourceId: z.ZodOptional; + }, z.core.$strict>>; + }, z.core.$strip>>; }, z.core.$strip>], "type">; // @public (undocumented) @@ -3276,9 +3632,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3287,6 +3648,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3295,6 +3659,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3489,6 +3862,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3499,6 +3881,36 @@ type IdentityFacade = IdentityReadFacade & Readonly<{ retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public (undocumented) @@ -3542,6 +3954,34 @@ type IdentityReadFacade = Readonly<{ // @public type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3554,12 +3994,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3580,6 +4070,7 @@ type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3614,12 +4105,12 @@ export type ImportOptions = z.input; export const ImportOptionsSchema: z.ZodObject<{ onConflict: z.ZodEnum<{ error: "error"; - update: "update"; skip: "skip"; + update: "update"; }>; onUnknownProperty: z.ZodDefault>; validateReferences: z.ZodDefault; @@ -3783,9 +4274,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public const INGESTION_IMPORT_TARGET_BRAND: unique symbol; @@ -3898,6 +4386,15 @@ export const InterchangeIdentityAssertionSchema: z.ZodObject<{ }, z.core.$strip>>; }, z.core.$strip>; +// @public (undocumented) +export type InterchangeIdentityRetention = z.infer; + +// @public +export const InterchangeIdentityRetentionSchema: z.ZodObject<{ + prunedBeforeRevision: z.ZodNumber; + prunedAt: z.ZodISODateTime; +}, z.core.$strip>; + // @public (undocumented) export const InterchangeIdentitySchema: z.ZodObject<{ profile: z.ZodLiteral<"typegraph-identity-v1">; @@ -3926,6 +4423,81 @@ export const InterchangeIdentitySchema: z.ZodObject<{ id: z.ZodString; }, z.core.$strip>>; }, z.core.$strip>>; + transitions: z.ZodOptional; + recordedRevision: z.ZodNumber; + recordedAt: z.ZodISODateTime; + validAt: z.ZodISODateTime; + class: z.ZodObject<{ + kind: z.ZodString; + id: z.ZodString; + }, z.core.$strip>; + priorClass: z.ZodOptional>; + assertionIds: z.ZodArray; + decision: z.ZodOptional; + branchAncestry: z.ZodOptional>; + mergePlanDigest: z.ZodOptional; + reviewDigest: z.ZodOptional; + sourceId: z.ZodOptional; + }, z.core.$strict>>; + }, z.core.$strip>>>; + retention: z.ZodOptional>; +}, z.core.$strip>; + +// @public (undocumented) +export type InterchangeIdentityTransition = z.infer; + +// @public +export const InterchangeIdentityTransitionSchema: z.ZodObject<{ + transitionId: z.ZodString; + cause: z.ZodEnum<{ + fold: "fold"; + assert: "assert"; + retract: "retract"; + detach: "detach"; + restore: "restore"; + "window-end": "window-end"; + "kind-drop": "kind-drop"; + "schema-transition": "schema-transition"; + reconcile: "reconcile"; + }>; + recordedRevision: z.ZodNumber; + recordedAt: z.ZodISODateTime; + validAt: z.ZodISODateTime; + class: z.ZodObject<{ + kind: z.ZodString; + id: z.ZodString; + }, z.core.$strip>; + priorClass: z.ZodOptional>; + assertionIds: z.ZodArray; + decision: z.ZodOptional; + branchAncestry: z.ZodOptional>; + mergePlanDigest: z.ZodOptional; + reviewDigest: z.ZodOptional; + sourceId: z.ZodOptional; + }, z.core.$strict>>; }, z.core.$strip>; // @public (undocumented) @@ -3984,7 +4556,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3999,6 +4571,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -4042,8 +4622,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -4054,6 +4638,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -4083,6 +4668,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -4093,25 +4681,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -4122,6 +4710,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -4153,6 +4742,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -4219,6 +4811,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -4249,7 +4847,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -4275,6 +4873,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -4362,14 +4963,19 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -4412,7 +5018,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4472,12 +5078,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4500,19 +5102,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4521,12 +5125,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4551,10 +5152,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4584,9 +5185,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4594,8 +5221,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4707,6 +5335,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4714,6 +5365,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4872,10 +5534,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4883,6 +5573,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4891,16 +5584,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; // @public -type OperationHookContext = HookContext & Readonly<{ +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; + +// @public +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4933,7 +5634,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -5004,6 +5705,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -5025,6 +5732,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -5070,6 +5782,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -5187,21 +5909,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -5231,6 +5971,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -5257,6 +5998,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -5265,6 +6007,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -5276,8 +6019,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -5321,7 +6065,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -5348,6 +6092,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5412,6 +6157,19 @@ const RECORDED_INSTANT_BRAND: unique symbol; // @public const RECORDED_POINT_READ_NAMES: readonly ["getById", "getByIds"]; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5463,13 +6221,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5597,6 +6355,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5738,6 +6513,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5752,7 +6529,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5763,6 +6540,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public type RowProps = string | Readonly>; @@ -5822,6 +6612,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5842,6 +6633,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5872,7 +6668,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5909,11 +6706,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5999,11 +6801,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -6011,11 +6815,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -6041,7 +6840,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -6120,6 +6919,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -6184,6 +6989,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -6212,6 +7021,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -6229,6 +7040,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -6245,6 +7058,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -6280,6 +7098,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -6306,12 +7125,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6399,6 +7218,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6407,11 +7227,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6566,6 +7391,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6587,6 +7421,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6621,7 +7472,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6634,10 +7485,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6706,33 +7569,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6799,7 +7662,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6839,6 +7702,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6872,13 +7740,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6892,13 +7761,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -6934,6 +7852,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -7000,6 +7921,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -7010,9 +7932,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -7028,7 +7950,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -7038,15 +7960,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -7060,34 +7998,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -7119,13 +8072,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -7368,6 +8321,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7387,8 +8346,8 @@ export type UnknownPropertyStrategy = z.infer; @@ -7503,6 +8462,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7524,6 +8484,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph-postgres-pglite.api.md b/packages/typegraph/etc/typegraph-postgres-pglite.api.md index b5e70a950..909844f9e 100644 --- a/packages/typegraph/etc/typegraph-postgres-pglite.api.md +++ b/packages/typegraph/etc/typegraph-postgres-pglite.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -390,8 +415,8 @@ type BatchOnceOptions = Readonly<{ // @public type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -418,16 +443,17 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -437,7 +463,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -449,7 +475,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -610,15 +636,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -794,6 +820,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -802,6 +937,12 @@ type ConditionalExpressionNode = Readonly<{ otherwise: DatabaseExpression; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -811,13 +952,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -826,14 +988,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -957,11 +1119,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -969,11 +1141,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -985,15 +1167,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1003,6 +1177,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1041,7 +1216,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1092,13 +1267,25 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1204,6 +1391,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1347,6 +1545,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1361,9 +1566,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1379,18 +1584,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1398,18 +1603,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1434,37 +1648,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1521,9 +1735,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1558,17 +1772,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1584,9 +1807,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1597,13 +1820,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1637,6 +1863,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1645,12 +1874,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1693,8 +1936,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1725,6 +1970,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1782,6 +2030,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1860,6 +2118,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1868,6 +2130,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2228,6 +2495,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2326,7 +2596,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2780,7 +3050,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2825,9 +3095,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -2990,12 +3261,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3027,9 +3298,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3038,6 +3314,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3046,6 +3325,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3280,6 +3568,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public export type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3290,6 +3587,36 @@ export type IdentityFacade = IdentityReadFacade & Readonl retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -3324,6 +3651,34 @@ export type IdentityReadFacade = Readonly<{ // @public export type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3336,12 +3691,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3362,6 +3767,7 @@ export type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3485,9 +3891,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InitialQueryBuilder = QueryBuilder; @@ -3578,7 +3981,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3593,6 +3996,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -3636,8 +4047,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3648,6 +4063,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3677,6 +4093,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -3687,25 +4106,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -3716,6 +4135,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -3747,6 +4167,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -3813,6 +4236,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -3852,7 +4281,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3878,6 +4307,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -3970,11 +4402,6 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -3986,6 +4413,16 @@ type MigrationHookContext = Readonly<{ diff: SchemaDiff; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -4028,7 +4465,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4088,12 +4525,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4116,19 +4549,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4137,12 +4572,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4167,10 +4599,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4200,9 +4632,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4210,8 +4668,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4323,6 +4782,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4330,6 +4812,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4488,10 +4981,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4499,6 +5020,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4507,16 +5031,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; // @public -type OperationHookContext = HookContext & Readonly<{ +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4549,7 +5081,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -4620,6 +5152,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -4641,6 +5179,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -4686,6 +5229,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -4803,21 +5356,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -4847,6 +5418,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -4873,6 +5445,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -4881,6 +5454,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -4892,8 +5466,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -4937,7 +5512,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -4964,6 +5539,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5033,6 +5609,19 @@ type RecordedHeterogeneousNodeWriteBatch = Readonly<{ writeNodeUpsertBatch: []>(entries: Entries) => Promise>; }>; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5099,13 +5688,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5233,6 +5822,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5374,6 +5980,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5388,7 +5996,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5399,6 +6007,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public type RowProps = string | Readonly>; @@ -5458,6 +6079,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5478,6 +6100,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5508,7 +6135,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5555,11 +6183,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5645,11 +6278,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5657,11 +6292,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5687,7 +6317,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5766,6 +6396,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -5830,6 +6466,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -5858,6 +6498,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -5875,6 +6517,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5891,6 +6535,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -5926,6 +6575,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -5952,12 +6602,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6048,6 +6698,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6056,11 +6707,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6215,6 +6871,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6236,6 +6901,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6270,7 +6952,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6283,10 +6965,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6355,33 +7049,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6448,7 +7142,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6488,6 +7182,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6521,13 +7220,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6541,13 +7241,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -6583,6 +7332,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -6649,6 +7401,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -6659,9 +7412,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -6677,7 +7430,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -6687,15 +7440,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -6709,34 +7478,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -6756,13 +7540,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -7010,6 +7794,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7135,6 +7925,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7156,6 +7947,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph-profiler.api.md b/packages/typegraph/etc/typegraph-profiler.api.md index af6be1e0c..30fb0b632 100644 --- a/packages/typegraph/etc/typegraph-profiler.api.md +++ b/packages/typegraph/etc/typegraph-profiler.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -390,8 +415,8 @@ type BatchOnceOptions = Readonly<{ // @public export type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -418,16 +443,17 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -437,7 +463,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -449,7 +475,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -610,15 +636,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -794,6 +820,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -802,6 +937,12 @@ type ConditionalExpressionNode = Readonly<{ otherwise: DatabaseExpression; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -811,13 +952,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -826,14 +988,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -957,11 +1119,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -969,11 +1141,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -985,15 +1167,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1003,6 +1177,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1022,7 +1197,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1073,6 +1248,15 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public export type DeclaredIndex = Readonly<{ entityType: ProfileEntityType; @@ -1085,10 +1269,13 @@ export type DeclaredIndex = Readonly<{ // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1194,6 +1381,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1337,6 +1535,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1351,9 +1556,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1369,18 +1574,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1388,18 +1593,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1424,37 +1638,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1511,9 +1725,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1548,17 +1762,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1574,9 +1797,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1587,13 +1810,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1627,6 +1853,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1635,12 +1864,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1683,8 +1926,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1715,6 +1960,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1772,6 +2020,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1850,6 +2108,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1858,6 +2120,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2218,6 +2485,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2316,7 +2586,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2770,7 +3040,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2815,9 +3085,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -2980,12 +3251,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3017,9 +3288,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3028,6 +3304,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3036,6 +3315,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3230,6 +3518,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3240,6 +3537,36 @@ type IdentityFacade = IdentityReadFacade & Readonly<{ retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -3274,6 +3601,34 @@ type IdentityReadFacade = Readonly<{ // @public type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3286,12 +3641,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3312,6 +3717,7 @@ type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3445,9 +3851,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InitialQueryBuilder = QueryBuilder; @@ -3538,7 +3941,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3553,6 +3956,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -3596,8 +4007,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3608,6 +4023,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3637,6 +4053,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -3647,25 +4066,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -3676,6 +4095,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -3707,6 +4127,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -3773,6 +4196,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -3803,7 +4232,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3829,6 +4258,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -3916,14 +4348,19 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -3966,7 +4403,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4026,12 +4463,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4054,19 +4487,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4075,12 +4510,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4105,10 +4537,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4138,9 +4570,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4148,8 +4606,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4261,6 +4720,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4268,6 +4750,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4426,10 +4919,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4437,6 +4958,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4445,16 +4969,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; // @public -type OperationHookContext = HookContext & Readonly<{ +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; + +// @public +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4487,7 +5019,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -4558,6 +5090,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -4579,6 +5117,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -4624,6 +5167,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public export type ProfiledStore = Store & { readonly profiler: QueryProfiler; @@ -4798,21 +5351,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -4842,6 +5413,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -4868,6 +5440,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -4876,6 +5449,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -4887,8 +5461,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -4944,7 +5519,7 @@ export class QueryProfiler { type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -4971,6 +5546,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5038,6 +5614,19 @@ const RECORDED_INSTANT_BRAND: unique symbol; // @public const RECORDED_POINT_READ_NAMES: readonly ["getById", "getByIds"]; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5089,13 +5678,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5223,6 +5812,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5364,6 +5970,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5378,7 +5986,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5389,6 +5997,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public type RowProps = string | Readonly>; @@ -5448,6 +6069,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5468,6 +6090,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5498,7 +6125,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5535,11 +6163,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5625,11 +6258,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5637,11 +6272,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5667,7 +6297,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5746,6 +6376,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -5810,6 +6446,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -5838,6 +6478,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -5855,6 +6497,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5871,6 +6515,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -5906,6 +6555,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -5932,12 +6582,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6025,6 +6675,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6033,11 +6684,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6192,6 +6848,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6213,6 +6878,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6247,7 +6929,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6260,10 +6942,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6332,33 +7026,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6425,7 +7119,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6465,6 +7159,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6498,13 +7197,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6518,13 +7218,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -6560,6 +7309,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -6626,6 +7378,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -6636,9 +7389,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -6654,7 +7407,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -6664,15 +7417,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -6686,34 +7455,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -6733,13 +7517,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -6982,6 +7766,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7110,6 +7900,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7131,6 +7922,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph-provenance.api.md b/packages/typegraph/etc/typegraph-provenance.api.md index 4c2e2eedf..937b64f9c 100644 --- a/packages/typegraph/etc/typegraph-provenance.api.md +++ b/packages/typegraph/etc/typegraph-provenance.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -390,8 +415,8 @@ type BatchOnceOptions = Readonly<{ // @public export type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -418,16 +443,17 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -437,7 +463,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -449,7 +475,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -610,15 +636,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -794,6 +820,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -802,6 +937,12 @@ type ConditionalExpressionNode = Readonly<{ otherwise: DatabaseExpression; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -811,13 +952,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -826,14 +988,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -957,11 +1119,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -969,11 +1141,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -985,15 +1167,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1003,6 +1177,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public (undocumented) @@ -1025,7 +1200,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1076,13 +1251,25 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1188,6 +1375,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1331,6 +1529,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1345,9 +1550,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1363,18 +1568,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1382,18 +1587,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1418,37 +1632,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1505,9 +1719,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1542,17 +1756,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1568,9 +1791,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1581,13 +1804,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1621,6 +1847,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1629,7 +1858,9 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; @@ -1638,6 +1869,18 @@ type EdgeIntrospection = Readonly<{ // @public (undocumented) type EdgeKind = Extract; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1680,8 +1923,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1712,6 +1957,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1769,6 +2017,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1847,6 +2105,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1855,6 +2117,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2215,6 +2482,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2313,7 +2583,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2770,7 +3040,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2815,9 +3085,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -2980,12 +3251,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3017,9 +3288,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3028,6 +3304,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3036,6 +3315,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3264,6 +3552,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3274,6 +3571,36 @@ type IdentityFacade = IdentityReadFacade & Readonly<{ retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -3308,6 +3635,34 @@ type IdentityReadFacade = Readonly<{ // @public type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3320,12 +3675,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3346,6 +3751,7 @@ type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3469,9 +3875,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InitialQueryBuilder = QueryBuilder; @@ -3562,7 +3965,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3577,6 +3980,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -3620,8 +4031,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3632,6 +4047,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3664,6 +4080,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -3674,25 +4093,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -3703,6 +4122,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -3734,6 +4154,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -3807,6 +4230,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -3837,7 +4266,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3863,6 +4292,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -3955,14 +4387,19 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -4005,7 +4442,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4065,12 +4502,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4093,19 +4526,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4114,12 +4549,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4144,10 +4576,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4177,9 +4609,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4187,8 +4645,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4303,6 +4762,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4310,6 +4792,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4468,10 +4961,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4479,6 +5000,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4487,16 +5011,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; // @public -type OperationHookContext = HookContext & Readonly<{ +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4529,7 +5061,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -4600,6 +5132,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -4621,6 +5159,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -4666,6 +5209,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -4821,21 +5374,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -4865,6 +5436,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -4891,6 +5463,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -4899,6 +5472,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -4910,8 +5484,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -4955,7 +5530,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -4982,6 +5557,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5051,6 +5627,19 @@ type RecordedHeterogeneousNodeWriteBatch = Readonly<{ writeNodeUpsertBatch: []>(entries: Entries) => Promise>; }>; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5107,13 +5696,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5241,6 +5830,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5382,6 +5988,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5396,7 +6004,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5407,6 +6015,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public (undocumented) export type RetractionCapability = NodeKind, FactKind extends NodeKind = NodeKind, JustificationKind extends NodeKind = NodeKind> = Readonly<{ retract: (source: ProvenanceNodeRef) => Promise>; @@ -5482,6 +6103,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5502,6 +6124,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5532,7 +6159,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5569,11 +6197,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5659,11 +6292,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5671,11 +6306,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5701,7 +6331,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5780,6 +6410,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public (undocumented) type SourceKindsFromConfig> = KindsFromRef; @@ -5847,6 +6483,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -5875,6 +6515,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -5892,6 +6534,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5908,6 +6552,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -5943,6 +6592,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -5969,12 +6619,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6062,6 +6712,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6070,11 +6721,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6229,6 +6885,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6250,6 +6915,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6284,7 +6966,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6297,10 +6979,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6369,33 +7063,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6462,7 +7156,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6502,6 +7196,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6535,13 +7234,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6555,13 +7255,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) export type SurvivedVia = NodeKind, JustificationKind extends NodeKind = NodeKind> = Readonly<{ fact: ProvenanceFactRef; @@ -6603,6 +7352,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -6669,6 +7421,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -6679,9 +7432,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -6697,7 +7450,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -6707,15 +7460,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -6729,34 +7498,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -6776,13 +7560,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -7025,6 +7809,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7150,6 +7940,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7171,6 +7962,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph-schema.api.md b/packages/typegraph/etc/typegraph-schema.api.md index 8bfd1a480..38a66bfcc 100644 --- a/packages/typegraph/etc/typegraph-schema.api.md +++ b/packages/typegraph/etc/typegraph-schema.api.md @@ -110,15 +110,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -133,6 +133,9 @@ type ClaimIndexMaterializationParams = Readonly<{ leaseMs: number; }>; +// @public +export function classifyOntologyChanges(before: OntologySnapshot, after: OntologySnapshot): readonly OntologyChange[]; + // @public export function classifySchemaChanges(diff: SchemaDiff): SchemaChangeClassification; @@ -189,28 +192,74 @@ type CompiledStatementSql = IntentSql<"statement">; // @public (undocumented) type CompiledTemporaryStatementSql = IntentSql<"temporary-statement">; +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + // @public export function computeSchemaDiff(before: SerializedSchema, after: SerializedSchema): SchemaDiff; // @public export function computeSchemaHash(schema: SerializedSchema): Promise; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -319,6 +368,16 @@ type ContributionRepopulationStats = Readonly<{ // @public type ContributionScope = "deployment" | "graph"; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -330,15 +389,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -348,6 +399,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -489,9 +541,18 @@ type DurableEdgeBatchMembers = Readonly<{ const EDGE_TYPE_BRAND: "__edgeType"; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -575,11 +636,17 @@ type EdgeCreateCommandResult = Readonly<{ ]; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -604,6 +671,18 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeMatchIdentity = Readonly<{ name: string; @@ -622,8 +701,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -686,12 +767,15 @@ type EngineRevision = string & Readonly<{ }>; // @public -export function ensureSchema(backend: GraphBackend, graph: G, options?: SchemaManagerOptions & { +export function ensureSchema(backend: GraphBackend, graph: G, options?: SchemaManagerOptions & EnsureSchemaPreloadedOptions): Promise; + +// @public +type EnsureSchemaPreloadedOptions = Readonly<{ preloaded?: Readonly<{ activeRow: SchemaVersionRow | undefined; storedSchema: SerializedSchema | undefined; }>; -}): Promise; +}>; // @public type EntityKey = Readonly<{ @@ -734,6 +818,10 @@ export type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -770,6 +858,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -847,7 +938,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -1128,7 +1219,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -1173,9 +1264,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -1368,6 +1460,7 @@ export type GraphTemplate = Readonly<{ id: string; reconciled: ReconciledSchema; schemaHash: string; + registryHash?: string; }>; // @public @@ -1378,6 +1471,12 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type HardDeleteEdgeParams = Readonly<{ graphId: string; @@ -1475,6 +1574,8 @@ type IdentityTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; // @public @@ -1594,9 +1695,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public (undocumented) export function initializeSchema(backend: GraphBackend, graph: G, options?: Readonly<{ schema?: SqlSchema; @@ -1670,6 +1768,12 @@ export function isBackwardsCompatible(diff: SchemaDiff): boolean; // @public export function isSchemaInitialized(backend: GraphBackend, graphId: string): Promise; +// @public +export function isStructuralSubtype(child: JsonSchema, parent: JsonSchema): StructuralSubtypeResult; + +// @public +export function isTypeLevelSubtype(child: JsonSchema, parent: JsonSchema): StructuralSubtypeResult; + // @public (undocumented) type JsonPointer = string & { readonly __jsonPointer: unique symbol; @@ -1684,8 +1788,12 @@ export type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -1696,6 +1804,7 @@ export type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -1716,25 +1825,25 @@ type KindEntity = "node" | "edge"; // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -1745,6 +1854,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -1776,6 +1886,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -1847,7 +1960,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -1883,11 +1996,6 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -1908,6 +2016,16 @@ export type MigrationHookContext = Readonly<{ diff: SchemaDiff; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public const NODE_TYPE_BRAND: "__nodeType"; @@ -2055,10 +2173,38 @@ type NullCheckOp = "isNull" | "isNotNull"; // @public export type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +export type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public @@ -2066,7 +2212,13 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +export type OntologySnapshot = Pick; + +// @public +export function ontologyTighteningProbes(changes: readonly OntologyChange[]): readonly OntologyDataProbe[]; // @public export function parseSerializedSchema(json: string): SerializedSchema; @@ -2088,6 +2240,22 @@ type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ // @public type PredicateBuilder = Readonly>; +// @public +export const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + +// @public (undocumented) +export function probeCoversViolationFamily(probe: Pick, family: string): boolean; + +// @public +export function projectTypeVisible(schema: JsonSchema): JsonSchema; + // @public type PurgeEdgeClaimsParams = Readonly<{ graphId: string; @@ -2109,6 +2277,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -2189,6 +2358,23 @@ export function registerGraphTemplate(backend: GraphBackend, reconciled: ReconciledSchema; }>): Promise>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -2235,6 +2421,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -2257,6 +2445,7 @@ export type SchemaChangeClassification = "identical" | "additive" | "incompatibl // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -2277,6 +2466,11 @@ export type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public export type SchemaHash = string; @@ -2370,11 +2564,13 @@ export type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -2382,11 +2578,6 @@ export type SerializedEdgeDef = Readonly<{ // @public export type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -2412,7 +2603,7 @@ export type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedPredicate = Readonly<{ @@ -2515,6 +2706,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -2543,6 +2738,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -2560,6 +2757,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -2579,6 +2778,25 @@ type SqlTextChunk = Readonly<{ // @public type StrategyTableContribution = TableContribution; +// @public +export type StructuralIncomparableReason = "unsupported-keyword" | "schema-reference" | "unsupported-construct" | "max-depth-exceeded"; + +// @public +export type StructuralSubtypeReason = "missing-required-property" | "optional-in-child-required-in-parent" | "type-token-mismatch" | "unconstrained-where-parent-constrains" | "value-set-not-subset" | "string-length-not-tighter" | "pattern-mismatch" | "format-mismatch" | "numeric-bound-not-tighter" | "multiple-of-mismatch" | "array-bounds-not-tighter" | "tuple-arity-mismatch" | "no-matching-union-member" | "property-names-mismatch"; + +// @public +export type StructuralSubtypeResult = Readonly<{ + verdict: "subtype"; +}> | Readonly<{ + verdict: "not-subtype"; + reason: StructuralSubtypeReason; + path: readonly string[]; +}> | Readonly<{ + verdict: "incomparable"; + reason: StructuralIncomparableReason; + path: readonly string[]; +}>; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -2614,6 +2832,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalMode = "current" | "asOf" | "includeEnded" | "includeTombstones"; @@ -2681,6 +2902,26 @@ type UniqueConstraintPredicateBuilder> = Re [K in keyof z.infer]-?: UniqueConstraintField; }>; +// @public +export type UniquenessClaimGate = Readonly<{ + cause: "constraint-added" | "constraint-reshaped"; + kind: string; + constraintName: string; + axis: string; +}> | Readonly<{ + cause: "claim-axis-moved"; + kind: string; + constraintName: string; + axis: string; + lostKinds: readonly string[]; +}>; + +// @public +export type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; diff --git a/packages/typegraph/etc/typegraph-sqlite-local.api.md b/packages/typegraph/etc/typegraph-sqlite-local.api.md index c511cdaa2..e8822c4e0 100644 --- a/packages/typegraph/etc/typegraph-sqlite-local.api.md +++ b/packages/typegraph/etc/typegraph-sqlite-local.api.md @@ -25,8 +25,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -90,6 +90,14 @@ type AggregateResult, Aliases // @public type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -179,6 +190,19 @@ type ArrayPredicate = Readonly<{ length?: number; }>; +// @public (undocumented) +type AssignableKeyClosure = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public type BackendCapabilities = Readonly<{ execution: Readonly<{ @@ -244,6 +268,7 @@ type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -251,7 +276,7 @@ type BaseStoreOptions = Readonly<{ type BaseTraversalOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; maxHops?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -390,8 +415,8 @@ type BatchOnceOptions = Readonly<{ // @public type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -418,16 +443,17 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); // @public -type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public type BulkFindEdgesFromParams> = Readonly<{ @@ -437,7 +463,7 @@ type BulkFindEdgesFromParams> = Reado // @public type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -449,7 +475,7 @@ type BulkFindEdgesToParams> = Readonl // @public type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -617,15 +643,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -801,6 +827,115 @@ type CompileQueryOptions = Readonly<{ // @public type ComposableQuery = QueryAst | SetOperation; +// @public +type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +type CompositionExistence = "optional" | "required"; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +type CompositionViaRef = AnyEdgeType | string; + +// @public +type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public (undocumented) type ConditionalExpressionNode = Readonly<{ kind: "conditional"; @@ -809,6 +944,12 @@ type ConditionalExpressionNode = Readonly<{ otherwise: DatabaseExpression; }>; +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -818,13 +959,34 @@ type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -833,14 +995,14 @@ type ConstraintNames = "unique" extends keyof R ? R[ }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -964,11 +1126,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -976,11 +1148,21 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -992,15 +1174,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1010,6 +1184,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public @@ -1048,7 +1223,7 @@ type CreateVectorIndexParams = Readonly<{ }>; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1099,16 +1274,28 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type Decrement = DepthDecrementMap[Current]; // @public export const DEFAULT_LOCAL_SQLITE_PRAGMAS: Required; +// @public +type DefaultAliasExpansionAxis = Exclude; + // @public type DegreeOptions = TemporalAlgorithmOptions & Readonly<{ edges?: readonly EdgeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; }>; // @public @@ -1214,6 +1401,17 @@ type DerivedRelation = Readonly<{ offset?: number; }>; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; + // @public type DisjointOverlapRow = Readonly<{ kinds: readonly [string, string]; @@ -1357,6 +1555,13 @@ type EdgeAccessor = IsDynamicEdgeType extends true ? D toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -1371,9 +1576,9 @@ type EdgeAliasMap = Readonly>>; type EdgeAllowedPairs = ExtractAllowedPairs; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1389,18 +1594,18 @@ type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1408,18 +1613,27 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; // @public @@ -1444,37 +1658,37 @@ type EdgeClaimOutcome = Readonly<{ type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -1531,9 +1745,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -1568,17 +1782,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -type EdgeEntityReadBackend = Pick; +type EdgeEntityReadBackend = Pick; // @public (undocumented) type EdgeEntityWriteBackend = Pick; @@ -1594,9 +1817,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -1607,13 +1830,16 @@ type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -1647,6 +1873,9 @@ type EdgeIndexDeclaration = IndexDeclarationBase & Readonly<{ // @public (undocumented) type EdgeIndexDirection = "out" | "in" | "none"; +// @public +type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) type EdgeIntrospection = Readonly<{ name: string; @@ -1655,12 +1884,26 @@ type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public type EdgeKinds = keyof G["edges"] & string; @@ -1703,8 +1946,10 @@ type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -1735,6 +1980,9 @@ type EdgeTargetMap = Readonly>; // @public type EdgeTargets = readonly NodeType[] | EdgeTargetMap; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -1792,6 +2040,16 @@ type EncodeTilde = S extends `${infer Head}~${infer Tail}` ? ` // @public type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public (undocumented) const ENGINE_RECORDED_READ_SOURCE: unique symbol; @@ -1870,6 +2128,10 @@ type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -1878,6 +2140,11 @@ type EvolutionRequirement = Readonly<{ // @public type EvolutionRequirements = readonly EvolutionRequirement[]; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -2238,6 +2505,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -2336,7 +2606,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -2790,7 +3060,7 @@ type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -2835,9 +3105,10 @@ type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -3000,12 +3271,12 @@ type GraphDef = Record = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -3037,9 +3308,14 @@ type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -3048,6 +3324,9 @@ type GraphNodeReference = { }>; }[NodeKinds]; +// @public +type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public type GraphTemplateRow = Readonly<{ template_id: string; @@ -3056,6 +3335,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -3290,6 +3578,15 @@ type IdentityClassPageOptions = Readonly<{ limit: number; }>; +// @public +type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public export type IdentityFacade = IdentityReadFacade & Readonly<{ assertSame: (a: IdentityNodeRefInput, b: IdentityNodeRefInput, window?: IdentityValidityWindow) => Promise>; @@ -3300,6 +3597,36 @@ export type IdentityFacade = IdentityReadFacade & Readonl retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -3334,6 +3661,34 @@ export type IdentityReadFacade = Readonly<{ // @public export type IdentityRelation = "same" | "different"; +// @public (undocumented) +type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -3346,12 +3701,62 @@ type IdentitySamePathStep = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -3372,6 +3777,7 @@ export type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -3495,9 +3901,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public type InitialQueryBuilder = QueryBuilder; @@ -3588,7 +3991,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -3603,6 +4006,14 @@ type InternalWeaklyConnectedComponentsOptions = InternalTemp // @public (undocumented) type InternalWeightedShortestPathOptions = InternalTemporalAlgorithmOptions & Omit, keyof TemporalAlgorithmOptions>; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public (undocumented) type IsDynamicEdgeType = E extends Readonly<{ [DYNAMIC_EDGE_BRAND]: true; @@ -3646,8 +4057,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -3658,6 +4073,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -3687,6 +4103,9 @@ type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public (undocumented) type KindPopulationStatistics = Readonly<{ entity: KindEntity; @@ -3697,25 +4116,25 @@ type KindPopulationStatistics = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -3726,6 +4145,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -3757,6 +4177,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -3823,6 +4246,12 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) type LiteralExpressionNode = Readonly<{ kind: "literal"; @@ -3879,7 +4308,7 @@ type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -3905,6 +4334,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -3997,11 +4429,6 @@ type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; // @public type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -4013,6 +4440,16 @@ type MigrationHookContext = Readonly<{ diff: SchemaDiff; }>; +// @public +type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + // @public type NeighborNodeOrderField = { [K in keyof G["nodes"] & string]: Exclude, "id" | "kind" | "meta"> & string; @@ -4055,7 +4492,7 @@ type NeighborResult> = Readonly<{ type NeighborsOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; depth?: number; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; cyclePolicy?: AlgorithmCyclePolicy; }>; @@ -4115,12 +4552,8 @@ type NodeChange = Readonly<{ }>; // @public -type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -4143,19 +4576,21 @@ type NodeCollection = Readonly<{ }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -4164,12 +4599,9 @@ type NodeCollection = Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -4194,10 +4626,10 @@ type NodeCollection = Readonly<{ bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -4227,9 +4659,35 @@ type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) type NodeEntityReadBackend = Pick; @@ -4237,8 +4695,9 @@ type NodeEntityReadBackend = Pick; // @public -type NodeGetOrCreateByConstraintOptions = Readonly<{ +type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -4350,6 +4809,29 @@ type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public type NodeRegistration = Readonly<{ type: N; @@ -4357,6 +4839,17 @@ type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -4515,10 +5008,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -4526,6 +5047,9 @@ type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -4534,16 +5058,24 @@ type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; // @public -type OperationHookContext = HookContext & Readonly<{ +type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -4576,7 +5108,7 @@ type OuterReferenceExpressionNode = Readonly<{ type PageRankOptions = TemporalAlgorithmOptions & IterativeMemoryOptions & Readonly<{ edges: readonly EdgeKinds[]; nodeKinds?: readonly NodeKinds[]; - direction?: TraversalDirection_2; + direction?: TraversalDirection$1; dampingFactor?: number; tolerance?: number; maxIterations?: number; @@ -4647,6 +5179,12 @@ class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -4668,6 +5206,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -4713,6 +5256,16 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -4830,21 +5383,39 @@ class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -4874,6 +5445,7 @@ class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -4900,6 +5472,7 @@ class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -4908,6 +5481,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -4919,8 +5493,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -4964,7 +5539,7 @@ type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -4991,6 +5566,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -5060,6 +5636,19 @@ type RecordedHeterogeneousNodeWriteBatch = Readonly<{ writeNodeUpsertBatch: []>(entries: Entries) => Promise>; }>; +// @public +type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -5126,13 +5715,13 @@ const RecordedStoreView: new (store: Store, coordinate: R type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -5260,6 +5849,23 @@ type RefreshSchemaOptions = Readonly<{ minVersion?: number; }>; +// @public +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -5401,6 +6007,8 @@ type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -5415,7 +6023,7 @@ type ResolvedSqlTableNames = Readonly<{ type ResolvedStoreCore = StoreCore & AddedStoreReads; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -5426,6 +6034,19 @@ type ResolvePathAlias = PC extends string ? PC : PC extend // @public (undocumented) type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualified" : "ids"; +// @public +type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public type RowProps = string | Readonly>; @@ -5485,6 +6106,7 @@ type ScalarSubqueryExpressionNode = Readonly<{ // @internal type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -5505,6 +6127,11 @@ type SchemaDiff = Readonly<{ summary: string; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public type SchemaHash = string; @@ -5535,7 +6162,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -5582,11 +6210,16 @@ type SearchableMetadata = Readonly<{ language: string; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public @@ -5672,11 +6305,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -5684,11 +6319,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5714,7 +6344,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public type SerializedSchema = Readonly<{ @@ -5793,6 +6423,12 @@ type SimilarToOptions = Readonly<{ // @public type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -5857,6 +6493,10 @@ abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -5885,6 +6525,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -5902,6 +6544,8 @@ type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -5918,6 +6562,11 @@ type SqlTextChunk = Readonly<{ value: string; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -5953,6 +6602,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -5979,12 +6629,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -6075,6 +6725,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -6083,11 +6734,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -6242,6 +6898,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6263,6 +6928,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -6297,7 +6979,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -6310,10 +6992,22 @@ type StoreRuntime = Readonly<{ // @public class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -6382,33 +7076,33 @@ type StoreViewDegreeOptions = Omit, keyof T type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -6475,7 +7169,7 @@ type StoreViewReachableOptions = Omit, k type StoreViewShortestPathOptions = Omit, keyof TemporalAlgorithmOptions>; // @public -type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -6515,6 +7209,11 @@ type StringPredicate = Readonly<{ pattern: string | ParameterRef; }>; +// @public (undocumented) +type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; + // @public (undocumented) type SubgraphEdgeProjectionField = EdgeProjectionPropertyKey | "meta"; @@ -6548,13 +7247,14 @@ type SubgraphNodeResult = NodeKinds< type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -6568,13 +7268,62 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + +// @public +type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public (undocumented) type SystemColumnName = "graph_id" | "kind" | "id" | "from_kind" | "from_id" | "to_kind" | "to_id" | "deleted_at" | "valid_from" | "valid_to" | "created_at" | "updated_at" | "version"; @@ -6610,6 +7359,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +type TargetCardinality = Exclude; + // @public type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -6676,6 +7428,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -6686,9 +7439,9 @@ type TransactionContext = TransactionCollections & Readon describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -6704,7 +7457,7 @@ type TransactionOutcome = Readonly<{ }>; // @public -type TransactionReadBackend = Readonly; +type TransactionReadBackend = Readonly; // @public type TransactionReceipt = Readonly<{ @@ -6714,15 +7467,31 @@ type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -6736,34 +7505,49 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } // @public -type TraversalDirection = "out" | "in"; +type TraversalDirection$1 = "out" | "in" | "both"; // @public -type TraversalDirection_2 = "out" | "in" | "both"; +type TraversalDirection = "out" | "in"; // @public type TraversalExpansion = "none" | "implying" | "inverse" | "all"; @@ -6783,13 +7567,13 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; // @public -type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -7037,6 +7821,12 @@ type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public type UniquenessScope = "kind" | "kindWithSubClasses"; @@ -7162,6 +7952,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -7183,6 +7974,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/etc/typegraph.api.md b/packages/typegraph/etc/typegraph.api.md index a212f7080..bd01b6efe 100644 --- a/packages/typegraph/etc/typegraph.api.md +++ b/packages/typegraph/etc/typegraph.api.md @@ -97,8 +97,8 @@ type AddedStoreReads = AddedStoreReadsBoundary & Required type AddedStoreReadsBoundary = Readonly<{ withCheckedReads?: (expectedSchemaVersion: number | undefined, fn: (reads: CheckedReadScope) => Promise) => Promise; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; lineageRevisionNow?: () => Promise; changesSince?: (revision: EngineRevision) => Promise; }>; @@ -173,6 +173,14 @@ export type AggregateResult, // @public export type AlgorithmCyclePolicy = RecursiveCyclePolicy; +// @public +export type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +// @public (undocumented) +type AliasExpansionOptions = Readonly<{ + expansion?: AliasExpansionAxis | undefined; +}>; + // @public (undocumented) type AliasExpressions>>; +// @public +type AliasNodeType = SubsumedNodeType; + // @public (undocumented) type AliasSchemaValue = z.infer["type"]["schema"]>; @@ -298,6 +309,19 @@ export function asNodeId(value: string): NodeId = [ +Exclude, Reached> +] extends ([ +never +]) ? Reached : AssignableKeyClosure>; + +// @public +export type AssignableNodeKinds = OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) : true extends SubsumptionLiteralsErased ? K | (keyof G["nodes"] & string) : K | Extract, keyof G["nodes"] & string>; + +// @public (undocumented) +type AssignableNodeTypes = N extends NodeType ? KindNodeTypes, N["kind"]>> : never; + // @public export function avg(alias: Alias, field: Property): AggregateExpr<"avg", FieldRef>; @@ -401,6 +425,7 @@ export type BaseStoreOptions = Readonly<{ schema?: SqlSchema; queryDefaults?: Readonly<{ traversalExpansion?: TraversalExpansion; + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -547,8 +572,8 @@ export type BatchOnceOptions = Readonly<{ // @public export type BatchReadBuilder = Readonly<{ - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => CompiledOneStatementRead[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => CompiledOneStatementRead[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => CompiledOneStatementRead; subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => CompiledOneStatementRead>; }>; @@ -575,8 +600,14 @@ type BooleanExpressionNode = Readonly<{ // @public (undocumented) type BooleanFieldAccessor = BaseFieldAccessor; +// @public (undocumented) +type BrandedNodeReference = N extends NodeType ? Readonly<{ + kind: N["kind"]; + id: NodeId; +}> : never; + // @public -export function broader(narrowerConcept: NodeType, broaderConcept: NodeType): OntologyRelation; +export function broader(narrowerConcept: N, broaderConcept: B): TypedOntologyRelation; // @public type BuildRecursiveAliases = ([DC] extends [false] ? {} : Record, RecursiveAlias<"depth", "ids", Optional>>) & ([PC] extends [false] ? {} : Record, RecursiveAlias<"path", ResolvePathFormat, Optional>>); @@ -585,12 +616,7 @@ type BuildRecursiveAliases = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; +export type BulkEdgeSourceGroup = NodeReferenceGroup>; // @public export type BulkFindEdgesFromParams> = Readonly<{ @@ -600,7 +626,7 @@ export type BulkFindEdgesFromParams> // @public export type BulkFindEdgesFromResult> = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -612,7 +638,7 @@ export type BulkFindEdgesToParams> = // @public export type BulkFindEdgesToResult> = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -735,8 +761,11 @@ export class CardinalityError extends TypeGraphError { // @public export type CardinalityErrorDetails = Readonly<{ edgeKind: string; + direction: EdgeCardinalityDirection; fromKind: string; fromId: string; + toKind: string; + toId: string; cardinality: string; existingCount: number; }>; @@ -791,15 +820,15 @@ type CheckUniqueParams = Readonly<{ }>; // @public -type ClaimEdgeCardinalityParams = Readonly<{ +type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & Readonly<{ graphId: string; - cardinality: Exclude; edgeKind: string; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope?: CompositionClaimScope; }>; // @public @@ -1026,6 +1055,232 @@ export function composeFragments(f1: QueryFragment, f2: QueryFragment, f3: QueryFragment, f4: QueryFragment): QueryFragment; +// @public +export type CompositionAttachment = Readonly<{ + whole: Readonly<{ + kind: WholeKind; + id: string; + }>; + via?: Via; + props?: CompositionAttachmentProps; + validFrom?: string | null; + validTo?: string; +}>; + +// @public (undocumented) +export type CompositionAttachmentProps = [ +Via +] extends [AnyEdgeType] ? z.input : Record; + +// @public +export type CompositionCascadedPart = CompositionNodeRef & Readonly<{ + whole: CompositionNodeRef; + viaEdgeKind: string; +}>; + +// @public +export type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ + edgeKind: string; + partSide: "from" | "to"; + }>[]; +}>; + +// @public +export class CompositionCycleError extends TypeGraphError { + constructor(details: CompositionCycleErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: CompositionCycleErrorDetails; +} + +// @public +export type CompositionCycleErrorDetails = Readonly<{ + wholeKind: string; + wholeId: string; + revisitedKind: string; + revisitedId: string; +}>; + +// @public +export class CompositionError extends TypeGraphError { + constructor(details: CompositionErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: CompositionErrorDetails; +} + +// @public +export type CompositionErrorDetails = Readonly<{ + situation: "occupied"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + edgeKind: string; + incumbentEdgeId?: string; +}> | Readonly<{ + situation: "futureStart" | "futureEnd"; + partKind: string; + partId?: string; + wholeKind: string; + wholeId: string; + edgeKind: string; + edgeId?: string; + bound: string; + operationInstant: string; +}> | Readonly<{ + situation: "futureMove"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + at: string; + operationInstant: string; +}> | Readonly<{ + situation: "moveBeforeIncumbent"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + incumbentEdgeKind: string; + incumbentEdgeId: string; + incumbentValidFrom: string; + at: string; +}>; + +// @public +export type CompositionExistence = "optional" | "required"; + +// @public +export class CompositionExistenceError extends TypeGraphError { + constructor(details: CompositionExistenceErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: CompositionExistenceErrorDetails; +} + +// @public +export type CompositionExistenceErrorDetails = Readonly<{ + partKind: string; + partId?: string; + situation: "create" | "detach" | "existing" | "props"; + edgeKind?: string; + edgeId?: string; + currentWhole?: Readonly<{ + kind: string; + id: string; + }>; + currentVia?: string; + requestedWhole?: Readonly<{ + kind: string; + id: string; + }>; + requestedVia?: string; + currentProps?: Record; + requestedProps?: Record; +}>; + +// @public +export type CompositionHeldEdge = Readonly<{ + id: string; + kind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + validFrom?: string; + validTo?: string; +}>; + +// @public (undocumented) +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +// @public +type CompositionNavigationEdge = O extends { + via: infer V extends AnyEdgeType; +} ? V : DynamicEdgeType; + +// @public +export type CompositionNavigationOptions = Readonly<{ + from?: keyof Aliases & string; + maxHops?: number; + depth?: RecursiveTraversalOptions["depth"]; + path?: RecursiveTraversalOptions["path"]; + via?: CompositionViaRef; +}>; + +// @public +type CompositionNavigationResult = QueryBuilder>, EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, RecursiveAliases & BuildRecursiveAliases, CoordinateState>; + +// @public +export type CompositionNodeRef = Readonly<{ + kind: string; + id: string; +}>; + +// @public +export type CompositionOptions = Readonly<{ + via: AnyEdgeType; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + partSide: CompositionPartSide; + population: "one" | "oneActive"; + existence: CompositionExistence; +}>; + +// @public +export type CompositionPartSide = "from" | "to"; + +// @public +type CompositionRealization = Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}>; + +// @public +type CompositionRelation = Readonly<{ + pairs: readonly CompositionPair[]; + edgeKinds: ReadonlySet; + partSideByEdgeKind: ReadonlyMap; +}>; + +// @public +export function compositionViaKind(via: CompositionViaRef): string; + +// @public +export type CompositionViaRef = AnyEdgeType | string; + +// @public +export type CompositionWholeKinds = OntologyTypeErased extends true ? keyof G["nodes"] & string : true extends CompositionWholeKindsUndecidable ? keyof G["nodes"] & string : DeclaredCompositionWholeKinds; + +// @public +type CompositionWholeKindsUndecidable = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : [Extract] extends [never] ? [ +Extract +] extends [never] ? false : true extends EndpointKindErased ? true : false : true : true : never; + // @public export function computeTransitiveClosure(relations: readonly (readonly [string, string])[]): ReadonlyMap>; @@ -1045,6 +1300,12 @@ export class ConfigurationError extends TypeGraphError { }); } +// @public +type ConstrainedCardinality = Exclude; + +// @public +type ConstrainedTargetCardinality = Exclude; + // @public export type ConstraintFenceViolation = Readonly<{ family: "nodeUniqueness" | "nodeDisjointness"; @@ -1054,13 +1315,34 @@ export type ConstraintFenceViolation = Readonly<{ family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; -}>; +}> | Readonly<{ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ + kind: string; + id: string; + }>[]; +}> | Readonly<{ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; +}> | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; + edges: readonly MisassignedEdgeEndpointRow[]; +}> | EdgeAcyclicityViolation; // @public type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; // @public @@ -1069,14 +1351,14 @@ export type ConstraintNames = "unique" extends keyof }[] ? N & string : string : never; // @public -type ContendedEdgeRow = Readonly<{ +type ContendedEdgeRow = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; edgeId: string; fromKind: string; fromId: string; toKind: string; toId: string; + scope: CompositionClaimScope | undefined; }>; // @public @@ -1229,11 +1511,21 @@ abstract class CoordinatePinnedView { // (undocumented) protected readonly coordinate: ReadCoordinate; degree(node: NodeIdentifier, options?: StoreViewDegreeOptions): Promise; + getNodeByRef>(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + getNodeByRef(reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; // (undocumented) protected internalAlgorithms(): InternalGraphAlgorithms; labelPropagation(options: StoreViewLabelPropagationOptions): Promise; get mode(): TemporalMode; neighbors(node: NodeIdentifier, options: StoreViewNeighborsOptions): Promise; + abstract get nodes(): object; pageRank(options: StoreViewPageRankOptions): Promise; personalizedPageRank(options: StoreViewPersonalizedPageRankOptions): Promise; query(): InitialQueryBuilder; @@ -1241,7 +1533,7 @@ abstract class CoordinatePinnedView { shortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions): Promise; // (undocumented) protected readonly store: Store; - subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise>; + subgraph, const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: StoreViewSubgraphOptions): Promise, P>>; weaklyConnectedComponents(options: StoreViewWeaklyConnectedComponentsOptions): Promise; weightedShortestPath(from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions): Promise; } @@ -1251,11 +1543,6 @@ export const core: { readonly subClassOfMetaEdge: Readonly<{ name: "subClassOf"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1263,11 +1550,6 @@ export const core: { readonly broaderMetaEdge: Readonly<{ name: "broader"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1275,11 +1557,6 @@ export const core: { readonly narrowerMetaEdge: Readonly<{ name: "narrower"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1287,11 +1564,6 @@ export const core: { readonly relatedToMetaEdge: Readonly<{ name: "relatedTo"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1299,35 +1571,6 @@ export const core: { readonly equivalentToMetaEdge: Readonly<{ name: "equivalentTo"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; - description: string | undefined; - }>; - __metaEdge: true; - }>; - readonly sameAsMetaEdge: Readonly<{ - name: "sameAs"; - properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; - description: string | undefined; - }>; - __metaEdge: true; - }>; - readonly differentFromMetaEdge: Readonly<{ - name: "differentFrom"; - properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1335,11 +1578,6 @@ export const core: { readonly disjointWithMetaEdge: Readonly<{ name: "disjointWith"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1347,11 +1585,6 @@ export const core: { readonly partOfMetaEdge: Readonly<{ name: "partOf"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1359,11 +1592,6 @@ export const core: { readonly hasPartMetaEdge: Readonly<{ name: "hasPart"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1371,11 +1599,6 @@ export const core: { readonly inverseOfMetaEdge: Readonly<{ name: "inverseOf"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1383,11 +1606,6 @@ export const core: { readonly impliesMetaEdge: Readonly<{ name: "implies"; properties: Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; __metaEdge: true; @@ -1397,8 +1615,6 @@ export const core: { readonly narrower: typeof narrower; readonly relatedTo: typeof relatedTo; readonly equivalentTo: typeof equivalentTo; - readonly sameAs: typeof sameAs; - readonly differentFrom: typeof differentFrom; readonly disjointWith: typeof disjointWith; readonly partOf: typeof partOf; readonly hasPart: typeof hasPart; @@ -1424,6 +1640,16 @@ export function countDistinctEdges(edgeAlias: Alias) // @public export function countEdges(edgeAlias: Alias): AggregateExpr<"count", FieldRef>; +// @public +type CountEdgesAtEndpointParams = Readonly<{ + graphId: string; + edgeKind: string; + endpoint: "from" | "to"; + endpointKind: string; + endpointId: string; + activeOnly?: boolean; +}>; + // @public type CountEdgesByKindParams = Readonly<{ graphId: string; @@ -1435,15 +1661,7 @@ type CountEdgesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; -}>; - -// @public -type CountEdgesFromParams = Readonly<{ - graphId: string; - edgeKind: string; - fromKind: string; - fromId: string; - activeOnly?: boolean; + limit?: number; }>; // @public @@ -1453,6 +1671,7 @@ type CountNodesByKindParams = Readonly<{ excludeDeleted?: boolean; temporalMode?: TemporalMode; asOf?: string; + limit?: number; }>; // @public (undocumented) @@ -1523,6 +1742,7 @@ export type CreateNodeInput = Readonly<{ props: z.infer; validFrom?: string | null; validTo?: string; + partOf?: CompositionAttachment; }>; // @public @@ -1537,6 +1757,7 @@ type CreateQueryBuilderOptions = Readonly<{ dialect?: SqlDialect; schema?: SqlSchema; defaultTraversalExpansion?: TraversalExpansion; + defaultExpansion?: DefaultAliasExpansionAxis | undefined; identityEnabled?: boolean; identitySameIdAcrossKinds?: "fold" | "ignore"; }>; @@ -1642,7 +1863,7 @@ SchemaValidationResult export const CURRENT_GRAPH_EXTENSION_VERSION: 1; // @public -const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex"]; +const CURRENT_ONLY_READ_NAMES: readonly ["findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", "planDelete"]; // @public const DATABASE_EXTENSION_NAMES: readonly ["pg_trgm", "vector"]; @@ -1713,18 +1934,33 @@ type DateFieldAccessor = BaseFieldAccessor & Readonly< between: (lower: Date | string | ParameterRef, upper: Date | string | ParameterRef) => Predicate; }>; +// @public +type DeclaredCompositionWholeKinds = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) | ([Extract] extends [never] ? never : [Extract, K>] extends [never] ? never : EndpointKinds) : never : never; + // @public (undocumented) type DecodePointerSegment = S extends `${infer Head}~1${infer Tail}` ? `${DecodePointerSegment}/${DecodePointerSegment}` : S extends `${infer Head}~0${infer Tail}` ? `${DecodePointerSegment}~${DecodePointerSegment}` : S; // @public (undocumented) type Decrement = DepthDecrementMap[Current]; +// @public +export const DEFAULT_ALIAS_EXPANSION_AXIS: DefaultAliasExpansionAxis; + // @public (undocumented) export const DEFAULT_SEARCHABLE_LANGUAGE: "english"; // @public export const DEFAULT_SQL_SCHEMA: SqlSchema; +// @public +export type DefaultAliasExpansionAxis = Exclude; + // @public export function defineEdge(name: K): EdgeType; @@ -1756,7 +1992,7 @@ export type DefineEdgeOptions, From extends }>; // @public -export function defineGraph>, const TEdges extends Record, const TOntology extends readonly OntologyRelation[], const TIdentity extends GraphIdentityConfig | undefined = undefined>(config: GraphDefConfig): GraphDef, TOntology, TIdentity>; +export function defineGraph>, const TEdges extends Record, const TOntology extends readonly OntologyRelation[] = readonly [], const TIdentity extends GraphIdentityConfig | undefined = undefined>(config: GraphDefConfig): GraphDef, TOntology, TIdentity>; // @public export function defineGraphExtension(input: T): T & GraphExtension; @@ -1901,8 +2137,16 @@ type DerivedRelation = Readonly<{ offset?: number; }>; -// @public @deprecated -export function differentFrom(kindA: NodeType, kindB: NodeType): OntologyRelation; +// @public +type DirectlyAssignableKeys = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, Reached> +] extends [never] ? never : SubsumptionEndpointKeys : never : never; // @public export class DisjointError extends TypeGraphError { @@ -1927,7 +2171,7 @@ type DisjointOverlapRow = Readonly<{ }>; // @public -export function disjointWith(kindA: NodeType, kindB: NodeType): OntologyRelation; +export function disjointWith(kindA: A, kindB: B): TypedOntologyRelation; // @public type DropVectorIndexParams = Readonly<{ @@ -2093,6 +2337,50 @@ export type EdgeAccessor = IsDynamicEdgeType extends t toId: StringFieldAccessor; }> & EdgePropsAccessor; +// @public +export class EdgeAcyclicityError extends TypeGraphError { + constructor(details: EdgeAcyclicityErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: EdgeAcyclicityErrorDetails; +} + +// @public +export type EdgeAcyclicityErrorDetails = Readonly<{ + relation: string; + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + selfLoop: boolean; +}>; + +// @public +export class EdgeAcyclicityIndeterminateError extends TypeGraphError { + constructor(details: EdgeAcyclicityIndeterminateErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: EdgeAcyclicityIndeterminateErrorDetails; +} + +// @public +export type EdgeAcyclicityIndeterminateErrorDetails = Readonly<{ + relation: string; + operation: string; + graphId: string; +}>; + +// @public +type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + relation: string; + edgeIds: readonly string[]; +}>; + // @public type EdgeAlias = Readonly<{ type: E; @@ -2110,9 +2398,9 @@ type EdgeAllowedPairs = ExtractAllowedPairs = Pick, (typeof EDGE_BATCH_READ_NAMES)[number]>; // @public (undocumented) -type EdgeBulkCreateItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkCreateItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -2128,18 +2416,18 @@ export type EdgeBulkFindOptions = Readonly<{ }>; // @public (undocumented) -type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkGetOrCreateItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation : never; // @public (undocumented) -type EdgeBulkInsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; +type EdgeBulkInsertItem, G extends GraphDef> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -2147,20 +2435,32 @@ type EdgeBulkInsertItem : never; // @public (undocumented) -type EdgeBulkUpsertItem> = Pairs extends EdgeEndpointPairTypes ? Readonly<{ +type EdgeBulkUpsertItem = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & ValidityEndMutation : never; // @public -type EdgeCardinalityDeclaration = Readonly<{ +export type EdgeCardinalityAxisRef = Readonly<{ + direction: "source"; + cardinality: ConstrainedCardinality; +}> | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; +}>; + +// @public +export type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & Readonly<{ edgeKind: string; - cardinality: Exclude; + scope?: CompositionClaimScope; }>; +// @public +export type EdgeCardinalityDirection = "source" | "target"; + // @public type EdgeChange = Readonly<{ type: ChangeType; @@ -2183,37 +2483,37 @@ type EdgeClaimOutcome = Readonly<{ export type EdgeCollection> = Readonly<{ - create: (...args: EdgeCreateArgumentsTuple) => Promise>; - getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; - getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; - update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; - findFrom: (from: NodeRef, options?: QueryOptions) => Promise[]>; - findTo: (to: NodeRef, options?: QueryOptions) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; - batchFindFrom: (from: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindTo: (to: NodeRef, options?: QueryOptions) => BatchableQuery>; - batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; +}, InputId extends string = EdgeId, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + create: (...args: EdgeCreateArgumentsTuple) => Promise>; + getById: (id: InputId, options?: QueryOptions) => Promise | undefined>; + getByIds: (ids: readonly InputId[], options?: QueryOptions) => Promise | undefined)[]>; + update: (id: InputId, props: Partial>, options?: ValidityEndMutation) => Promise>; + findFrom: (from: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + findTo: (to: EdgeEndpointRef, options?: QueryOptions) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions) => Promise[][]>; + batchFindFrom: (from: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindTo: (to: EdgeEndpointRef, options?: QueryOptions) => BatchableQuery>; + batchFindByEndpoints: (...args: EdgeFindByEndpointsArguments) => BatchableQuery>; delete: (id: InputId) => Promise; hardDelete: (id: InputId) => Promise; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>, temporal?: QueryOptions) => Promise[]>; + }>, temporal?: QueryOptions) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions) => Promise; - bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; - bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; - bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; + bulkCreate: (items: readonly EdgeBulkCreateItem[]) => Promise[]>; + bulkUpsertById: (items: readonly EdgeBulkUpsertItem[]) => Promise[]>; + bulkInsert: (items: readonly EdgeBulkInsertItem[]) => Promise; bulkDelete: (ids: readonly InputId[]) => Promise; - findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; - getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise>; - bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise[]>; + findByEndpoints: (...args: EdgeFindByEndpointsArguments) => Promise | undefined>; + getOrCreateByEndpoints: (...args: EdgeGetOrCreateByEndpointsArguments) => Promise, SubsumedNodeType>>; + bulkGetOrCreateByEndpoints: (items: readonly EdgeBulkGetOrCreateItem[], options?: Pick, "matchOn" | "ifExists">) => Promise, SubsumedNodeType>[]>; }>; // @public (undocumented) @@ -2270,9 +2570,9 @@ options?: EdgeCreateOptions ] : [props: z.input, options?: EdgeCreateOptions]; // @public (undocumented) -type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeCreateArgumentsTuple = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, ...args: EdgeCreateArguments ] : never; @@ -2307,17 +2607,26 @@ type EdgeCreateOptions = Readonly<{ validTo?: string; }>; +// @public +export type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + // @public type EdgeEndpointPairTypes = Readonly<{ from: NodeType; to: NodeType; }>; +// @public +export type EdgeEndpointRef = NodeRef> | SubsumedNodeReference>; + // @public type EdgeEndpointSide = "from" | "to"; // @public (undocumented) -export type EdgeEntityReadBackend = Pick; +export type EdgeEntityReadBackend = Pick; // @public (undocumented) export type EdgeEntityWriteBackend = Pick; @@ -2336,9 +2645,9 @@ type EdgeExistsBetweenParams = Readonly<{ }>; // @public (undocumented) -type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeFindByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions ] : never; @@ -2349,13 +2658,16 @@ export type EdgeFindByEndpointsOptions = Readonly<{ props?: Partial>; }>; +// @public (undocumented) +type EdgeFromNodeTypes = EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + // @public type EdgeFromTypes = R["from"] extends readonly (infer N)[] ? N : never; // @public (undocumented) -type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ -from: NodeRef, -to: NodeRef, +type EdgeGetOrCreateByEndpointsArguments = Pairs extends EdgeEndpointPairTypes ? [ +from: EdgeEndpointRef, +to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions ] : never; @@ -2422,6 +2734,9 @@ export type EdgeIndexWhereBuilder = Readonly<{ [K in keyof z.infer]-?: IndexWhereFieldBuilder[K]>; }>; +// @public +export type EdgeInGraph = Edge, SubsumedNodeType>; + // @public (undocumented) export type EdgeIntrospection = Readonly<{ name: string; @@ -2430,12 +2745,26 @@ export type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; }>; +// @public +type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + pairs: readonly Readonly<{ + from: string; + to: string; + }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + // @public export type EdgeKinds = keyof G["edges"] & string; @@ -2509,8 +2838,10 @@ export type EdgeRegistration; + acyclic?: boolean; }>; // @public @@ -2544,6 +2875,9 @@ export type EdgeTargets = readonly NodeType[] | EdgeTargetMap; // @public export type EdgeTemporalReads = Pick, (typeof EDGE_TEMPORAL_READ_NAMES)[number]>; +// @public (undocumented) +type EdgeToNodeTypes = EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + // @public type EdgeToTypes = ExtractTargets; @@ -2677,6 +3011,16 @@ export type EndpointErrorDetails = Readonly<{ // @public export type EndpointExistence = "notDeleted" | "currentlyValid" | "ever"; +// @public +type EndpointKindErased = Endpoint extends string ? false : Endpoint extends { + kind: infer Kind extends string; +} ? string extends Kind ? true : false : true; + +// @public (undocumented) +type EndpointKinds = Extract["kind"]; + // @public export class EndpointNotFoundError extends TypeGraphError { constructor(details: EndpointNotFoundErrorDetails, options?: { @@ -2779,7 +3123,20 @@ export type EntityKey = Readonly<{ type EqualityOperand = T | FieldRef | ParameterRef; // @public -export function equivalentTo(kindA: NodeType, kindBOrIri: NodeType | string): OntologyRelation; +export function equivalentTo(kindA: A, kindB: B & EquivalentToCheck): TypedOntologyRelation; + +// @public (undocumented) +export function equivalentTo(kindA: A, kindBOrIri: string): TypedOntologyRelation; + +// @public (undocumented) +export function equivalentTo(kindA: A, kindB: B): TypedOntologyRelation; + +// @public +export type EquivalentToCheck = [ +IncompatibleKeys, NodeProps> +] extends [never] ? [ +IncompatibleKeys, NodeProps> +] extends [never] ? unknown : StructuralSubtypeMismatch, NodeProps>> : StructuralSubtypeMismatch, NodeProps>>; // @public export type ErrorCategory = "user" | "constraint" | "system"; @@ -2816,6 +3173,10 @@ export type EvolutionRequirement = Readonly<{ kind: "vector-slot"; nodeKind: string; fieldPath: string; +}> | Readonly<{ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -2837,6 +3198,11 @@ export type EvolvedTransactionOutcome = Readonly<{ }>; }>; +// @public +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + // @public export class ExecutableAggregateQuery> { constructor(config: QueryBuilderConfig, state: QueryBuilderState, fields: R); @@ -3424,6 +3790,9 @@ type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // @public @@ -3522,7 +3891,7 @@ type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & CompositionRealization; // @public type ExtensionPropertyModifiers = Readonly<{ @@ -3980,7 +4349,7 @@ const GRAPH_COMMAND_COORDINATION_BRAND: unique symbol; const GRAPH_DEF_BRAND: "__graphDef"; // @public -const GRAPH_EXTENSION_ISSUE_CODES: readonly ["UNSUPPORTED_PROPERTY_TYPE", "INVALID_PROPERTY_REFINEMENT", "NESTED_ARRAY", "NESTED_OBJECT_TOO_DEEP", "INVALID_MODIFIER_TARGET", "INVALID_ENUM_VALUES", "INVALID_NUMBER_BOUNDS", "INVALID_LENGTH_BOUNDS", "INVALID_PATTERN", "INVALID_EMBEDDING_DIMENSIONS", "INVALID_SEARCHABLE_LANGUAGE", "RESERVED_PROPERTY_NAME", "INVALID_KIND_NAME", "DUPLICATE_KIND_NAME", "EMPTY_PROPERTIES", "EMPTY_FROM_OR_TO", "DUPLICATE_UNIQUE_CONSTRAINT", "EMPTY_UNIQUE_FIELDS", "DUPLICATE_UNIQUE_FIELD", "UNKNOWN_UNIQUE_FIELD", "INVALID_UNIQUE_WHERE_OP", "UNKNOWN_UNIQUE_WHERE_FIELD", "INVALID_ANNOTATION", "UNKNOWN_META_EDGE", "ONTOLOGY_CYCLE", "ONTOLOGY_SELF_LOOP", "ONTOLOGY_DISJOINT_CONFLICT", "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS", "DUPLICATE_ONTOLOGY_RELATION", "INVALID_DOCUMENT_SHAPE", "UNKNOWN_DOCUMENT_KEY", "UNSUPPORTED_STRING_FORMAT", "INVALID_INDEX_DECLARATION", "DUPLICATE_INDEX_NAME", "EMPTY_INDEX_FIELDS", "UNKNOWN_PROPERTY_KEY"]; +const GRAPH_EXTENSION_ISSUE_CODES: readonly ["UNSUPPORTED_PROPERTY_TYPE", "INVALID_PROPERTY_REFINEMENT", "NESTED_ARRAY", "NESTED_OBJECT_TOO_DEEP", "INVALID_MODIFIER_TARGET", "INVALID_ENUM_VALUES", "INVALID_NUMBER_BOUNDS", "INVALID_LENGTH_BOUNDS", "INVALID_PATTERN", "INVALID_EMBEDDING_DIMENSIONS", "INVALID_SEARCHABLE_LANGUAGE", "RESERVED_PROPERTY_NAME", "INVALID_KIND_NAME", "DUPLICATE_KIND_NAME", "EMPTY_PROPERTIES", "EMPTY_FROM_OR_TO", "DUPLICATE_UNIQUE_CONSTRAINT", "EMPTY_UNIQUE_FIELDS", "DUPLICATE_UNIQUE_FIELD", "UNKNOWN_UNIQUE_FIELD", "INVALID_UNIQUE_WHERE_OP", "UNKNOWN_UNIQUE_WHERE_FIELD", "INVALID_ANNOTATION", "UNKNOWN_META_EDGE", "ONTOLOGY_CYCLE", "ONTOLOGY_SELF_LOOP", "ONTOLOGY_DISJOINT_CONFLICT", "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS", "DUPLICATE_ONTOLOGY_RELATION", "ONTOLOGY_COMPOSITION_VIA_REQUIRED", "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN", "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN", "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN", "INVALID_DOCUMENT_SHAPE", "UNKNOWN_DOCUMENT_KEY", "UNSUPPORTED_STRING_FORMAT", "INVALID_INDEX_DECLARATION", "DUPLICATE_INDEX_NAME", "EMPTY_INDEX_FIELDS", "UNKNOWN_PROPERTY_KEY"]; // @public export class GraphAlgorithmConvergenceError extends TypeGraphError { @@ -4056,7 +4425,7 @@ export type GraphBackend = Readonly<{ hardDeleteEdgesBatch?: (this: void, params: DeleteEdgesBatchParams) => Promise; getEdge: (this: void, graphId: string, id: string) => Promise; getEdges?: (this: void, graphId: string, ids: readonly string[]) => Promise; - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: (this: void, params: CountEdgesAtEndpointParams) => Promise; edgeExistsBetween: (this: void, params: EdgeExistsBetweenParams) => Promise; findEdgesConnectedTo: (this: void, params: FindEdgesConnectedToParams) => Promise; findNodesByKind: (this: void, params: FindNodesByKindParams) => Promise; @@ -4101,9 +4470,10 @@ export type GraphBackend = Readonly<{ graphId: string; expectedVersion: number; }>) => Promise; - lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaWriteFenceParams) => Promise; + lockSchemaVersionAndGraphWrite?: (this: void, params: SchemaGraphWriteFenceParams) => Promise; commitSchemaVersionWithPreflight?: (this: void, params: CommitSchemaVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; setActiveVersion: (this: void, params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight?: (this: void, params: SetActiveVersionParams, preflight: (target: SchemaCommitPreflightBackend) => Promise) => Promise; registerGraphTemplate?: (this: void, params: Readonly<{ templateId: string; schemaHash: string; @@ -4284,12 +4654,12 @@ type GraphDefConfig, TEdges exte // @public export type GraphEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // @public export type GraphEdgeForKinds> = { - [P in K]: Edge) ? N : NodeType>; + [P in K]: EdgeInGraph) ? N : NodeType>; }[K]; // @public (undocumented) @@ -4417,9 +4787,14 @@ export type GraphLifecycleBackend = Pick = { - [K in keyof G["nodes"] & string]-?: NodeCollection>; + [K in keyof G["nodes"] & string]-?: NodeCollection, CompositionWholeKinds>; }; +// @public +export type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + // @public export type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -4428,6 +4803,9 @@ export type GraphNodeReference = { }>; }[NodeKinds]; +// @public +export type GraphNodeReferenceInput = GraphNodeReference | SubsumedNodeReference>; + // @public export type GraphStorageConsistency = "snapshot" | "per-statement"; @@ -4451,6 +4829,7 @@ export type GraphTemplate = Readonly<{ id: string; reconciled: ReconciledSchema; schemaHash: string; + registryHash?: string; }>; // @public @@ -4461,6 +4840,15 @@ type GraphTemplateRow = Readonly<{ created_at: string; }>; +// @public +type GraphWithoutOntology = GraphDef, Record, readonly []>; + +// @public +export type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + // @public type GroupBySpec = Readonly<{ fields: readonly (DatabaseExpression | FieldRef)[]; @@ -4497,7 +4885,7 @@ export type HardDeleteUniquesByNodeIdsParams = Readonly<{ type HasMeta = Selection extends readonly string[] ? "meta" extends Selection[number] ? true : false : false; // @public -export function hasPart(whole: NodeType, part: NodeType): OntologyRelation; +export function hasPart(whole: Whole, part: Part, options: CompositionOptions): TypedOntologyRelation; // @public export function having(aggregate: AggregateExpr, op: ComparisonOp, value: number | string | boolean): AggregateComparisonPredicate; @@ -4555,7 +4943,7 @@ export type HeterogeneousNodeUpsertResult = ResolvedStoreCore & StoreEvolution> & Readonly<{ @@ -4684,6 +5072,12 @@ export type IdConfig = Readonly<{ edgeIdGenerator: IdGenerator; }>; +// @public +export const IDENTITY_REPLAY_DEFAULT_LIMIT = 200; + +// @public (undocumented) +export const IDENTITY_REPLAY_MAX_LIMIT = 2000; + // @public export type IdentityAssertion = Readonly<{ id: IdentityAssertionId; @@ -4759,6 +5153,15 @@ export type IdentityContradictionErrorDetails = Readonly<{ conflictingKinds?: readonly [string, string]; }>; +// @public +export type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + // @public export class IdentityEndpointValidityError extends TypeGraphError { constructor(details: IdentityEndpointValidityErrorDetails); @@ -4793,6 +5196,36 @@ export type IdentityFacade = IdentityReadFacade & Readonl retractSameAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; retractDifferentAssertion: (a: IdentityNodeRefInput, b: IdentityNodeRefInput) => Promise | undefined>; bulkRetractAssertions: (ids: readonly IdentityAssertionId[]) => Promise[]>; + transitionsOf: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; + replay: (ref: IdentityNodeRefInput, options?: IdentityReplayOptions) => Promise>; +}>; + +// @public +export type IdentityHistoryCompleteness = Readonly<{ + status: "complete"; +}> | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; +}>; + +// @public +export type IdentityHistoryIncompleteReason = Readonly<{ + reason: "more-pages"; + nextCursor: TransitionPageCursor; +}> | Readonly<{ + reason: "retention-truncated"; + before: RecordedInstant; +}> | Readonly<{ + reason: "unattributed-restored"; + count: number; +}> | Readonly<{ + reason: "uncaptured-members"; + count: number; +}> | Readonly<{ + reason: "restored-not-reconstructed"; + count: number; +}> | Readonly<{ + reason: "pending-transaction-notes"; }>; // @public @@ -4827,6 +5260,65 @@ export type IdentityReadFacade = Readonly<{ // @public export type IdentityRelation = "same" | "different"; +// @public (undocumented) +export type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +export class IdentityReplayError extends TypeGraphError { + constructor(message: string, details: IdentityReplayErrorDetails, options?: Readonly<{ + suggestion?: string; + }>); + // (undocumented) + readonly details: IdentityReplayErrorDetails; +} + +// @public (undocumented) +export type IdentityReplayErrorDetails = Readonly<{ + code: "IDENTITY_REPLAY_REQUIRES_HISTORY"; + graphId: string; +}> | Readonly<{ + code: "IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED"; + graphId: string; +}> | Readonly<{ + code: "IDENTITY_REPLAY_HISTORY_TRUNCATED"; + requestedFrom?: string; + requestedTo: string; + prunedBefore: string; +}> | Readonly<{ + code: "IDENTITY_REPLAY_WALK_INCOMPLETE"; + ceiling: number; +}> | Readonly<{ + code: "IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK"; + requestedBefore: string; + requestedRevision: number; + highestPrunableRevision: number; +}>; + +// @public +export type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +// @public +export type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +// @public +type IdentityRestoreBaseline = Readonly<{ + hasOwnHistory: boolean; + floorRevision: number; +}>; + // @public export type IdentitySamePathStep = Readonly<{ from: IdentityNodeReference; @@ -4864,12 +5356,62 @@ export type IdentitySeparationViolationErrorDetails = Readonly<{ }>; }>; +// @public (undocumented) +type IdentityServiceContext = Readonly<{ + graph: G; + graphId: string; + schemaVersion: number | undefined; + registry: KindRegistry; + backend: GraphBackend | TransactionBackend; + schema: SqlSchema; + historyEnabled: boolean; + recordedTimeOwnership: RecordedTimeOwnership; + revisionTrackingEnabled: boolean; + sameIdAcrossKinds: "fold" | "ignore"; + coordinate?: ReadCoordinate; + loadNodes: (references: readonly PlainNodeRef[], coordinate?: ReadCoordinate) => Promise | undefined)[]>; +}>; + // @public type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; +}>; + +// @public +export type IdentityTransition = RecordedIdentityTransition | RestoredIdentityTransition; + +// @public +export type IdentityTransitionCause = "assert" | "retract" | "fold" | "detach" | "restore" | "window-end" | "kind-drop" | "schema-transition" | "reconcile"; + +// @public +export type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +// @public +export type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +// @public +export type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; }>; // @public @@ -4905,6 +5447,7 @@ export type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + transitions: number; total: number; }>; @@ -4918,7 +5461,7 @@ export type IfExistsMode = "return" | "update"; export const IMMUTABLE_VALIDITY_LOWER_BOUND_CODE = "IMMUTABLE_VALIDITY_LOWER_BOUND"; // @public -export function implies(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation; +export function implies(edgeA: A, edgeB: B): TypedOntologyRelation; // @public const INCOMPATIBLE_CHANGE_TYPES: readonly ["REMOVE_PROPERTY", "ADD_REQUIRED_PROPERTY", "TIGHTEN_OPTIONALITY", "TIGHTEN_CONSTRAINT", "ADD_PATTERN", "CHANGE_PATTERN", "ADD_FORMAT", "CHANGE_FORMAT", "TIGHTEN_INT", "TIGHTEN_ENUM", "TYPE_CHANGE", "ADD_UNIQUE_ON_POPULATED", "TIGHTEN_EDGE_ENDPOINTS"]; @@ -4943,6 +5486,13 @@ export class IncompatibleChangeError extends GraphExtensionError { // @public (undocumented) type IncompatibleChangeType = (typeof INCOMPATIBLE_CHANGE_TYPES)[number]; +// @public +type IncompatibleKeys = { + [K in LiteralKeysOf]-?: K extends keyof Child ? [ + Child[K] + ] extends [Parent[K]] ? never : K : undefined extends Parent[K] ? never : K; +}[LiteralKeysOf] & string; + // @public type IndexChange = Readonly<{ type: ChangeType; @@ -5089,9 +5639,6 @@ type IndexWhereOperand = Readonly<{ valueType: ValueType | undefined; }>; -// @public -export type InferenceType = "subsumption" | "hierarchy" | "substitution" | "constraint" | "composition" | "association" | "none"; - // @public export type InitialQueryBuilder = QueryBuilder; @@ -5207,7 +5754,7 @@ type InternalReachableOptions = InternalBaseTraversalOptions type InternalShortestPathOptions = InternalBaseTraversalOptions; // @public -type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ +type InternalSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -5248,7 +5795,7 @@ export type InvalidEdgeWeightErrorDetails = Readonly<{ export type InvalidEdgeWeightReason = "missing" | "negative" | "non_numeric" | "out_of_range"; // @public -export function inverseOf(edgeA: AnyEdgeType, edgeB: AnyEdgeType): OntologyRelation; +export function inverseOf(edgeA: A, edgeB: B): TypedOntologyRelation; // @public export function invertClosure(closure: ReadonlyMap>): ReadonlyMap>; @@ -5256,6 +5803,14 @@ export function invertClosure(closure: ReadonlyMap>) // @public export const INVERTED_VALIDITY_WINDOW_CODE = "INVERTED_VALIDITY_WINDOW"; +// @public (undocumented) +const IRI_ENDPOINT: unique symbol; + +// @public (undocumented) +type IriEndpointKey = Readonly<{ + [IRI_ENDPOINT]: true; +}>; + // @public export function isConstraintError(error: unknown): boolean; @@ -5374,8 +5929,12 @@ type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -5386,6 +5945,7 @@ type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; @@ -5437,6 +5997,9 @@ export type KindIntrospection = Readonly<{ unique: readonly UniqueIntrospection[]; }>; +// @public (undocumented) +type KindNodeTypes = K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + // @public export class KindNotFoundError extends TypeGraphError { constructor(kindName: string, entity: KindEntity, options?: Readonly<{ @@ -5475,25 +6038,25 @@ export type KindReferent = Readonly<{ // @public class KindRegistry { - constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, identity?: GraphIdentityConfig); + constructor(nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, closures: RegistryClosures, identity?: GraphIdentityConfig, composition?: CompositionRelation, edgeFacts?: ReadonlyMap); areDisjoint(a: string, b: string): boolean; areEquivalent(a: string, b: string): boolean; // (undocumented) readonly broaderClosure: ReadonlyMap>; + compositionEdgeKinds(): readonly string[]; + compositionEdgeKindsOver(partKind: string): readonly string[]; + compositionEdgeKindsUnder(wholeKind: string): readonly string[]; + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined; + compositionExistence(concretePartKind: string): CompositionExistence; + compositionPairsBetween(partKind: string, wholeKind: string): readonly CompositionPair[]; + compositionPairVia(partKind: string, wholeKind: string, viaEdgeKind: string): CompositionPair | undefined; + compositionPartKindsUnder(wholeKind: string): readonly string[]; + compositionPartKindsUnderVia(wholeKind: string, viaEdgeKind: string): readonly string[]; + compositionPartSide(edgeKind: string): CompositionPartSide | undefined; + compositionPopulation(concretePartKind: string): "one" | "oneActive" | undefined; + compositionRelation(): CompositionRelation; + compositionWholeKindsOver(partKind: string): readonly string[]; + compositionWholeKindsOverVia(partKind: string, viaEdgeKind: string): readonly string[]; disjointKindPairs(): readonly (readonly [string, string])[]; disjointPairLabel(a: string, b: string): string; // (undocumented) @@ -5504,6 +6067,7 @@ class KindRegistry { readonly edgeImplyingClosure: ReadonlyMap>; // (undocumented) readonly edgeInverses: ReadonlyMap; + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined; // (undocumented) readonly edgeKinds: ReadonlyMap; // (undocumented) @@ -5535,6 +6099,9 @@ class KindRegistry { isAssignableTo(concreteKind: string, targetKind: string): boolean; isAssignableToAny(concreteKind: string, targetKinds: readonly string[]): boolean; isBroaderThan(broaderConcept: string, narrowerConcept: string): boolean; + isCompositionEdge(edgeKind: string): boolean; + isCompositionPart(kind: string): boolean; + isCompositionWhole(kind: string): boolean; isNarrowerThan(narrowerConcept: string, broaderConcept: string): boolean; isPartOf(part: string, whole: string): boolean; isSubClassOf(child: string, parent: string): boolean; @@ -5616,6 +6183,9 @@ type LineageMembers = Readonly<{ // @public type LineageSession = Pick; +// @public +type ListAliasNodeType = K extends unknown ? AliasNodeType : never; + // @public export function listGraphIds(backend: GraphBackend, options?: ListGraphIdsOptions): Promise; @@ -5626,6 +6196,9 @@ export type ListGraphIdsOptions = Readonly<{ limit?: number | undefined; }>; +// @public +type ListPolymorphicNodeType = K extends unknown ? PolymorphicNodeType : never; + // @public (undocumented) function literal(value: T): DatabaseExpression, never>; @@ -5635,6 +6208,11 @@ type LiteralExpressionNode = Readonly<{ value: DatabaseLiteral; }>; +// @public +type LiteralKeysOf = { + [K in keyof T]-?: string extends K ? never : K; +}[keyof T] & string; + // @public (undocumented) type LiteralResult = T extends null ? undefined : T; @@ -5668,7 +6246,7 @@ export type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; // @public @@ -5694,6 +6272,9 @@ type MapRowToMeta>, M extends Readonl [SnakeKey in keyof M as M[SnakeKey] & string]: SnakeKey extends keyof R ? R[SnakeKey] : never; }>; +// @public +type MatchedEndpoints = ([Extract] extends [never] ? never : To) | ([Extract] extends [never] ? never : From | To); + // @public type MatchesOptions = Readonly<{ mode?: FulltextQueryMode; @@ -5794,13 +6375,43 @@ export type MeasurableTransactionContext = TransactionContex type MembershipOperand = readonly T[] | ParameterRef; // @public -type MergeAliasMaps = keyof Added extends never ? Existing : Existing & Added; +type MergeAliasMaps = keyof Added extends never ? Existing : Existing & Added; + +// @public (undocumented) +type MergeEdgeAliasMaps = keyof Added extends never ? Existing : Existing & Added; + +// @public +const META_EDGE_BRAND: "__metaEdge"; + +// @public +const META_EDGE_BROADER: "broader"; + +// @public +const META_EDGE_DISJOINT_WITH: "disjointWith"; + +// @public +const META_EDGE_EQUIVALENT_TO: "equivalentTo"; + +// @public +const META_EDGE_HAS_PART: "hasPart"; + +// @public +const META_EDGE_IMPLIES: "implies"; + +// @public +const META_EDGE_INVERSE_OF: "inverseOf"; + +// @public +const META_EDGE_NARROWER: "narrower"; + +// @public +const META_EDGE_PART_OF: "partOf"; -// @public (undocumented) -type MergeEdgeAliasMaps = keyof Added extends never ? Existing : Existing & Added; +// @public +const META_EDGE_RELATED_TO: "relatedTo"; // @public -const META_EDGE_BRAND: "__metaEdge"; +const META_EDGE_SUB_CLASS_OF: "subClassOf"; // @public export type MetaEdge = Readonly<{ @@ -5809,29 +6420,11 @@ export type MetaEdge = Readonly<{ properties: MetaEdgeProperties; }>; -// @public -export function metaEdge(name: K, options?: MetaEdgeOptions): MetaEdge; - // @public (undocumented) type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; -// @public -export type MetaEdgeOptions = Readonly<{ - transitive?: boolean; - symmetric?: boolean; - reflexive?: boolean; - inverse?: string; - inference?: InferenceType; - description?: string; -}>; - // @public export type MetaEdgeProperties = Readonly<{ - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -5887,7 +6480,7 @@ export type MigrateRecordedAnchorOptions = Readonly<{ }>; // @public -export const MIGRATION_FAILURE_REASONS: readonly ["schema-behind", "breaking-change", "no-active-version", "version-not-found", "kind-removal", "edge-match-identity-rekey"]; +export const MIGRATION_FAILURE_REASONS: readonly ["schema-behind", "breaking-change", "no-active-version", "version-not-found", "kind-removal", "edge-match-identity-rekey", "ontology-tightening-violated", "edge-cardinality-tightening-violated", "unique-constraint-requires-empty-kind", "custom-relation-removal"]; // @public export class MigrationError extends TypeGraphError { @@ -5925,6 +6518,32 @@ export type MigrationErrorDetails = Readonly<{ toVersion: number; reason: "edge-match-identity-rekey"; edgeKinds: readonly string[]; +}> | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "unique-constraint-requires-empty-kind"; + constraints: readonly UniquenessClaimGate[]; +}> | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "ontology-tightening-violated"; + changes: readonly OntologyChange[]; + violations: readonly ConstraintFenceViolation[]; +}> | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "edge-cardinality-tightening-violated"; + axes: readonly EdgeCardinalityDeclaration[]; + violations: readonly ConstraintFenceViolation[]; +}> | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "custom-relation-removal"; + relations: readonly string[]; }>; // @public (undocumented) @@ -5942,7 +6561,17 @@ type MigrationHookContext = Readonly<{ export function min(alias: Alias, field: Property): AggregateExpr<"min", FieldRef>; // @public -export function narrower(broaderConcept: NodeType, narrowerConcept: NodeType): OntologyRelation; +export type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + +// @public +export function narrower(broaderConcept: B, narrowerConcept: N): TypedOntologyRelation; // @public export type NeighborNodeOrderField = { @@ -6007,7 +6636,7 @@ const NODE_TEMPORAL_READ_NAMES: readonly ["getById", "getByIds", "find", "count" const NODE_TYPE_BRAND: "__nodeType"; // @public -const NODE_WRITE_NAMES: readonly ["create", "createFromRecord", "update", "compareAndSet", "updateWhere", "delete", "hardDelete", "upsertById", "upsertByIdFromRecord", "bulkCreate", "bulkReplaceById", "bulkUpsertById", "bulkInsert", "bulkDelete", "getOrCreateByConstraint", "bulkGetOrCreateByConstraint"]; +const NODE_WRITE_NAMES: readonly ["create", "createFromRecord", "update", "reparent", "bulkReparent", "compareAndSet", "updateWhere", "delete", "hardDelete", "upsertById", "upsertByIdFromRecord", "bulkCreate", "bulkReplaceById", "bulkUpsertById", "bulkInsert", "bulkDelete", "getOrCreateByConstraint", "bulkGetOrCreateByConstraint"]; // @public export type NodeAccessor = IsDynamicNodeType extends true ? DynamicNodeAccessor : Readonly<{ @@ -6055,12 +6684,8 @@ type NodeChange = Readonly<{ }>; // @public -export type NodeCollection = Readonly<{ - create: (props: z.input, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; +export type NodeCollection = Readonly<{ + create: (props: z.input, options?: NodeCreateOptions) => Promise>; getById: (id: NodeId, options?: QueryOptions) => Promise | undefined>; getByIds: (ids: readonly NodeId[], options?: QueryOptions) => Promise | undefined)[]>; update: (id: NodeId, props: Partial>, options?: ValidityEndMutation) => Promise>; @@ -6083,19 +6708,21 @@ export type NodeCollection = Rea }>) => Promise>; - delete: (id: NodeId) => Promise; + reparent: (id: NodeId, options: NodeReparentOptions) => Promise>; + bulkReparent: (items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[]) => Promise[]>; + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; hardDelete: (id: NodeId) => Promise; + planDelete: (id: NodeId) => Promise; find: (filter?: Readonly<{ where?: (accessor: NodeAccessor) => Predicate; limit?: number; offset?: number; }>, temporal?: QueryOptions) => Promise[]>; count: (temporal?: QueryOptions) => Promise; - createFromRecord: (data: Record, options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>) => Promise>; + createFromRecord: (data: Record, options?: NodeCreateOptions) => Promise>; upsertById: (id: string, props: z.input, options?: Readonly<{ validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -6104,12 +6731,9 @@ export type NodeCollection = Rea validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; }> & ValidityEndMutation) => Promise>; - bulkCreate: (items: readonly Readonly<{ + bulkCreate: (items: readonly (Readonly<{ props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[]) => Promise[]>; + }> & NodeCreateOptions)[]) => Promise[]>; bulkUpsertById: (items: readonly (Readonly<{ id: string; props: z.input; @@ -6134,10 +6758,10 @@ export type NodeCollection = Rea bulkFindByIndex: (indexName: string, items: readonly Readonly<{ props: Partial>; }>[], options?: NodeBulkFindByIndexOptions) => Promise[][]>; - getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; - bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ + getOrCreateByConstraint: (constraintName: CN, props: z.input, options?: NodeGetOrCreateByConstraintOptions) => Promise>; + bulkGetOrCreateByConstraint: (constraintName: CN, items: readonly Readonly<{ props: z.input; - }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; + }>[], options?: NodeGetOrCreateByConstraintOptions) => Promise[]>; }>; // @public (undocumented) @@ -6182,9 +6806,58 @@ export type NodeCreateCommandResult = Readonly<{ ]; }>; +// @public +export type NodeCreateOptions = Readonly<{ + id?: string; + validFrom?: string | null; + validTo?: string; + partOf?: CompositionAttachment; +}>; + // @public export type NodeCurrentReads = Pick, (typeof CURRENT_ONLY_READ_NAMES)[number]>; +// @public +export type NodeDeleteOptions = Readonly<{ + expect: NodeDeletePlan; +}>; + +// @public +export type NodeDeletePlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +// @public +export class NodeDeletePlanMismatchError extends TypeGraphError { + constructor(details: NodeDeletePlanMismatchErrorDetails, options?: { + cause?: unknown; + }); + // (undocumented) + readonly details: NodeDeletePlanMismatchErrorDetails; +} + +// @public +export type NodeDeletePlanMismatchErrorDetails = Readonly<{ + kind: string; + id: string; +}> & (Readonly<{ + situation: "notLive"; +}> | Readonly<{ + situation: "changed"; + addedParts: readonly CompositionCascadedPart[]; + droppedParts: readonly CompositionCascadedPart[]; + addedEdgeIds: readonly string[]; + droppedEdgeIds: readonly string[]; +}>); + +// @public +type NodeDeletePolicy = Readonly<{ + enforceDeleteBehavior: boolean; + consumedEdgeIds?: ReadonlySet; + cascadeComposition?: boolean; +}>; + // @public (undocumented) export type NodeEntityReadBackend = Pick; @@ -6192,8 +6865,9 @@ export type NodeEntityReadBackend = Pick; // @public -export type NodeGetOrCreateByConstraintOptions = Readonly<{ +export type NodeGetOrCreateByConstraintOptions = Readonly<{ ifExists?: IfExistsMode; + partOf?: CompositionAttachment; }>; // @public @@ -6390,6 +7064,29 @@ export type NodeRef = Node | Readonly<{ id: string; }>; +// @public (undocumented) +type NodeReferenceGroup = R extends Readonly<{ + kind: infer K extends string; + id: infer I; +}> ? Readonly<{ + kind: K; + ids: readonly I[]; +}> : never; + +// @public +export interface NodeReferenceLookup { + // (undocumented) + >(reference: Readonly<{ + kind: K; + id: string; + }>): Promise | undefined>; + // (undocumented) + (reference: Readonly<{ + kind: string; + id: string; + }>): Promise> | Node | undefined>; +} + // @public export type NodeRegistration = Readonly<{ type: N; @@ -6397,6 +7094,17 @@ export type NodeRegistration = Readonly<{ onDelete?: DeleteBehavior; }>; +// @public +export type NodeReparentOptions = Omit, "validFrom" | "validTo"> & Readonly<{ + at?: string; +}>; + +// @public +export type NodeReparentResult = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + // @public type NodeRow = Readonly<{ graph_id: string; @@ -6602,10 +7310,38 @@ type OneStatementReadProvenance = Readonly<{ // @public type OntologyChange = Readonly<{ type: ChangeType; - entity: "metaEdge" | "relation"; + entity: "relation" | "edgeRegistration"; name: string; severity: ChangeSeverity; details: string; + probes?: readonly OntologyDataProbe[]; + removedCustomRelation?: string; +}>; + +// @public (undocumented) +export type OntologyDataProbe = OntologyDataProbeBody & Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; +}>; + +// @public +type OntologyDataProbeBody = Readonly<{ + kind: "nodeDisjointness"; + pairs: readonly (readonly [string, string])[]; +}> | Readonly<{ + kind: "nodeUniqueness"; + groups: readonly UniquenessComponentProbeGroup[]; +}> | Readonly<{ + kind: "edgeEndpointAssignability"; + allowances: readonly EdgeEndpointAllowance[]; +}> | Readonly<{ + kind: "edgeAcyclicity"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "composition"; + edgeKinds: readonly string[]; +}> | Readonly<{ + kind: "compositionExistence"; + edgeKinds: readonly string[]; }>; // @public (undocumented) @@ -6613,6 +7349,9 @@ export type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -6621,16 +7360,24 @@ export type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; to: NodeType | AnyEdgeType | string; -}>; +}> & CompositionRealization; + +// @public +type OntologyTypeErased = number extends G["ontology"]["length"] ? true : false; // @public -export type OperationHookContext = HookContext & Readonly<{ +export type OperationHookContext = HookContext & OperationOutcomeFacts & Readonly<{ operation: "create" | "update" | "delete"; entity: KindEntity; kind: string; id: string; }>; +// @public +type OperationOutcomeFacts = Readonly<{ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + // @public type OptionalGraphBackendMember = OptionalKeys; @@ -6729,7 +7476,7 @@ type ParameterValue = Value extends "boolean" ? boolean export function parseJsonPointer(pointer: JsonPointer): readonly string[]; // @public -export function partOf(part: NodeType, whole: NodeType): OntologyRelation; +export function partOf(part: Part, whole: Whole, options: CompositionOptions): TypedOntologyRelation; // @public export type PathNode = Readonly<{ @@ -6761,6 +7508,12 @@ export class Placeholder { readonly name: string; } +// @public +type PlainNodeRef = Readonly<{ + kind: string; + id: string; +}>; + // @public export type PlanEvolutionOptions = Readonly<{ source?: "database" | "cached"; @@ -6782,6 +7535,11 @@ type PointerSegmentsForObject = { [K in ObjectPointerKey]: readonly [K] | (Current extends 1 ? readonly [K] : readonly [K, ...JsonPointerSegmentsFor>]); }[ObjectPointerKey]; +// @public +export type PolymorphicNodeType = Omit & Readonly<{ + kind: string; +}>; + // @public (undocumented) export type PopulatedSchemaKind = SchemaKindEmptinessProbe & Readonly<{ count: number; @@ -6827,6 +7585,19 @@ type PreparedQueryConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; }>; +// @public +export const PROBE_VIOLATION_FAMILIES: { + readonly nodeDisjointness: readonly ["nodeDisjointness"]; + readonly nodeUniqueness: readonly ["nodeUniqueness"]; + readonly edgeEndpointAssignability: readonly ["edgeEndpointAssignability"]; + readonly edgeAcyclicity: readonly ["edgeAcyclicity"]; + readonly composition: readonly ["composition", "edgeAcyclicity", "compositionFutureWindow"]; + readonly compositionExistence: readonly ["compositionExistence"]; +}; + +// @public (undocumented) +export function probeCoversViolationFamily(probe: Pick, family: string): boolean; + // @public (undocumented) type ProjectedEdgeResult = Readonly, "id" | "kind" | "fromKind" | "fromId" | "toKind" | "toId">> & Readonly> & (HasMeta extends true ? Readonly<{ meta: EdgeMeta; @@ -6882,6 +7653,14 @@ export type PropsAccessor = Readonly<{ [K in CommonPropertyKeys>]-?: FieldAccessor[K]>; }>; +// @public +export function pruneIdentityTransitions(store: Store, options: Readonly<{ + beforeRecorded: string; +}>): Promise>; + // @public type PurgeEdgeClaimsParams = Readonly<{ graphId: string; @@ -6944,21 +7723,39 @@ export class QueryBuilder>(fields: R): ExecutableAggregateQuery; count(): Promise; exists(): Promise; - from(kinds: Kinds, alias: UniqueAlias): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options?: { + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "exact"; + }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: { + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from, A extends string>(kinds: Kinds, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + from(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases, RecursiveAliases, CoordinateState>; // (undocumented) from(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; - fromDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + from(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>>, EdgeAliases, RecursiveAliases, CoordinateState>; + // (undocumented) + fromDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>>>, EdgeAliases, RecursiveAliases, CoordinateState>; + fromDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder, EdgeAliases, RecursiveAliases, CoordinateState>; fuseWith(options: HybridFusionOptions): QueryBuilder; // @internal getExpressionScopeIdentity(): symbol; @@ -6988,6 +7785,7 @@ export class QueryBuilder) => DatabaseExpression, direction?: SortDirection): QueryBuilder; // (undocumented) orderBy(alias: A, field: string, direction?: SortDirection): QueryBuilder; + parts = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; pipe(fragment: (builder: QueryBuilder) => QueryBuilder): QueryBuilder; project>>>(build: (context: QueryExpressionContext) => Fields): ExecutableProjectionQuery>; select(selectFunction: (context: SelectContext) => R): ExecutableQuery; @@ -7014,6 +7812,7 @@ export class QueryBuilder) => DatabaseExpression): QueryBuilder; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; whereNode(alias: A, predicateFunction: (n: NodeAccessor, expressions: QueryExpressionContext) => Predicate | DatabaseExpression): QueryBuilder; + wholes = Record>(nodeAlias: UniqueAlias, options?: O): CompositionNavigationResult; } // @public @@ -7022,6 +7821,7 @@ type QueryBuilderConfig = Readonly<{ registry: KindRegistry; schemaIntrospector: SchemaIntrospector; defaultTraversalExpansion: TraversalExpansion; + defaultExpansion: DefaultAliasExpansionAxis; identityEnabled: boolean; identitySameIdAcrossKinds: "fold" | "ignore"; backend?: GraphBackend; @@ -7033,8 +7833,9 @@ type QueryBuilderConfig = Readonly<{ type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + startRequestedKinds: readonly string[]; currentAlias: string; - includeSubClasses: boolean; + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; resultPredicate?: PredicateExpression; @@ -7081,7 +7882,7 @@ export type QueryOptions = NoRecordedCoordinate & Readonly<{ type QueryStart = Readonly<{ alias: string; kinds: readonly string[]; - includeSubClasses: boolean; + expansion: AliasExpansionAxis; }>; // @public (undocumented) @@ -7108,6 +7909,7 @@ type ReadConstraintFenceViolationsParams = Readonly<{ uniqueConstraintNames: readonly string[]; disjointKindPairs: readonly (readonly [string, string])[]; edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; }>; // @public @@ -7213,6 +8015,19 @@ export type RecordedHeterogeneousNodeWriteBatch = Readonly<{ writeNodeUpsertBatch: []>(entries: Entries) => Promise>; }>; +// @public +export type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + // @public export type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; @@ -7293,13 +8108,13 @@ export const RecordedStoreView: new (store: Store, coordi export type RecordedStoreViewEdgeCollection = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ - scan: (options?: RecordedScanOptions) => Promise>>; +}, G extends GraphDef = GraphWithoutOntology> = Pick, (typeof RECORDED_POINT_READ_NAMES)[number]> & Readonly<{ + scan: (options?: RecordedScanOptions) => Promise>>; }>; // @public export type RecordedStoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection; }; // @public @@ -7434,7 +8249,24 @@ export function registerGraphTemplate(backend: GraphBackend, }>): Promise>; // @public -export function relatedTo(conceptA: NodeType, conceptB: NodeType): OntologyRelation; +type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + +// @public +export function relatedTo(conceptA: A, conceptB: B): TypedOntologyRelation; // @public type RelationalIndexDeclaration = NodeIndexDeclaration | EdgeIndexDeclaration; @@ -7641,6 +8473,8 @@ export type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; fulltext: string; uniques: string; edgeClaims: string; @@ -7664,7 +8498,7 @@ export type ResolveJsonPointer = Pointer extends "" ? export type ResolveJsonPointerSegments = Segments extends readonly [] ? T : Segments extends readonly [infer Head, ...infer Tail] ? ResolveJsonPointerSegments>, Extract> : unknown; // @public -type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node : Node : Node; +type ResolveNode = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? Node> : Node : Node; // @public type ResolveNodeType = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? N : NodeType : NodeType; @@ -7678,6 +8512,19 @@ type ResolvePathFormat = PC extends QualifiedRecursivePathOption ? "qualifie // @public (undocumented) type ResolvePointerSegment = T extends readonly (infer U)[] ? Segment extends NonNegativeIntegerString ? U : unknown : T extends Record ? Segment extends keyof T ? Segment extends NonNegativeIntegerString ? unknown : T[Segment] : unknown : unknown; +// @public +export type RestoredIdentityTransition = Omit, "recorded" | "restored"> & Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ + at: string; + }>; +}>; + +// @public +export type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + // @public export class RestrictedDeleteError extends TypeGraphError { constructor(details: RestrictedDeleteErrorDetails, options?: { @@ -7767,9 +8614,6 @@ export type RuntimeNodeReferenceFor = T extends Runti // @public export type RuntimeNodeTypeFor = T extends RuntimeNodeKind ? NodeType : never; -// @public @deprecated -export function sameAs(kindA: NodeType, kindBOrIri: NodeType | string): OntologyRelation; - // @public (undocumented) type ScalarExpressionSubqueryRelation = ExpressionSubqueryRelation]>; @@ -7799,6 +8643,7 @@ export type SchemaCommitBackend = Pick Promise; + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; // @public @@ -7852,6 +8697,11 @@ export type SchemaFenceTimeoutErrorDetails = Readonly<{ waitBudgetMs: number; }>; +// @public +type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & Readonly<{ + fenceRow: "advance" | "leave"; +}>; + // @public export type SchemaHash = string; @@ -7882,7 +8732,8 @@ type SchemaIntrospector = Readonly<{ getSharedEdgeFieldTypeInfo: (edgeKindNames: readonly string[], fieldName: string) => FieldTypeInfo | undefined; hasDeclaredField: (kindNames: readonly string[], fieldName: string) => boolean; hasDeclaredEdgeField: (edgeKindNames: readonly string[], fieldName: string) => boolean; - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: (kindNames: readonly string[]) => readonly string[]; + kindsDivergingFromSubsumingDeclaration: (kindNames: readonly string[], fieldName: string) => readonly string[]; }>; // @public (undocumented) @@ -7975,6 +8826,9 @@ type SchemaWriteTransactionBackend = TransactionBackend & Readonly<{ // @public export type ScopedMeasure = (fn: (scoped: Context) => Promise) => Promise>; +// @public +export const SEARCH_EXPANSION_DEFAULT: "exact"; + // @public export function searchable(options?: SearchableOptions): SearchableSchema; @@ -7996,11 +8850,16 @@ export type SearchableSchema = z.ZodString & Readonly<{ [SEARCHABLE_FIELD_KEY]: SearchableMetadata; }>; +// @public +type SearchNodeType = [ +E +] extends ["exact"] ? N : PolymorphicNodeType; + // @public export type SearchScopeOptions = Readonly<{ where?: (accessor: NodeAccessor) => Predicate; offset?: number; - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; // @public (undocumented) @@ -8089,11 +8948,13 @@ type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -8101,11 +8962,6 @@ type SerializedEdgeDef = Readonly<{ // @public type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -8131,7 +8987,7 @@ type SerializedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & CompositionRealization; // @public export type SerializedSchema = Readonly<{ @@ -8210,6 +9066,12 @@ type SimilarToOptions = Readonly<{ // @public export type SortDirection = "asc" | "desc"; +// @public +type SourceKindList = readonly [ +keyof G["nodes"] & string, +...(keyof G["nodes"] & string)[] +]; + // @public type SpecOf[]> = { [K in XS[number] as K["id"]]: K["members"][number]; @@ -8283,6 +9145,10 @@ export abstract class SqlSchema implements SqlSchemaFields { // (undocumented) abstract readonly identitySeparationTable: SqlFragment; // (undocumented) + abstract readonly identityTransitionRetentionTable: SqlFragment; + // (undocumented) + abstract readonly identityTransitionsTable: SqlFragment; + // (undocumented) abstract readonly nodesTable: SqlFragment; // (undocumented) abstract readonly recordedClockTable: SqlFragment; @@ -8311,6 +9177,8 @@ type SqlSchemaFields = Readonly<{ recordedIdentityAssertionsTable: SqlFragment; identityClosureTable: SqlFragment; identitySeparationTable: SqlFragment; + identityTransitionsTable: SqlFragment; + identityTransitionRetentionTable: SqlFragment; fulltextTable: SqlFragment; }>; @@ -8328,6 +9196,8 @@ export type SqlTableNames = Readonly<{ recordedIdentityAssertions?: string | undefined; identityClosure?: string | undefined; identitySeparation?: string | undefined; + identityTransitions?: string | undefined; + identityTransitionRetention?: string | undefined; fulltext: string; uniques: string; edgeClaims?: string | undefined; @@ -8369,6 +9239,11 @@ export type StaleVersionErrorDetails = Readonly<{ actual: number; }>; +// @public +type StatedExpansion = Readonly<{ + expansion: E; +}>; + // @public export type Store = ResolvedStoreCore & StoreTransactions & StoreEvolution>; @@ -8419,6 +9294,7 @@ type StoreCore = Readonly<{ runtimeEdgeKind: (kind: K, definition: D) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -8445,12 +9321,12 @@ type StoreCore = Readonly<{ ...BatchableQuery[] ]>(...queries: Queries) => Promise>; batchOnce?: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors?: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors?: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; + neighbors?: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors?: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; bulkFindEdgesFrom: >(params: BulkFindEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindEdgesTo: >(params: BulkFindEdgesToParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; bulkFindRuntimeEdgesFrom: (params: BulkFindRuntimeEdgesFromParams, options?: EdgeBulkFindEndpointOptions) => Promise[]>; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; clear: (options?: Readonly<{ preserveContributionMaterializations?: boolean; }>) => Promise; @@ -8553,7 +9429,7 @@ export type StorePopulationStatistics = Readonly<{ // @public export type StoreProjection = Readonly<{ nodes: { - [K in N]-?: NodeCollection; + [K in N]-?: NodeCollection>; }; edges: Pick, E>; }>; @@ -8571,6 +9447,7 @@ type StoreRuntime = Readonly<{ evolutionPlanningTarget?: (plan: EvolutionPlan) => Store; captureEnabled?: boolean; uniqueSidecarBatch?: BundleVerdictOf | undefined; + batchPointRead: BundleVerdictOf; queryBackend: (target?: GraphBackend | TransactionBackend) => GraphBackend; sealedQuery: (coordinate: ReadCoordinate) => InitialQueryBuilder; recordedNodeGetById: (kind: string, id: NodeId, coordinate: ReadCoordinate) => Promise | undefined>; @@ -8579,11 +9456,16 @@ type StoreRuntime = Readonly<{ recordedEdgeGetById: (kind: string, id: EdgeId, coordinate: ReadCoordinate) => Promise | undefined>; recordedEdgeGetByIds: (kind: string, ids: readonly EdgeId[], coordinate: ReadCoordinate) => Promise | undefined)[]>; recordedEdgeScan: (kind: string, coordinate: ReadCoordinate, options?: RecordedScanOptions) => Promise>>; - subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise>; + subgraphAtCoordinate: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: InternalSubgraphOptions) => Promise, P>>; algorithmsAtCoordinate: (coordinate: ReadCoordinate) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + deleteNodeWithPolicy: (target: GraphBackend | TransactionBackend, work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; applyResolvedNodeUniqueness: (target: TransactionBackend, writes: Readonly<{ upserts: readonly Readonly<{ kind: string; @@ -8738,6 +9620,15 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }>[]) => Promise; + applyImportedNodeWindowEnd: (target: Readonly>, input: Readonly<{ + kind: string; + id: string; + validTo?: string; + }>) => Promise; + detachDeletedImportedIdentityNode: (target: Readonly>, reference: Readonly<{ + kind: string; + id: string; + }>) => Promise; importIdentityAssertionsAtTarget: (target: Readonly>, assertions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -8759,6 +9650,23 @@ type StoreRuntime = Readonly<{ created: number; skipped: number; }>>; + readIdentityTransitionPageAtTarget: (target: GraphBackend | TransactionBackend, options: Readonly<{ + after?: IdentityTransitionCursor; + limit: number; + }>) => Promise>; + identityTransitionRetentionAtTarget: (target: GraphBackend | TransactionBackend) => Promise>; + readIdentityRestoreBaselineAtTarget: (target: Readonly>) => Promise; + importIdentityTransitionsAtTarget: (target: Readonly>, transitions: readonly IdentityTransitionTransfer[], carriedWatermark: number | undefined, baseline: IdentityRestoreBaseline) => Promise>; applyIdentityMergeAtTarget: (target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ id: string; relation: "same" | "different"; @@ -8793,7 +9701,7 @@ type StoreRuntime = Readonly<{ kind: string; id: string; }> | undefined; - }>[]) => Promise[], decision?: IdentityDecisionProvenance) => Promise>; @@ -8806,10 +9714,22 @@ type StoreRuntime = Readonly<{ // @public export class StoreSearch { constructor(context: StoreSearchContext); - fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; - hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; + fulltext(nodeKind: K, options: FulltextSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + fulltext(nodeKind: K, options: FulltextSearchOptions>): Promise>[]>; + hybrid(nodeKind: K, options: HybridSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + hybrid(nodeKind: K, options: HybridSearchOptions>): Promise>[]>; rebuildFulltext(nodeKind?: K, options?: RebuildFulltextOptions): Promise; - vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; + vector(nodeKind: K, options: VectorSearchOptions> & StatedExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions> & ExactExpansion): Promise>[]>; + // (undocumented) + vector(nodeKind: K, options: VectorSearchOptions>): Promise>[]>; } // @public (undocumented) @@ -8878,33 +9798,33 @@ export type StoreViewDegreeOptions = Omit, export type StoreViewEdgeCollection = Readonly<{ - getById: (id: EdgeId) => Promise | undefined>; - getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; +}, G extends GraphDef = GraphWithoutOntology> = Readonly<{ + getById: (id: EdgeId) => Promise | undefined>; + getByIds: (ids: readonly EdgeId[]) => Promise | undefined)[]>; find: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; - }>) => Promise[]>; + }>) => Promise[]>; count: (filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>) => Promise; - findFrom: (from: NodeRef) => Promise[]>; - findTo: (to: NodeRef) => Promise[]>; - bulkFindFrom: (froms: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; - bulkFindTo: (tos: readonly NodeRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + findFrom: (from: EdgeEndpointRef) => Promise[]>; + findTo: (to: EdgeEndpointRef) => Promise[]>; + bulkFindFrom: (froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; + bulkFindTo: (tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions) => Promise[][]>; findByEndpoints: (...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions - ] : never) => Promise | undefined>; + ] : never) => Promise | undefined>; }>; // @public export type StoreViewEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection; }; // @public @@ -8971,7 +9891,7 @@ export type StoreViewReachableOptions = Omit = Omit, keyof TemporalAlgorithmOptions>; // @public -export type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; +export type StoreViewSubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Omit, "temporalMode" | "asOf" | "recordedAsOf">; // @public export type StoreViewWeaklyConnectedComponentsOptions = Omit, keyof TemporalAlgorithmOptions>; @@ -9012,7 +9932,24 @@ type StringPredicate = Readonly<{ }>; // @public -export function subClassOf(child: NodeType, parent: NodeType): OntologyRelation; +export type StructuralSubtypeMismatch = Readonly<{ + __typegraphSubClassOfError: `subClassOf(${ChildKind}, ${ParentKind}): the child's schema must extend the parent's`; + missingOrIncompatibleProperties: Fields; + fix: "Give the child these properties with compatible types, or declare broader(child, parent) instead."; +}>; + +// @public +export function subClassOf(child: C, parent: P & SubClassOfCheck): TypedOntologyRelation; + +// @public +export type SubClassOfCheck = [ +IncompatibleKeys, NodeProps

> +] extends [never] ? unknown : StructuralSubtypeMismatch, NodeProps

>>; + +// @public (undocumented) +export type SubgraphCompositionSelection = boolean | Readonly<{ + via?: CompositionViaRef; +}>; // @public export type SubgraphEdgeKey = Readonly<{ @@ -9088,13 +10025,14 @@ export type SubgraphNodeResult = Nod type SubgraphNodeResultForKind, P> = ProjectionSelection extends readonly string[] ? ProjectedNodeResult> : Node; // @public (undocumented) -export type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ +export type SubgraphOptions, NK extends NodeKinds, P extends SubgraphProjectFor | undefined = undefined, C extends SubgraphCompositionSelection | undefined = undefined> = Readonly<{ edges: readonly EK[]; maxDepth?: number; includeKinds?: readonly NK[]; excludeRoot?: boolean; direction?: "out" | "both"; cyclePolicy?: RecursiveCyclePolicy; + composition?: C; temporalMode?: TemporalMode; asOf?: string; recordedAsOf?: never; @@ -9108,13 +10046,23 @@ type SubgraphProject = NodeKinds, edges?: SubgraphEdgeProjectionMap; }>; -// @public (undocumented) +// @public +type SubgraphProjectFor, EK extends EdgeKinds, C extends SubgraphCompositionSelection | undefined> = SubgraphProject>; + +// @public export type SubgraphResult = NodeKinds, EK extends EdgeKinds = EdgeKinds, P extends SubgraphProject | undefined = undefined> = Readonly<{ root: SubgraphNodeResult | undefined; nodes: readonly SubgraphNodeResult[]; edges: readonly SubgraphEdgeResult[]; }>; +// @public +export type SubgraphResultEdgeKinds, C extends SubgraphCompositionSelection | undefined> = EK | (true extends C ? EdgeKinds : C extends { + via: infer V; +} ? [ +V +] extends [AnyEdgeType] ? V["kind"] extends EdgeKinds ? V["kind"] : EdgeKinds : EdgeKinds : never); + // @public export type SubsetEdge> = { [Kind in K]: Edge; @@ -9125,6 +10073,45 @@ export type SubsetNode> = { [Kind in K]: Node; }[K]; +// @public +export type SubsumedNodeReference = BrandedNodeReference, N>>; + +// @public +export type SubsumedNodeType = N extends NodeType ? SubsumptionAffected extends true ? PolymorphicNodeType : N : never; + +// @public +type SubsumptionAffected = OntologyTypeErased extends true ? true : true extends SubsumptionLiteralsErased ? true : true extends SubsumptionElementNamesKind ? true : false; + +// @public +type SubsumptionElementNamesKind = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? [ +Extract>, K> +] extends ([ +never +]) ? false : true : false : never; + +// @public +type SubsumptionEndpointKeys = Endpoint extends string ? IriEndpointKey : Endpoint extends { + kind: infer Kind extends string; +} ? Kind : never; + +// @public +type SubsumptionLiteralsErased = Relation extends unknown ? Relation extends ({ + metaEdge: { + name: infer Name extends string; + }; + from: infer From; + to: infer To; +}) ? string extends Name ? true : true extends EndpointKindErased> ? true : false : true : never; + +// @public +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + // @public export function sum(alias: Alias, field: Property): AggregateExpr<"sum", FieldRef>; @@ -9192,6 +10179,9 @@ type TableState = Readonly<{ exists: boolean; }>; +// @public +export type TargetCardinality = Exclude; + // @public export type TemporalAlgorithmOptions = NoRecordedCoordinate & Readonly<{ temporalMode?: TemporalMode; @@ -9268,6 +10258,7 @@ type TransactionCollections = Readonly<{ getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; backend: TransactionReadBackend; getNodeCollection: (kind: K) => DynamicNodeCollection | undefined; + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade; }> : Readonly>); @@ -9293,9 +10284,9 @@ export type TransactionContext = TransactionCollections & describe: () => Promise; validateStore: (options: ValidateStoreOptions) => Promise; batchOnce: (build: (read: BatchReadBuilder) => Queries, options?: BatchOnceOptions) => Promise>; - neighbors: >(source: GraphNodeReference, options: NeighborReadOptions) => Promise[]>; - countNeighbors: >(source: GraphNodeReference, options: Omit, "limit" | "orderBy">) => Promise; - subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProject | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise>; + neighbors: >(source: GraphNodeReferenceInput, options: NeighborReadOptions) => Promise[]>; + countNeighbors: >(source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">) => Promise; + subgraph: , const NK extends NodeKinds = NodeKinds, const P extends SubgraphProjectFor | undefined = undefined, const C extends SubgraphCompositionSelection | undefined = undefined>(root: GraphNodeReference, options: SubgraphOptions) => Promise, P>>; }>; // @public @@ -9311,7 +10302,7 @@ export type TransactionOutcome = Readonly<{ }>; // @public -export type TransactionReadBackend = Readonly; +export type TransactionReadBackend = Readonly; // @public export type TransactionReceipt = Readonly<{ @@ -9321,15 +10312,31 @@ export type TransactionReceipt = Readonly<{ identity: IdentityWriteSummary; total: number; }>; + cascadedParts: readonly CompositionCascadedPart[]; recorded?: RecordedInstant; }>; // @public (undocumented) type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: (operation: "create" | "update" | "delete", kind: string, id: string, fn: () => Promise) => Promise; + deleteNodeWithPolicy: (work: Readonly<{ + kind: string; + id: string; + }>, policy?: NodeDeletePolicy) => Promise; + reviveNode: (work: Readonly<{ + kind: string; + id: string; + }>) => Promise; }>; +// @public (undocumented) +const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +// @public +export type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + // @public type Traversal = Readonly<{ edgeAlias: string; @@ -9343,26 +10350,41 @@ type Traversal = Readonly<{ optional: boolean; includeIdentityMembers?: boolean; variableLength?: VariableLengthSpec; + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; }>; // @public class TraversalBuilder> { - constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean); + constructor(config: QueryBuilderConfig, state: QueryBuilderState, edgeKinds: readonly string[], edgeAlias: EA, direction: Dir, fromAlias: string, inverseEdgeKinds?: readonly string[], optional?: Optional, variableLength?: VariableLengthState, pendingEdgePredicates?: readonly NodePredicate[], includeIdentityMembers?: boolean, compositionMembership?: Traversal["compositionMembership"]); recursive>(options?: O): TraversalBuilder; to, A extends string>(kind: K, alias: UniqueAlias, options?: { - includeSubClasses?: false; + expansion?: undefined; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + to, A extends string>(kind: K, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; // (undocumented) to, A extends string>(kind: K, alias: UniqueAlias, options: { - includeSubClasses: true; - }): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; - toDynamic(kind: T, alias: UniqueAlias, options?: { - includeSubClasses?: boolean; + expansion: "subclasses"; + }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + to, A extends string>(kind: K, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: { + expansion: "exact"; }): QueryBuilder, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + // (undocumented) + toDynamic(kind: T, alias: UniqueAlias, options?: { + expansion?: "subclasses" | undefined; + }): QueryBuilder>, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toDynamic(kind: T, alias: UniqueAlias, options: AliasExpansionOptions): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; + toKindSet(kinds: readonly string[], alias: A): QueryBuilder>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState>; whereEdge(alias: EA, predicateFunction: (edge: EdgeAccessor) => Predicate): TraversalBuilder; } @@ -9387,7 +10409,7 @@ export class TrustedImportError extends TypeGraphError { } // @public -export type TrustedImportErrorReason = "backend_unsupported" | "database_not_empty" | "fulltext_unsupported" | "history_unsupported" | "identity_unsupported" | "invalid_stream" | "revision_tracking_unsupported" | "uniqueness_unsupported" | "vector_unsupported"; +export type TrustedImportErrorReason = "acyclicity_unsupported" | "backend_unsupported" | "cardinality_unsupported" | "composition_unsupported" | "database_not_empty" | "fulltext_unsupported" | "history_unsupported" | "identity_unsupported" | "invalid_stream" | "revision_tracking_unsupported" | "uniqueness_unsupported" | "vector_unsupported"; // @public export type TrustedImportOptions = Readonly<{ @@ -9415,13 +10437,20 @@ type TupleComparisonPredicate = Readonly<{ }>; // @public -export type TypedEdgeCollection = EdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +export type TypedEdgeCollection = EdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, EdgeId, G>; + +// @public +export type TypedOntologyRelation = Readonly<{ + metaEdge: MetaEdge; + from: From; + to: To; +}> & CompositionRealization; // @public -export type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +export type TypedRecordedStoreViewEdgeCollection = RecordedStoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public -export type TypedStoreViewEdgeCollection = StoreViewEdgeCollection extends NodeType ? EdgeFromTypes : NodeType, EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, EdgeAllowedPairs>; +export type TypedStoreViewEdgeCollection = StoreViewEdgeCollection, EdgeToNodeTypes, EdgeAllowedPairs, G>; // @public (undocumented) const TYPEGRAPH_RECORDED_READ_SOURCE: unique symbol; @@ -9691,6 +10720,26 @@ export type UniqueIntrospection = Readonly<{ collation: Collation; }>; +// @public +export type UniquenessClaimGate = Readonly<{ + cause: "constraint-added" | "constraint-reshaped"; + kind: string; + constraintName: string; + axis: string; +}> | Readonly<{ + cause: "claim-axis-moved"; + kind: string; + constraintName: string; + axis: string; + lostKinds: readonly string[]; +}>; + +// @public +export type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + coveredKinds: readonly string[]; +}>; + // @public export class UniquenessError extends TypeGraphError { constructor(details: UniquenessErrorDetails, options?: { @@ -9725,7 +10774,7 @@ type UniqueRow = Readonly<{ }>; // @public (undocumented) -type UnsafeHistoryStoreBackendMember = "findEdgesByMatchIdentity" | "clearGraph" | "clearGraphPreservingContributionMaterializations" | "commitSchemaVersionWithPreflight" | "instantiateGraphTemplate" | "executeDdl" | "executeRaw" | "executeStatement" | "ensureIdentityTables" | "identityTableDdl" | "rebuildContribution" | "recordedTableDdl" | "repairContributions" | "registerGraphTemplate" | "schemaWriteTransaction" | "transaction" | "trustedImport"; +type UnsafeHistoryStoreBackendMember = "findEdgesByMatchIdentity" | "clearGraph" | "clearGraphPreservingContributionMaterializations" | "commitSchemaVersionWithPreflight" | "instantiateGraphTemplate" | "executeDdl" | "executeRaw" | "executeStatement" | "ensureIdentityTables" | "identityTableDdl" | "rebuildContribution" | "recordedTableDdl" | "repairContributions" | "registerGraphTemplate" | "schemaWriteTransaction" | "setActiveVersionWithPreflight" | "transaction" | "trustedImport"; // @public export class UnsupportedBackendCapabilityError extends TypeGraphError { @@ -9899,6 +10948,7 @@ type ValueType = "string" | "number" | "boolean" | "date" | "array" | "object" | type VariableLengthSpec = Readonly<{ minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathAlias?: string; pathFormat?: "qualified"; @@ -9920,6 +10970,8 @@ interface VariableLengthState { // (undocumented) enabled: boolean; // (undocumented) + exhaustive?: boolean; + // (undocumented) maxDepth: number; // (undocumented) minDepth: number; diff --git a/packages/typegraph/examples/03-subclass-hierarchy.ts b/packages/typegraph/examples/03-subclass-hierarchy.ts index 71fef4179..d56935750 100644 --- a/packages/typegraph/examples/03-subclass-hierarchy.ts +++ b/packages/typegraph/examples/03-subclass-hierarchy.ts @@ -7,7 +7,7 @@ * subsumption, so a `to: [Media]` edge accepts every subclass * instance (subclasses are also listed in the declaration purely * for TypeScript's benefit — see the edge definition below) - * - Query expansion with includeSubClasses — and what it excludes + * - Query expansion with expansion: "subclasses" — and what it excludes * - Inspecting the hierarchy through the registry * * Run with: @@ -240,21 +240,21 @@ export async function main(): Promise { // Querying at the top of the hierarchy answers "what has Alice // watched?" without caring about concrete kinds. - console.log("Query: All Media Alice watched (includeSubClasses: true)"); + console.log('Query: All Media Alice watched (expansion: "subclasses")'); const allWatched = await store .query() .from("Person", "p") .traverse("watched", "w") - .to("Media", "m", { includeSubClasses: true }) + .to("Media", "m", { expansion: "subclasses" }) .select((ctx) => ({ person: ctx.p.name, - title: ctx.m["title"], + title: ctx.m.title, kind: ctx.m.kind, })) .execute(); for (const row of allWatched) { - console.log(` ${row.person} watched "${String(row.title)}" (${row.kind})`); + console.log(` ${row.person} watched "${row.title}" (${row.kind})`); } console.log( " ^ All four items, including both TVShows — Media expands to\n" + @@ -264,21 +264,21 @@ export async function main(): Promise { // Expansion follows the hierarchy, so it also excludes: a Movie // query pulls in Documentary but NOT TVShow, which sits on the // other branch under Media. - console.log("\nQuery: All Movies Alice watched (includeSubClasses: true)"); + console.log('\nQuery: All Movies Alice watched (expansion: "subclasses")'); const moviesWatched = await store .query() .from("Person", "p") .traverse("watched", "w") - .to("Movie", "m", { includeSubClasses: true }) + .to("Movie", "m", { expansion: "subclasses" }) .select((ctx) => ({ person: ctx.p.name, - title: ctx.m["title"], + title: ctx.m.title, kind: ctx.m.kind, })) .execute(); for (const row of moviesWatched) { - console.log(` ${row.person} watched "${String(row.title)}" (${row.kind})`); + console.log(` ${row.person} watched "${row.title}" (${row.kind})`); } console.log( ' ^ "Breaking Bad" and "The Office" are excluded: TVShow is a\n' + @@ -286,12 +286,12 @@ export async function main(): Promise { ); // Without subclass expansion - console.log("\nQuery: Only exact Movie type (includeSubClasses: false)"); + console.log('\nQuery: Only exact Movie type (expansion: "exact")'); const exactMovies = await store .query() .from("Person", "p") .traverse("watched", "w") - .to("Movie", "m", { includeSubClasses: false }) + .to("Movie", "m", { expansion: "exact" }) .select((ctx) => ({ person: ctx.p.name, title: ctx.m.title, diff --git a/packages/typegraph/examples/08-custom-ontology.ts b/packages/typegraph/examples/08-custom-ontology.ts index ef758fe9e..9537236bd 100644 --- a/packages/typegraph/examples/08-custom-ontology.ts +++ b/packages/typegraph/examples/08-custom-ontology.ts @@ -1,15 +1,15 @@ /** - * Example 08: Advanced Ontology and Custom Meta-Edges + * Example 08: Advanced Ontology and Type-Level Annotations * * This example builds a research knowledge graph and demonstrates: * - The core meta-edges working together: subClassOf, broader/narrower, * equivalentTo (with an external IRI), disjointWith, partOf, inverseOf, * and implies — each with an observable registry or query effect. - * - Custom meta-edges (`metaEdge(...)`) registered in `ontology: []`: - * what the library does with them (persists, introspects, serializes) - * and what it deliberately does not (no built-in inference — the - * KindRegistry computes closures only for the core meta-edges, so - * custom semantics are interpreted by your application, as shown here). + * - Free-form type-level semantics via `defineGraph({ annotations })`: + * vocabulary the built-in KindRegistry has no closure for (it only + * computes closures for the core meta-edges above) — persisted, + * introspectable, serialized with the schema, and interpreted entirely + * by your application, as shown here. * - Ontology relations are TYPE-level (between kinds), never instance-level. * That is why research fields are modeled as node kinds below: a SKOS * statement like "DeepLearning narrower-than ArtificialIntelligence" @@ -28,11 +28,7 @@ import { equivalentTo, implies, inverseOf, - type MetaEdge, - metaEdge, narrower, - type NodeType, - type OntologyRelation, partOf, relatedTo, subClassOf, @@ -48,7 +44,8 @@ import { createExampleBackend } from "./_helpers"; const SCHEMA_ORG_PERSON_IRI = "https://schema.org/Person"; const DEEP_LEARNING_NAME = "Deep Learning"; const ATTENTION_PAPER_TITLE = "Attention Is All You Need"; -const SEQ2SEQ_PAPER_TITLE = "Sequence to Sequence Learning with Neural Networks"; +const SEQ2SEQ_PAPER_TITLE = + "Sequence to Sequence Learning with Neural Networks"; // ============================================================ // Part 1: Node and Edge Kinds @@ -121,45 +118,35 @@ const affiliatedWith = defineEdge("affiliatedWith", { const belongsTo = defineEdge("belongsTo", { schema: z.object({}) }); // ============================================================ -// Part 2: Custom Meta-Edges +// Part 2: Type-Level Semantics via Annotations // ============================================================ -// A custom meta-edge is a TYPE-level vocabulary term. The library stores -// its relations in the graph definition, exposes them through -// `store.introspect().ontology`, and serializes them with the schema — -// but the built-in KindRegistry computes closures ONLY for the core -// meta-edges. Custom semantics (like the transitive walk below) are -// interpreted by your application. - -// Learning prerequisite between topic kinds: -// "you should know the from-kind before studying the to-kind". -const prerequisiteOfMetaEdge = metaEdge("prerequisiteOf", { - transitive: true, // AI prereq ML, ML prereq DL => AI prereq DL - inference: "hierarchy", - description: "Learning prerequisite between research-field kinds", -}); - -// Replacement between publication kinds: -// "the from-kind is the authoritative successor of the to-kind". -const supersedesMetaEdge = metaEdge("supersedes", { - inference: "substitution", - description: "The from-kind is the authoritative successor of the to-kind", -}); - -// Factory functions in the same style as the core `subClassOf`, `broader`, ... -function prerequisiteOf( - prerequisite: NodeType, - dependent: NodeType, -): OntologyRelation { - return { metaEdge: prerequisiteOfMetaEdge, from: prerequisite, to: dependent }; -} - -function supersedes( - newerKind: NodeType, - olderKind: NodeType, -): OntologyRelation { - return { metaEdge: supersedesMetaEdge, from: newerKind, to: olderKind }; -} +// `defineGraph({ annotations })` is a consumer-owned, free-form JSON bag +// for the graph as a whole (`GraphAnnotations`) — the place for TYPE-level +// vocabulary the library itself has no built-in reasoning for. It travels +// with the graph definition the same way the core meta-edges do: stored, +// introspectable (`store.introspect()`), and serialized with the schema — +// but with no KindRegistry closure computed over it. Your application +// interprets it, as `relatesVia` below does with a breadth-first walk. +// +// Two research-field/publication vocabularies, each a set of (from, to) +// kind-name pairs plus whether the relation composes transitively: +// - "prerequisiteOf": "you should know the from-kind before the to-kind". +// - "supersedes": "the from-kind is the authoritative successor of the +// to-kind". +const GRAPH_ANNOTATIONS = { + prerequisiteOf: { + transitive: true, // AI prereq ML, ML prereq DL => AI prereq DL + pairs: [ + ["ArtificialIntelligence", "MachineLearning"], + ["MachineLearning", "DeepLearning"], + ], + }, + supersedes: { + transitive: false, + pairs: [["Paper", "Preprint"]], + }, +} as const; // ============================================================ // Part 3: The Graph and Its Ontology @@ -213,7 +200,15 @@ const graph = defineGraph({ ], }, affiliatedWith: { type: affiliatedWith, from: [Author], to: [Institution] }, - belongsTo: { type: belongsTo, from: [Department], to: [Institution] }, + // `cardinality: "one"` — each Department belongs to exactly one + // Institution — is what makes `belongsTo` eligible to realize the + // `partOf` composition below. + belongsTo: { + type: belongsTo, + from: [Department], + to: [Institution], + cardinality: "one", + }, }, ontology: [ // === Subsumption (is-a) === @@ -243,7 +238,11 @@ const graph = defineGraph({ // Institution — `subClassOf(Department, Institution)` would wrongly // claim every Department IS an Institution. Model is-a with // subClassOf and part-of with partOf; hasPart is derived as its inverse. - partOf(Department, Institution), + // `via: belongsTo` names the edge whose live rows realize the + // composition; `belongsTo`'s `cardinality: "one"` is the whole-side + // fence that makes "one Institution per Department" an enforced + // invariant, not just a modeling convention. + partOf(Department, Institution, { via: belongsTo }), // === Edge semantics === inverseOf(cites, citedBy), @@ -254,43 +253,36 @@ const graph = defineGraph({ // or committed as a schema version — since `expand: "implying"` would // otherwise traverse the mismatched edges. implies(buildsOn, cites), - - // === Custom meta-edge relations === - prerequisiteOf(ArtificialIntelligence, MachineLearning), - prerequisiteOf(MachineLearning, DeepLearning), - supersedes(Paper, Preprint), ], + annotations: GRAPH_ANNOTATIONS, }); // ============================================================ -// Part 4: Application-Level Inference over Custom Meta-Edges +// Part 4: Application-Level Inference over Annotated Semantics // ============================================================ -function endpointKindName(endpoint: OntologyRelation["from"]): string { - return typeof endpoint === "string" ? endpoint : endpoint.kind; -} +type AnnotatedRelation = Readonly<{ + transitive: boolean; + pairs: readonly (readonly [string, string])[]; +}>; /** - * Answers "does `fromKind` relate to `toKind` via this custom meta-edge?" - * by reading the graph's own ontology relations. Honors the meta-edge's - * declared `transitive` property with a breadth-first walk — this is the - * pattern for giving custom meta-edges real semantics in your application. + * Answers "does `fromKind` relate to `toKind` via this annotated + * vocabulary term?" by reading the graph's own `annotations` — no registry + * involved, since the KindRegistry never computes a closure over + * consumer-owned annotations. Honors the declared `transitive` flag with a + * breadth-first walk — this is the pattern for giving free-form, + * application-defined semantics real behavior. */ function relatesVia( - ontology: readonly OntologyRelation[], - customMetaEdge: MetaEdge, + relation: AnnotatedRelation, fromKind: string, toKind: string, ): boolean { - const directPairs = ontology - .filter((relation) => relation.metaEdge.name === customMetaEdge.name) - .map( - (relation) => - [endpointKindName(relation.from), endpointKindName(relation.to)] as const, + if (!relation.transitive) { + return relation.pairs.some( + ([from, to]) => from === fromKind && to === toKind, ); - - if (!customMetaEdge.properties.transitive) { - return directPairs.some(([from, to]) => from === fromKind && to === toKind); } const visited = new Set([fromKind]); @@ -298,7 +290,7 @@ function relatesVia( while (queue.length > 0) { const current = queue.shift(); if (current === undefined) break; - for (const [from, to] of directPairs) { + for (const [from, to] of relation.pairs) { if (from !== current || visited.has(to)) continue; if (to === toKind) return true; visited.add(to); @@ -323,7 +315,7 @@ export async function main() { const store = createStore(graph, backend); const registry = store.registry; - console.log("=== Advanced Ontology and Custom Meta-Edges ===\n"); + console.log("=== Advanced Ontology and Type-Level Annotations ===\n"); // --- Instance data --------------------------------------------- @@ -439,30 +431,30 @@ export async function main() { console.log("\n=== Queries Shaped by the Ontology ===\n"); // Subsumption expansion: one query over the whole publication hierarchy. - console.log("All publications (from 'Publication', includeSubClasses):"); + console.log("All publications (from 'Publication', expansion: subclasses):"); const publications = await store .query() - .from("Publication", "pub", { includeSubClasses: true }) - .select((ctx) => ({ title: ctx.pub["title"], kind: ctx.pub.kind })) + .from("Publication", "pub", { expansion: "subclasses" }) + .select((ctx) => ({ title: ctx.pub.title, kind: ctx.pub.kind })) .execute(); for (const row of publications) { - console.log(` [${row.kind}] "${String(row.title)}"`); + console.log(` [${row.kind}] "${row.title}"`); } // Bound topic filter: only papers tagged with the Deep Learning field. - // `includeSubClasses` widens the target alias to the base kind, so we + // `expansion: "subclasses"` widens the target alias to the base kind, so we // bind on `kind` (always statically typed) rather than a schema field. console.log(`\nPapers about ${DEEP_LEARNING_NAME}:`); const dlPapers = await store .query() .from("Paper", "p") .traverse("about", "a") - .to("Topic", "t", { includeSubClasses: true }) + .to("Topic", "t", { expansion: "subclasses" }) .whereNode("t", (topic) => topic.kind.eq(DeepLearning.kind)) - .select((ctx) => ({ title: ctx.p.title, topic: ctx.t["name"] })) + .select((ctx) => ({ title: ctx.p.title, topic: ctx.t.name })) .execute(); for (const row of dlPapers) { - console.log(` "${row.title}" is about ${String(row.topic)}`); + console.log(` "${row.title}" is about ${row.topic}`); } // SKOS closure feeding a query: publications about AI or any narrower @@ -470,14 +462,14 @@ export async function main() { console.log("\nPublications about AI or any narrower field:"); const aiPublications = await store .query() - .from("Publication", "pub", { includeSubClasses: true }) + .from("Publication", "pub", { expansion: "subclasses" }) .traverse("about", "a") - .to("Topic", "t", { includeSubClasses: true }) + .to("Topic", "t", { expansion: "subclasses" }) .whereNode("t", (topic) => topic.kind.in([...aiOrNarrower])) - .select((ctx) => ({ title: ctx.pub["title"], field: ctx.t["name"] })) + .select((ctx) => ({ title: ctx.pub.title, field: ctx.t.name })) .execute(); for (const row of aiPublications) { - console.log(` "${String(row.title)}" (via ${String(row.field)})`); + console.log(` "${row.title}" (via ${row.field})`); } // Implication expansion: the published paper has NO explicit cites edge, @@ -488,8 +480,8 @@ export async function main() { .from("Paper", "p") .whereNode("p", ({ title }) => title.eq(ATTENTION_PAPER_TITLE)) .traverse("cites", "c", { expand: "none" }) - .to("Publication", "cited", { includeSubClasses: true }) - .select((ctx) => ({ title: ctx.cited["title"] })) + .to("Publication", "cited", { expansion: "subclasses" }) + .select((ctx) => ({ title: ctx.cited.title })) .execute(); console.log(` Explicit cites edges: ${explicitCites.length}`); const impliedCites = await store @@ -497,11 +489,13 @@ export async function main() { .from("Paper", "p") .whereNode("p", ({ title }) => title.eq(ATTENTION_PAPER_TITLE)) .traverse("cites", "c", { expand: "implying" }) - .to("Publication", "cited", { includeSubClasses: true }) - .select((ctx) => ({ title: ctx.cited["title"] })) + .to("Publication", "cited", { expansion: "subclasses" }) + .select((ctx) => ({ title: ctx.cited.title })) .execute(); for (const row of impliedCites) { - console.log(` With expand "implying": cites "${String(row.title)}" (via buildsOn)`); + console.log( + ` With expand "implying": cites "${row.title}" (via buildsOn)`, + ); } // Inverse + implication combined: nothing ever wrote a citedBy edge, @@ -512,59 +506,42 @@ export async function main() { .from("Paper", "s") .whereNode("s", ({ title }) => title.eq(SEQ2SEQ_PAPER_TITLE)) .traverse("citedBy", "cb", { expand: "all" }) - .to("Publication", "citing", { includeSubClasses: true }) - .select((ctx) => ({ title: ctx.citing["title"] })) + .to("Publication", "citing", { expansion: "subclasses" }) + .select((ctx) => ({ title: ctx.citing.title })) .execute(); for (const row of citingPublications) { - console.log(` Cited by "${String(row.title)}"`); + console.log(` Cited by "${row.title}"`); } - // --- Custom meta-edges in practice ------------------------------ + // --- Type-level annotations in practice -------------------------- - console.log("\n=== Custom Meta-Edges in Practice ===\n"); - - console.log("Custom relations visible via store.introspect().ontology:"); - const customRelations = store - .introspect() - .ontology.filter( - (relation) => - relation.metaEdge === prerequisiteOfMetaEdge.name || - relation.metaEdge === supersedesMetaEdge.name, - ); - for (const relation of customRelations) { - console.log( - ` ${relation.from} --${relation.metaEdge}--> ${relation.to}` + - ` (origin: ${relation.origin})`, - ); - } + console.log("\n=== Type-Level Annotations in Practice ===\n"); - console.log("\nMeta-edge properties travel with the definition:"); - console.log( - ` prerequisiteOf: transitive=${prerequisiteOfMetaEdge.properties.transitive},` + - ` inference=${prerequisiteOfMetaEdge.properties.inference}`, - ); + const annotations = store.introspect().annotations; console.log( - ` supersedes: transitive=${supersedesMetaEdge.properties.transitive},` + - ` inference=${supersedesMetaEdge.properties.inference}`, + "Annotated vocabulary visible via store.introspect().annotations:", + annotations === undefined ? [] : Object.keys(annotations), ); console.log( "\nApplication-level inference (the registry only reasons over core" + - "\nmeta-edges, so we walk the declared relations ourselves):", + "\nmeta-edges, so we walk the annotated pairs ourselves):", + ); + const isAiPrerequisiteOfDl = relatesVia( + GRAPH_ANNOTATIONS.prerequisiteOf, + "ArtificialIntelligence", + "DeepLearning", ); console.log( " ArtificialIntelligence prerequisiteOf DeepLearning (transitive)?", - relatesVia( - graph.ontology, - prerequisiteOfMetaEdge, - "ArtificialIntelligence", - "DeepLearning", - ), + isAiPrerequisiteOfDl, ); - console.log( - " Paper supersedes Preprint?", - relatesVia(graph.ontology, supersedesMetaEdge, "Paper", "Preprint"), + const paperSupersedesPreprint = relatesVia( + GRAPH_ANNOTATIONS.supersedes, + "Paper", + "Preprint", ); + console.log(" Paper supersedes Preprint?", paperSupersedesPreprint); // --- Verified summary ------------------------------------------- @@ -622,21 +599,19 @@ export async function main() { citingPublications.length === 2, ); assertClaim( - "custom meta-edge relations are introspectable", - customRelations.length === 3, + "graph annotations are introspectable", + annotations !== undefined && + Object.keys(annotations).length === 2 && + "prerequisiteOf" in annotations && + "supersedes" in annotations, ); assertClaim( - "custom transitive inference: AI is a prerequisite of DL", - relatesVia( - graph.ontology, - prerequisiteOfMetaEdge, - "ArtificialIntelligence", - "DeepLearning", - ), + "annotated transitive inference: AI is a prerequisite of DL", + isAiPrerequisiteOfDl, ); assertClaim( - "custom non-transitive inference: Paper supersedes Preprint", - relatesVia(graph.ontology, supersedesMetaEdge, "Paper", "Preprint"), + "annotated non-transitive inference: Paper supersedes Preprint", + paperSupersedesPreprint, ); console.log("\n=== Custom Ontology example complete ==="); diff --git a/packages/typegraph/examples/27-durable-merge-review.ts b/packages/typegraph/examples/27-durable-merge-review.ts index 6a50a2600..efdd2ea41 100644 --- a/packages/typegraph/examples/27-durable-merge-review.ts +++ b/packages/typegraph/examples/27-durable-merge-review.ts @@ -174,11 +174,15 @@ export async function main(): Promise { db, async (tx) => { // Apply first: the plan fence must be checked before any other write - // to this target graph in the caller-owned transaction. + // to this target graph in the caller-owned transaction. Passing the + // revalidation records its review digest on every identity + // transition the apply causes; it is refused unless it names this + // plan. const applied = await applyMergePlanInTransaction( store, tx, checked.plan, + { review: checked }, ); // Additional graph writes may follow the apply. They share its one diff --git a/packages/typegraph/examples/33-composition-lifecycle.ts b/packages/typegraph/examples/33-composition-lifecycle.ts new file mode 100644 index 000000000..9314869cd --- /dev/null +++ b/packages/typegraph/examples/33-composition-lifecycle.ts @@ -0,0 +1,506 @@ +/** + * Example 28: Composition Lifecycle + * + * A podcast publishes episodes, and each episode is cut into segments. A + * segment has no meaning outside the episode it belongs to: delete the + * episode, and its segments should go with it. That is composition — + * `partOf`/`hasPart` — not an ordinary edge: + * + * Segment --(segmentOf, REQUIRED, oneActive)--> Episode --(episodeOf, one)--> Podcast + * + * A segment can never exist without a live episode (`existence: "required"`); + * an episode's podcast is optional metadata by comparison. `segmentOf` also + * carries a required `order` property, so attaching a segment always states + * `partOf.props`. + * + * This example demonstrates: + * - declaring composition with `partOf`, `existence: "required"`, and a + * realizing edge with a required schema property + * - a bare create of a required part is refused; `partOf` with `props` + * succeeds + * - idempotent attachment via `getOrCreateByConstraint`'s `partOf` + * postcondition, including its refusal for a genuinely different whole + * - `reparent` moving a part between wholes, and reparenting to the current + * whole as a no-op + * - `store.subgraph(root, { composition: true })` exporting the complete + * owned unit, at any depth + * - `planDelete` previewing exactly what a delete will cascade through, + * decided by the planner the delete itself runs + * - the delete cascade's `cascadedParts`, surfaced identically on an + * `onOperationEnd` hook and on a `transactionWithReceipt` receipt + * + * Run with: + * npx tsx examples/33-composition-lifecycle.ts + */ +import { deepStrictEqual } from "node:assert/strict"; + +import { + CompositionExistenceError, + createAdapterStore, + defineEdge, + defineGraph, + defineNode, + type OperationHookContext, + partOf, +} from "@nicia-ai/typegraph"; +import { z } from "zod"; + +import { requireDefined } from "../src/utils/presence"; +import { createExampleBackend } from "./_helpers"; + +// ============================================================ +// Schema: Podcast -> Episode -> Segment, two composition pairs +// ============================================================ + +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string() }), +}); +const Episode = defineNode("Episode", { + schema: z.object({ title: z.string() }), +}); +const Segment = defineNode("Segment", { + schema: z.object({ label: z.string() }), +}); + +const episodeOf = defineEdge("episodeOf", { schema: z.object({}) }); +// The realizing edge for the REQUIRED pair carries a required property, so +// every attachment through it must state `partOf.props`. +const segmentOf = defineEdge("segmentOf", { + schema: z.object({ order: z.number().int() }), +}); + +const graph = defineGraph({ + id: "podcast_composition_example", + nodes: { + Podcast: { + type: Podcast, + unique: [ + { + name: "podcast_title", + fields: ["title"], + scope: "kind", + collation: "binary", + }, + ], + }, + Episode: { + type: Episode, + unique: [ + { + name: "episode_title", + fields: ["title"], + scope: "kind", + collation: "binary", + }, + ], + }, + Segment: { + type: Segment, + unique: [ + { + name: "segment_label", + fields: ["label"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + // Optional pair: an episode's podcast is metadata, not a lifeline. + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + // Required pair: a segment cannot outlive the episode it was cut from. + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "oneActive", + }, + }, + ontology: [ + partOf(Episode, Podcast, { via: episodeOf }), + partOf(Segment, Episode, { via: segmentOf, existence: "required" }), + ], +}); + +function assertEqual(actual: T, expected: T, label: string): void { + // `deepStrictEqual` compares Sets and Maps by membership; a JSON round + // trip would serialize every Set as `{}` and pass vacuously. + deepStrictEqual(actual, expected, label); + console.log(` OK: ${label}`); +} + +function assertTrue(condition: boolean, label: string): void { + if (!condition) throw new Error(`${label}: expected true, got false`); + console.log(` OK: ${label}`); +} + +export async function main(): Promise { + const backend = createExampleBackend(); + + const endedOperations: OperationHookContext[] = []; + const startedOperations: OperationHookContext[] = []; + const store = createAdapterStore(graph, backend, { + hooks: { + onOperationStart: (ctx) => startedOperations.push(ctx), + onOperationEnd: (ctx) => endedOperations.push(ctx), + }, + }); + + try { + console.log( + "=== Composition Lifecycle (Podcast -> Episode -> Segment) ===\n", + ); + + // ============================================================ + // 1. A bare required-part create is refused; `partOf` with props works + // ============================================================ + + console.log( + "=== 1. Required existence: bare create refused, partOf succeeds ===\n", + ); + + const podcast = await store.nodes.Podcast.create({ title: "Deep Dive" }); + const episode = await store.nodes.Episode.create( + { title: "Pilot" }, + { partOf: { whole: podcast } }, + ); + console.log(` created podcast ${podcast.id} and episode ${episode.id}`); + + const bareCreateError = await store.nodes.Segment.create({ + label: "cold-open", + }).catch((error: unknown) => error); + assertTrue( + bareCreateError instanceof CompositionExistenceError, + "bare Segment.create is refused with CompositionExistenceError", + ); + assertEqual( + (bareCreateError as CompositionExistenceError).code, + "COMPOSITION_WHOLE_REQUIRED", + "refusal code is COMPOSITION_WHOLE_REQUIRED", + ); + assertEqual( + (bareCreateError as CompositionExistenceError).details.situation, + "create", + "refusal situation is 'create'", + ); + assertEqual( + await store.nodes.Segment.count(), + 0, + "no Segment row was written by the refused create", + ); + + const coldOpen = await store.nodes.Segment.create( + { label: "cold-open" }, + { + partOf: { whole: episode, props: { order: 1 } }, + }, + ); + const coldOpenEdges = await store.edges.segmentOf.findFrom(coldOpen); + console.log( + ` created segment ${coldOpen.id} attached via segmentOf order=${requireDefined(coldOpenEdges[0]).order}`, + ); + assertEqual(coldOpenEdges.length, 1, "exactly one segmentOf edge exists"); + assertEqual( + requireDefined(coldOpenEdges[0]).order, + 1, + "the realizing edge carries the stated props", + ); + + // ============================================================ + // 2. Idempotent get-or-create with `partOf` + // ============================================================ + + console.log( + "\n=== 2. getOrCreateByConstraint: idempotent attach, refused move ===\n", + ); + + const attachment = { + whole: episode, + via: "segmentOf" as const, + props: { order: 2 }, + }; + + const firstCall = await store.nodes.Segment.getOrCreateByConstraint( + "segment_label", + { label: "interview" }, + { partOf: attachment }, + ); + assertEqual(firstCall.action, "created", "first call creates the segment"); + + const secondCall = await store.nodes.Segment.getOrCreateByConstraint( + "segment_label", + { label: "interview" }, + { partOf: attachment }, + ); + assertEqual(secondCall.action, "found", "second identical call finds it"); + assertEqual( + secondCall.node.id, + firstCall.node.id, + "the found node is the same segment", + ); + const interviewEdgesAfterRepeat = await store.edges.segmentOf.findFrom( + firstCall.node, + ); + assertEqual( + interviewEdgesAfterRepeat.length, + 1, + "no new segmentOf edge was written by the repeat", + ); + + const otherEpisode = await store.nodes.Episode.create( + { title: "Bonus" }, + { partOf: { whole: podcast } }, + ); + const movedElsewhereError = + await store.nodes.Segment.getOrCreateByConstraint( + "segment_label", + { label: "interview" }, + { partOf: { whole: otherEpisode } }, + ).catch((error: unknown) => error); + assertTrue( + movedElsewhereError instanceof CompositionExistenceError, + "naming a DIFFERENT whole is refused", + ); + assertEqual( + (movedElsewhereError as CompositionExistenceError).details.situation, + "existing", + "refusal situation is 'existing'", + ); + assertEqual( + (movedElsewhereError as CompositionExistenceError).code, + "COMPOSITION_WHOLE_CONFLICT", + "refusal code is COMPOSITION_WHOLE_CONFLICT", + ); + const interviewEdgesAfterRefusal = await store.edges.segmentOf.findFrom( + firstCall.node, + ); + assertEqual( + requireDefined(interviewEdgesAfterRefusal[0]).toId, + episode.id, + "the segment stays on its original episode — moving is reparent's job", + ); + + // ============================================================ + // 3. `reparent`: moving a part to a new whole + // ============================================================ + + console.log( + "\n=== 3. reparent moves a part; reparenting in place is a no-op ===\n", + ); + + await store.nodes.Segment.reparent(firstCall.node.id, { + whole: otherEpisode, + props: { order: 2 }, + }); + const interviewEdgesAfterMove = await store.edges.segmentOf.findFrom( + firstCall.node, + { temporalMode: "includeEnded" }, + ); + assertEqual( + interviewEdgesAfterMove.length, + 2, + "population 'oneActive' ENDS the old edge rather than deleting it", + ); + const endedEdge = requireDefined( + interviewEdgesAfterMove.find((edge) => edge.toId === episode.id), + "the retired attachment", + ); + const liveEdge = requireDefined( + interviewEdgesAfterMove.find((edge) => edge.toId === otherEpisode.id), + "the new attachment", + ); + assertTrue( + endedEdge.meta.validTo !== undefined, + "the retired edge's window is closed", + ); + assertEqual( + liveEdge.meta.validFrom, + requireDefined(endedEdge.meta.validTo), + "the new window opens at the exact instant the old one closed", + ); + + await store.nodes.Segment.reparent(firstCall.node.id, { + whole: otherEpisode, + }); + const interviewEdgesAfterNoop = await store.edges.segmentOf.findFrom( + firstCall.node, + { temporalMode: "includeEnded" }, + ); + assertEqual( + interviewEdgesAfterNoop.length, + 2, + "reparenting to the whole already held is a no-op — no new edge, no history", + ); + + // ============================================================ + // 4. Complete export: subgraph({ composition: true }) + // ============================================================ + + console.log( + "\n=== 4. subgraph({ composition: true }) exports the whole unit ===\n", + ); + + const exportPodcast = await store.nodes.Podcast.create({ + title: "Field Notes", + }); + const exportEpisode = await store.nodes.Episode.create( + { title: "Launch Day" }, + { partOf: { whole: exportPodcast } }, + ); + const exportSegmentOne = await store.nodes.Segment.create( + { label: "intro" }, + { + partOf: { + whole: exportEpisode, + props: { order: 1 }, + }, + }, + ); + const exportSegmentTwo = await store.nodes.Segment.create( + { label: "outro" }, + { + partOf: { + whole: exportEpisode, + props: { order: 2 }, + }, + }, + ); + + const unit = await store.subgraph(exportPodcast, { + edges: [], + composition: true, + }); + console.log( + ` exported ${unit.nodes.length} nodes rooted at ${requireDefined(unit.root).kind}/${requireDefined(unit.root).id}`, + ); + assertEqual( + requireDefined(unit.root).kind, + "Podcast", + "the export's root is the podcast", + ); + assertEqual( + new Set(unit.nodes.map((node) => node.id)), + new Set([ + exportPodcast.id, + exportEpisode.id, + exportSegmentOne.id, + exportSegmentTwo.id, + ]), + "the export carries the podcast, its episode, AND both segments", + ); + + // ============================================================ + // 5. Cascade receipt: delete the whole, inspect the cascade + // ============================================================ + + console.log( + "\n=== 5. Deleting the whole cascades leaf-first, reported as cascadedParts ===\n", + ); + + startedOperations.length = 0; + endedOperations.length = 0; + + // `planDelete` on the transaction's collection reads the delete's own plan + // under the per-graph write lock the delete takes, so no part can be + // attached between the preview and the delete that follows it. + const { receipt, result: preview } = await store.transactionWithReceipt( + async (tx) => { + const plan = await tx.nodes.Podcast.planDelete(exportPodcast.id); + await tx.nodes.Podcast.delete(exportPodcast.id); + return plan; + }, + ); + + console.log(` onOperationEnd fired ${endedOperations.length} time(s)`); + assertEqual( + endedOperations.length, + 1, + "exactly one operation event for the whole's delete — the cascade adds none", + ); + const endContext = requireDefined(endedOperations[0]); + assertEqual(endContext.kind, "Podcast", "the event is the Podcast delete"); + console.log( + ` hook cascadedParts: ${JSON.stringify(endContext.cascadedParts)}`, + ); + const cascadedParts = requireDefined( + endContext.cascadedParts, + "the delete's cascadedParts", + ); + // The cascade reports its parts leaf-first — every part before the whole it + // belongs to — and, within one level of the closure, ordered by kind and + // then id. The two segments are siblings of one episode, so their + // relative order is that sort, not the order they were created in; the + // episode follows both of them because it owns them. + // Each entry also names the whole the part was directly a part of and the + // edge kind that bound it, so the tree can be rebuilt from the list. + const expectedCascade = [ + ...[exportSegmentOne.id, exportSegmentTwo.id].toSorted().map((id) => ({ + kind: "Segment", + id, + whole: { kind: "Episode", id: exportEpisode.id }, + viaEdgeKind: "segmentOf", + })), + { + kind: "Episode", + id: exportEpisode.id, + whole: { kind: "Podcast", id: exportPodcast.id }, + viaEdgeKind: "episodeOf", + }, + ]; + assertEqual( + cascadedParts, + expectedCascade, + "onOperationEnd names the cascaded parts leaf-first, then by kind and id", + ); + assertTrue( + requireDefined(startedOperations[0]).cascadedParts === undefined, + "onOperationStart never carries cascadedParts — the cascade isn't planned yet", + ); + + console.log( + ` transactionWithReceipt cascadedParts: ${JSON.stringify(receipt.cascadedParts)}`, + ); + assertEqual( + receipt.cascadedParts, + cascadedParts, + "the receipt reports the identical cascade, in the identical order", + ); + assertEqual( + requireDefined(preview, "the delete plan").cascadedParts, + cascadedParts, + "planDelete previewed exactly the parts the delete then cascaded through", + ); + assertEqual( + receipt.writes.nodes, + { Podcast: 1 }, + "the cascade is NOT folded into the write counters — one delete call, one write intent", + ); + assertEqual( + await store.nodes.Episode.getById(exportEpisode.id), + undefined, + "the cascaded episode is actually gone", + ); + assertEqual( + await store.nodes.Segment.getById(exportSegmentOne.id), + undefined, + "the cascaded segment is actually gone", + ); + + console.log("\n=== Composition lifecycle example complete ==="); + } finally { + await backend.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/typegraph/examples/34-identity-history.ts b/packages/typegraph/examples/34-identity-history.ts new file mode 100644 index 000000000..9497ed2fd --- /dev/null +++ b/packages/typegraph/examples/34-identity-history.ts @@ -0,0 +1,397 @@ +/** + * Example 29: Identity History + * + * Three back-office systems each hold their own record of the same person: + * a CRM contact, an ERP contact, and a support-desk contact. Operational + * identity (`store.identity`) lets an application assert that two records + * denote the same real-world entity, retract that assertion when it turns + * out to be wrong, and — on a store opened with `history: true` — replay + * every step of how that belief changed over time. + * + * This example demonstrates: + * - paginated `transitionsOf`, reading a lineage a page at a time via + * `nextCursor` + * - paginated `replay`, pairing each transition with the class membership + * immediately before and after it + * - retention: `pruneIdentityTransitions` deletes transitions before a + * recorded instant, and pruning again at the same point is a no-op + * - the `restored` marker an archival import stamps on a transplanted + * transition, produced with a real export/import round trip + * + * Run with: + * npx tsx examples/34-identity-history.ts + */ +import { deepStrictEqual } from "node:assert/strict"; + +import { + createAdapterStoreWithSchema, + defineGraph, + defineNode, + type IdentityTransition, + pruneIdentityTransitions, + type TransitionPageCursor, +} from "@nicia-ai/typegraph"; +import { exportGraph, importGraph } from "@nicia-ai/typegraph/interchange"; +import { z } from "zod"; + +import { requireDefined } from "../src/utils/presence"; +import { createExampleBackend } from "./_helpers"; + +// ============================================================ +// Schema: one person, three systems of record, no edges needed — +// identity lives entirely in the assertion ledger. +// ============================================================ + +const CrmContact = defineNode("CrmContact", { + schema: z.object({ name: z.string() }), +}); +const ErpContact = defineNode("ErpContact", { + schema: z.object({ name: z.string() }), +}); +const SupportContact = defineNode("SupportContact", { + schema: z.object({ name: z.string() }), +}); + +const graph = defineGraph({ + id: "identity_history_example", + nodes: { + CrmContact: { type: CrmContact }, + ErpContact: { type: ErpContact }, + SupportContact: { type: SupportContact }, + }, + edges: {}, + // Different kinds, so a shared id would never implicitly fold two + // records — every join in this example comes from an explicit assertSame. + identity: { sameIdAcrossKinds: "ignore" }, +}); + +function assertEqual(actual: T, expected: T, label: string): void { + // `deepStrictEqual` compares Sets and Maps by membership; a JSON round + // trip would serialize every Set as `{}` and pass vacuously. + deepStrictEqual(actual, expected, label); + console.log(` OK: ${label}`); +} + +function assertTrue(condition: boolean, label: string): void { + if (!condition) throw new Error(`${label}: expected true, got false`); + console.log(` OK: ${label}`); +} + +/** + * The labeling function an audit view needs: it uses `restored?.at` + * (never a revision comparison) to tell a transplanted explanation from one + * this graph recorded itself. See "Archival transitions and the retention + * watermark" in the identity guide. + */ +function labelTransition(transition: IdentityTransition): string { + return transition.restored === undefined ? + "recorded here" + : `restored at ${transition.restored.at}`; +} + +export async function main(): Promise { + const backend = createExampleBackend(); + try { + const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + + console.log("=== Identity History ===\n"); + + // ============================================================ + // Build the lineage: join, split, rejoin + // ============================================================ + + console.log("=== 0. Three systems record one person ===\n"); + + const crm = await store.nodes.CrmContact.create( + { name: "Jordan Casey" }, + { id: "crm-1001" }, + ); + const erp = await store.nodes.ErpContact.create( + { name: "Jordan Casey" }, + { id: "erp-77" }, + ); + const support = await store.nodes.SupportContact.create( + { name: "Jordan Casey" }, + { id: "sup-42" }, + ); + console.log( + ` CrmContact/${crm.id}, ErpContact/${erp.id}, SupportContact/${support.id}`, + ); + + const crmErpAssertion = await store.identity.assertSame(crm, erp); + console.log(` assertSame(crm, erp): ${crmErpAssertion.action}`); + await store.identity.assertSame(erp, support); + console.log(" assertSame(erp, support): transitively joins all three"); + + const retracted = await store.identity.retractAssertion( + crmErpAssertion.assertion.id, + ); + console.log( + ` retractAssertion(crm<->erp): ended at ${retracted?.validTo} — crm splits off`, + ); + + await store.identity.assertSame(crm, erp); + console.log(" assertSame(crm, erp) again: rejoins all three\n"); + + const finalMembers = await store.identity.membersOf(crm); + assertEqual( + new Set(finalMembers.map((member) => member.id)), + new Set([crm.id, erp.id, support.id]), + "all three records are joined after the final re-assert", + ); + + // ============================================================ + // 1. Paginated transitionsOf + // ============================================================ + + console.log("\n=== 1. transitionsOf, paged with limit=2 ===\n"); + + const allTransitions: IdentityTransition[] = []; + let transitionsCursor: TransitionPageCursor | undefined; + let pageCount = 0; + do { + const page = await store.identity.transitionsOf(crm, { + limit: 2, + ...(transitionsCursor === undefined ? + {} + : { cursor: transitionsCursor }), + }); + pageCount += 1; + console.log( + ` page ${pageCount}: ${page.transitions.length} transition(s)`, + ); + for (const transition of page.transitions) { + console.log(` ${transition.cause} @ ${transition.recorded}`); + } + allTransitions.push(...page.transitions); + transitionsCursor = page.nextCursor; + } while (transitionsCursor !== undefined); + + assertTrue(pageCount > 1, "the lineage needed more than one page"); + // The retraction splits one class into two, and each resulting class's + // own new canonical gets its own transition row — both touch crm's + // lineage, so the split shows up as two `retract` transitions, not one. + assertEqual(allTransitions.length, 5, "five transitions total"); + assertEqual( + allTransitions.map((transition) => transition.cause), + ["assert", "assert", "retract", "retract", "assert"], + "the causes, oldest first, match the writes above", + ); + + // ============================================================ + // 2. Paginated replay + // ============================================================ + + console.log("\n=== 2. replay, paged with limit=2 ===\n"); + + const allSteps: Awaited< + ReturnType + >["steps"][number][] = []; + let replayCursor: TransitionPageCursor | undefined; + let replayPageCount = 0; + do { + const page = await store.identity.replay(crm, { + limit: 2, + ...(replayCursor === undefined ? {} : { cursor: replayCursor }), + }); + replayPageCount += 1; + console.log(` page ${replayPageCount}: ${page.steps.length} step(s)`); + for (const step of page.steps) { + const before = + step.before.map((member) => member.id).join(", ") || "(none)"; + const after = + step.after.map((member) => member.id).join(", ") || "(none)"; + console.log(` ${step.transition.cause}: [${before}] -> [${after}]`); + } + allSteps.push(...page.steps); + replayCursor = page.nextCursor; + } while (replayCursor !== undefined); + + assertTrue(replayPageCount > 1, "replay also needed more than one page"); + assertEqual(allSteps.length, 5, "replay pairs all five transitions"); + const lastStep = requireDefined(allSteps.at(-1)); + assertEqual( + new Set(lastStep.after.map((member) => member.id)), + new Set([crm.id, erp.id, support.id]), + "the final step's `after` matches the live membership", + ); + + // ============================================================ + // 3. Retention + // ============================================================ + + console.log("\n=== 3. Retention: pruneIdentityTransitions ===\n"); + + const secondTransition = requireDefined(allTransitions[1]); + const retentionCutoff = secondTransition.recorded; + + const { transitions: beforePruneTransitions } = + await store.identity.transitionsOf(crm); + const beforeCount = beforePruneTransitions.length; + const firstPrune = await pruneIdentityTransitions(store, { + beforeRecorded: retentionCutoff, + }); + const { transitions: afterPruneTransitions } = + await store.identity.transitionsOf(crm); + const afterCount = afterPruneTransitions.length; + console.log( + ` pruned ${firstPrune.pruned} transition(s) before revision ${firstPrune.prunedBeforeRevision}`, + ); + console.log(` transitionsOf: ${beforeCount} -> ${afterCount}`); + assertEqual( + firstPrune.pruned, + 1, + "exactly the first (join) transition was pruned", + ); + assertEqual(afterCount, beforeCount - 1, "one fewer transition is visible"); + + const secondPrune = await pruneIdentityTransitions(store, { + beforeRecorded: retentionCutoff, + }); + console.log( + ` pruning again at the same instant: pruned ${secondPrune.pruned}`, + ); + assertEqual( + secondPrune.pruned, + 0, + "pruning at an already-passed watermark is a no-op", + ); + + // ============================================================ + // 4. Labeling restored transitions + // ============================================================ + + console.log( + "\n=== 4. Restored transitions: a real archival export/import round trip ===\n", + ); + + const sourceBackend = createExampleBackend(); + const targetBackend = createExampleBackend(); + try { + const [source] = await createAdapterStoreWithSchema( + graph, + sourceBackend, + { history: true }, + ); + const sourceCrm = await source.nodes.CrmContact.create( + { name: "Alex Rivera" }, + { id: "restore-crm" }, + ); + const sourceErp = await source.nodes.ErpContact.create( + { name: "Alex Rivera" }, + { id: "restore-erp" }, + ); + const firstAssertion = await source.identity.assertSame( + sourceCrm, + sourceErp, + ); + await source.identity.retractAssertion(firstAssertion.assertion.id); + await source.identity.assertSame(sourceCrm, sourceErp); + + const { transitions: sourceTransitions } = + await source.identity.transitionsOf(sourceCrm); + console.log( + ` source lineage: ${sourceTransitions.length} native transition(s)`, + ); + + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + + const [target] = await createAdapterStoreWithSchema( + graph, + targetBackend, + { history: true }, + ); + const importResult = await importGraph(target, archive, { + onConflict: "skip", + }); + assertTrue(importResult.success, "the archival import succeeded"); + + const { transitions: targetTransitions } = + await target.identity.transitionsOf(sourceCrm); + console.log(" target lineage, labeled:"); + for (const transition of targetTransitions) { + console.log(` ${transition.cause}: ${labelTransition(transition)}`); + } + + const sourceTransitionIds = new Set( + sourceTransitions.map((transition) => transition.transitionId), + ); + const restoredHere = targetTransitions.filter((transition) => + sourceTransitionIds.has(transition.transitionId), + ); + assertEqual( + restoredHere.length, + sourceTransitions.length, + "every transplanted transition survived the round trip", + ); + assertTrue( + restoredHere.every( + (transition) => transition.restored?.at !== undefined, + ), + "every transplanted transition is marked restored", + ); + assertTrue( + sourceTransitions.every( + (transition) => transition.restored === undefined, + ), + "the SOURCE's own copies are never marked restored — the marker names where a row landed", + ); + + // The import itself performs a genuine identity write on the target + // (materializing the archive's CURRENT assertion as live state), and + // that write is native to this graph — captured by history exactly + // like any other write — even though every historical explanation + // beneath it arrived via restore. `replay` pairs that ONE native + // transition with a before/after snapshot and excludes the four + // restored ones, which is why `steps` is shorter than `transitions`. + const targetReplay = await target.identity.replay(sourceCrm); + const partialReasons = + targetReplay.completeness.status === "partial" ? + targetReplay.completeness.reasons + : []; + console.log( + ` target replay: ${targetReplay.steps.length} step(s), completeness=${targetReplay.completeness.status} (${partialReasons.map((entry) => entry.reason).join(", ")})`, + ); + assertEqual( + targetReplay.steps.length, + 1, + "replay pairs only the import's own native transition", + ); + const notReconstructed = partialReasons.find( + (entry) => entry.reason === "restored-not-reconstructed", + ); + assertEqual( + notReconstructed?.reason === "restored-not-reconstructed" ? + notReconstructed.count + : 0, + restoredHere.length, + "completeness counts every restored transition replay left out of steps", + ); + assertTrue( + !sourceTransitionIds.has( + requireDefined(targetReplay.steps[0]).transition.transitionId, + ), + "the one step replay produced is the NATIVE transition, never a restored one", + ); + } finally { + await sourceBackend.close(); + await targetBackend.close(); + } + + console.log("\n=== Identity history example complete ==="); + } finally { + await backend.close(); + } +} + +if (import.meta.url === `file://${process.argv[1]}`) { + main().catch((error: unknown) => { + console.error(error); + process.exit(1); + }); +} diff --git a/packages/typegraph/examples/README.md b/packages/typegraph/examples/README.md index 068cceafc..11dab346c 100644 --- a/packages/typegraph/examples/README.md +++ b/packages/typegraph/examples/README.md @@ -34,11 +34,12 @@ POSTGRES_URL=postgresql://typegraph:typegraph@127.0.0.1:5432/typegraph_test \ | Example | Description | |---------|-------------| -| [03-subclass-hierarchy.ts](./03-subclass-hierarchy.ts) | Type hierarchies with `subClassOf`: shared base schemas, polymorphic edge endpoints, `includeSubClasses` query expansion (and what it excludes), and registry introspection | +| [03-subclass-hierarchy.ts](./03-subclass-hierarchy.ts) | Type hierarchies with `subClassOf`: shared base schemas, polymorphic edge endpoints, `expansion: "subclasses"` query expansion (and what it excludes), and registry introspection | | [04-disjoint-constraints.ts](./04-disjoint-constraints.ts) | Mutual exclusion with `disjointWith` | | [05-edge-implications.ts](./05-edge-implications.ts) | Edge hierarchies with `implies` | | [06-inverse-edges.ts](./06-inverse-edges.ts) | Bidirectional relationships with `inverseOf` | -| [08-custom-ontology.ts](./08-custom-ontology.ts) | The core meta-edges working together (`broader`, `equivalentTo`, `disjointWith`, `partOf`, `inverseOf`, `implies`) plus custom `metaEdge()` relations — persisted, introspectable, and serialized, with custom semantics interpreted by your application | +| [08-custom-ontology.ts](./08-custom-ontology.ts) | The core meta-edges working together (`broader`, `equivalentTo`, `disjointWith`, `partOf`, `inverseOf`, `implies`) plus free-form type-level semantics via `defineGraph({ annotations })` — persisted, introspectable, and serialized, with the vocabulary interpreted by your application | +| [33-composition-lifecycle.ts](./33-composition-lifecycle.ts) | Whole/part composition with `partOf` and `existence: "required"`: a bare create of a required part is refused, `getOrCreateByConstraint`'s attachment postcondition, `reparent` between wholes, `subgraph({ composition: true })` exporting the complete owned unit, `planDelete` previewing a delete, and the delete cascade's `cascadedParts` on both an `onOperationEnd` hook and a `transactionWithReceipt` receipt | ### Data Management @@ -71,6 +72,7 @@ POSTGRES_URL=postgresql://typegraph:typegraph@127.0.0.1:5432/typegraph_test \ | [21-agent-decision-replay.ts](./21-agent-decision-replay.ts) | Reconstruct the exact knowledge graph an AI agent saw at decision time and replay the *same* `query()` / `degree()` over it — point-in-time-correct reasoning for audit, eval, and debugging | | [22-breach-forensics.ts](./22-breach-forensics.ts) | Bitemporal + graph: valid-time-windowed grants (`validFrom`/`validTo`), composed pins (`store.asOf(breachAt).asOfRecorded(alertAnchor)`), `reachable()` for the true blast radius, and a pinned `shortestPath()` that names the attack path incident response later deleted | | [26-store-views.ts](./26-store-views.ts) | Read lenses over one graph: view modes (current / includeTombstones / includeEnded), asOf-pinned edge reads, consistent snapshots, and the read-only refusal contract | +| [34-identity-history.ts](./34-identity-history.ts) | Operational identity over time: paginated `transitionsOf` and `replay` reading a lineage a page at a time via `nextCursor`, `pruneIdentityTransitions` retention with its no-op-below-the-watermark guarantee, and the `restored` marker an archival export/import round trip stamps on a transplanted transition | ### Provenance & Retraction diff --git a/packages/typegraph/package.json b/packages/typegraph/package.json index 08fb46740..3e8000a21 100644 --- a/packages/typegraph/package.json +++ b/packages/typegraph/package.json @@ -116,8 +116,8 @@ ], "sideEffects": false, "scripts": { - "dev": "tsup --watch", - "build": "tsup", + "dev": "node --import tsx scripts/build.ts --watch", + "build": "node --import tsx scripts/build.ts", "lint": "eslint .", "typecheck": "tsc --noEmit --skipLibCheck", "test:types": "node --import tsx scripts/test-types.ts all", @@ -185,7 +185,7 @@ "drizzle-orm": ">=0.35.0 <1.0.0", "pg": ">=8.0.0 <9.0.0", "postgres": ">=3.4.0 <4.0.0", - "zod": "^4.0.0" + "zod": "^4.5.0" }, "peerDependenciesMeta": { "@electric-sql/pglite": { diff --git a/packages/typegraph/scripts/api-report.ts b/packages/typegraph/scripts/api-report.ts index c30b0834f..e29c31c81 100644 --- a/packages/typegraph/scripts/api-report.ts +++ b/packages/typegraph/scripts/api-report.ts @@ -405,6 +405,260 @@ const EMPTY_FORGOTTEN_EXPORT_DEBT: ForgottenExportDebt = { // `BulkOperationHookContext`. Gate: every added symbol at every moved // entrypoint is one of those eight names, no entrypoint's debt decreased, // and no other entrypoint moved. +// +// Ontology change classification batch: the fourth +// constraint-fence-audit family (`edgeEndpointAssignability`) adds +// `EdgeEndpointAllowance` and `MisassignedEdgeEndpointRow` to +// `src/backend/types.ts`'s `ReadConstraintFenceViolationsParams` / +// `ConstraintFenceViolationRows`, and the new `MigrationErrorDetails` +// `"ontology-tightening-violated"` member carries `changes: readonly +// OntologyChange[]` (whose own `probes?: readonly OntologyDataProbe[]` names +// the new `OntologyDataProbe` union and its `UniquenessComponentProbeGroup` +// member). `OntologyChange` itself was ALREADY forgotten-export debt +// everywhere it renders (pre-existing, via `SchemaDiff.ontology`) and so is +// NOT part of this batch's delta — only the four truly new names are. +// Measured, not assumed: +// - `./schema` (271→273, +2) and the six `./adapters/drizzle/*` sub-entrypoints +// plus `./adapters/drizzle/engine` (each +2: `./adapters/drizzle/sqlite` +// 247→249, `./adapters/drizzle/postgres` 246→248, +// `./adapters/drizzle/postgres/pglite` 250→252, +// `./adapters/drizzle/sqlite/local` 250→252, +// `./adapters/drizzle/sqlite/libsql` 250→252, `./adapters/drizzle/engine` +// 320→322) gain only `EdgeEndpointAllowance` and `MisassignedEdgeEndpointRow`: +// `./schema` exports `OntologyChange` / `OntologyDataProbe` / +// `UniquenessComponentProbeGroup` directly (`classifyOntologyChanges`, +// `ontologyTighteningProbes`), so nothing else the new +// `MigrationErrorDetails` member or the fourth audit family reaches needs a +// forgotten name there or at the backend-adapter entrypoints, which never +// name `OntologyChange` at all. +// - The six `Store`-bearing entrypoints that do not export +// `classifyOntologyChanges` (`./interchange` 709→713, `./profiler` +// 711→715, `./graph-merge` 726→730, `./provenance` 717→721, `./sqlite/local` +// 706→710, `./postgres/pglite` 706→710) each gain all four: +// `EdgeEndpointAllowance`, `MisassignedEdgeEndpointRow`, `OntologyDataProbe`, +// and `UniquenessComponentProbeGroup` (+4 apiece). `./backend`, +// `./adapters/drizzle/indexes`, `./core`, `./graph-extension`, and +// `./indexes` are unaffected: `./backend` exports `EdgeEndpointAllowance` +// and `MisassignedEdgeEndpointRow` directly, and the other four never reach +// `MigrationErrorDetails`, `ReadConstraintFenceViolationsParams`, or +// `ConstraintFenceViolationRows` at all. +// - `.` (the package root) is the one Store-bearing entrypoint that does NOT +// gain any of the four: `src/index.ts` exports `EdgeEndpointAllowance` and +// `MisassignedEdgeEndpointRow` directly (alongside `ConstraintFenceViolation`) +// and `OntologyDataProbe` / `UniquenessComponentProbeGroup` directly +// (alongside the schema-reads block), so a consumer of +// `"@nicia-ai/typegraph"` alone can name every field of a narrowed +// `ConstraintFenceViolation` or `MigrationErrorDetails` without a subpath +// import. IN THIS BATCH's OWN commit, its debt therefore stayed at the +// pre-batch baseline (388), not 388→392 — the edge acyclicity batch below moves `.` on top +// of that baseline instead. Gate (this batch's own commit): every added symbol +// at every OTHER moved entrypoint is one of the four names above (never +// `OntologyChange` itself, which is pre-existing debt everywhere including +// `.`), `.` is the one entrypoint this batch leaves unchanged, no OTHER +// entrypoint's debt decreased, and exactly 13 entrypoints moved in this +// commit (a later batch moves more — see below). +// +// Edge acyclicity batch: the store's +// `assertEdgeRelationsAcyclic` / `readEdgeAcyclicityViolations` add a fifth +// `ConstraintFenceViolation` member (`src/store/claims/verify.ts`), +// `EdgeAcyclicityViolation` (`src/store/acyclicity.ts`) — internal, exported +// by name at NO entrypoint, unlike the classification batch's `EdgeEndpointAllowance` / +// `MisassignedEdgeEndpointRow`. A consumer narrowing `ConstraintFenceViolation` +// to `{ family: "edgeAcyclicity" }` can therefore never import its shape by +// name from any subpath, including `.` itself. Measured, not assumed: every +// one of the classification batch's SEVEN `ConstraintFenceViolation`-reaching entrypoints gains +// exactly this one name, +1 apiece, on top of that batch's baseline — +// `.` (388→389), `./graph-merge` (730→731), `./interchange` (713→714), +// `./postgres/pglite` (710→711), `./profiler` (715→716), `./provenance` +// (721→722), and `./sqlite/local` (710→711). No other entrypoint moves: +// `./schema`, the six `./adapters/drizzle/*` sub-entrypoints, `./backend`, +// `./core`, `./graph-extension`, `./adapters/drizzle/indexes`, and +// `./indexes` never reach `ConstraintFenceViolation` at all. Gate: every +// moved entrypoint gains exactly one name (`EdgeAcyclicityViolation`), `.` +// is no longer exempt the way it was in the classification batch (a batch CAN +// move it — the exemption was about that batch's four names specifically, not a standing +// invariant), no entrypoint's debt decreased, and exactly 7 entrypoints +// moved in THIS commit. +// +// Target-side edge cardinality (issue #610): every entrypoint that reaches +// `GraphBackend`, `ManagedEdgeCreatePlan`, `CardinalityErrorDetails`, +// `MigrationErrorDetails`, or the serialized/introspected edge shapes picks +// up `EdgeCardinalityAxisRef`, `EdgeCardinalityDirection`, +// `ConstrainedCardinality`, `ConstrainedTargetCardinality`, and/or +// `CountEdgesAtEndpointParams` as forgotten exports (the renamed +// `countEdgesFrom` → `countEdgesAtEndpoint` member and its widened claim/audit +// param types). `./core`, `./indexes` and `./adapters/drizzle/indexes` reach +// none of those shapes and are unaffected. +// +// Target-side cardinality review fix: `.` (the package root) now exports +// `EdgeCardinalityAxisRef` and `EdgeCardinalityDirection` directly — the two +// types `EdgeCardinalityDeclaration` (already public from `.`) and +// `CardinalityErrorDetails.direction` (already public from `.`) are built +// from — so a consumer of `"@nicia-ai/typegraph"` alone can name either +// field's type without a subpath import. `.`'s debt therefore DROPS 391→389 +// (two names move from forgotten to real exports); every OTHER entrypoint is +// unaffected, since none of them re-exports either name and both remain +// forgotten there exactly as the paragraph above describes. +// +// Transaction-scoped policy delete: `TransactionRuntime` and +// `StoreRuntime` both gain a `deleteNodeWithPolicy` member typed over +// `NodeDeletePolicy` (`store/operations/node-write-pipeline.ts`) so merge +// apply's node delete can bind to the SAME transaction's buffered hook +// runner and attempt instead of a freshly-built Store-scoped context. This +// is exactly the `[STORE_RUNTIME]`-reachable set (`.`, `./graph-merge`, +// `./interchange`, `./profiler`, `./provenance`, `./sqlite/local`, +// `./postgres/pglite`), each +1 for `NodeDeletePolicy` on top of the target-side cardinality baseline: `.` 389→390, +// `./graph-merge` 734→735, `./interchange` 717→718, `./postgres/pglite` +// 714→715, `./profiler` 719→720, `./provenance` 725→726, `./sqlite/local` +// 714→715. No other entrypoint renders `TransactionRuntime`/`StoreRuntime`, +// so no other entrypoint moved. +// +// Composition relation (partOf/hasPart via/partSide): every +// entrypoint that reaches `KindRegistry`, `ExtensionOntologyRelation`, or the +// registry closures picks up `CompositionPartSide` (a `@public` named export) +// and, where the entrypoint also surfaces `RegistryClosures` internals, +// `CompositionPair` / `CompositionRelation` as forgotten exports. `./indexes` +// and `./adapters/drizzle/indexes` never reach the registry and are +// unaffected. +// +// Typed subsumption batch: measured, not assumed — every +// added name below was confirmed against a merge-base build run through this +// same script (a temporary unconditional dump of each entrypoint's forgotten- +// export set, diffed line by line against this batch's set, then reverted). +// +// `.` gains exactly the 13 new declarations this batch introduces, all +// reachable only through `subClassOf`/`equivalentTo`/`sameAs`'s new generic +// signatures, none of them exported directly at the root: `TypedOntologyRelation`, +// `SubClassOfCheck`, `EquivalentToCheck`, `IncompatibleKeys`, `LiteralKeysOf`, +// `StructuralSubtypeMismatch`, `META_EDGE_SUB_CLASS_OF`, +// `META_EDGE_EQUIVALENT_TO`, `META_EDGE_SAME_AS`, `PolymorphicNodeType`, +// `AliasExpansionAxis`, `AliasNodeType`, `SubsumptionAffected` (388 → 401). +// +// The six Store-bearing entrypoints that render `QueryBuilder`/`Store` +// without naming the ontology relation functions themselves (`./interchange`, +// `./profiler`, `./graph-merge`, `./provenance`, `./sqlite/local`, +// `./postgres/pglite`) each gain only the four names reachable through the +// widened `from`/`to`/`fromDynamic`/`toDynamic` overloads and `QueryStart`'s +// new `expansion` field — `AliasExpansionAxis`, `AliasNodeType`, +// `PolymorphicNodeType`, `SubsumptionAffected` (+4 apiece) — never the +// `subClassOf`/`equivalentTo`/`sameAs`-only names above, since none of these +// six re-exports those functions. No other entrypoint moves: `./backend`, +// `./core`, `./schema`, `./graph-extension`, `./indexes`, and the five +// `./adapters/drizzle/*` entrypoints render neither the ontology relation +// functions nor a `QueryBuilder`, so none of this batch's new vocabulary +// becomes reachable there. `./schema` gains `isTypeLevelSubtype` and +// `projectTypeVisible` as DIRECT exports (visible in the api report diff, not +// this ledger), so its forgotten-export debt is unaffected. +// +// Typed subsumption follow-up: `SubsumptionAffected` gained a new private helper, +// `OntologyTypeErased` (whether `G["ontology"]` has lost its `const`-inferred +// tuple shape — see its docblock in `src/query/builder/types.ts`), reachable +// through the exact same seven entrypoints as `SubsumptionAffected` itself +// and no others, for the same reason: `.` (+1: 401 → 402) and the six +// Store-bearing entrypoints above (+1 apiece: `./interchange` 717 → 718, +// `./profiler` 719 → 720, `./graph-merge` 734 → 735, `./provenance` +// 725 → 726, `./sqlite/local` 714 → 715, `./postgres/pglite` 714 → 715). +// +// Identity transition log batch: `StoreRuntime.identityContext()` (added +// earlier in this feature, alongside the transition log's relations and +// `IdentityTableNames`/`SqlTableNames` gaining `identityTransitions` / +// `identityTransitionRetention`) was never reconciled against this ledger — +// `pnpm api-report:update` had not been run since. It returns +// `IdentityServiceContext`, an internal (non-exported) type that itself +// names `PlainNodeRef`; neither is exported directly anywhere, including the +// root. Both become newly reachable, and so newly forgotten-exported, at +// every entrypoint whose full `StoreRuntime` member set renders: `.` +// (388→390), `./interchange` (709→711), `./profiler` (711→713), +// `./graph-merge` (726→728), `./provenance` (717→719), `./sqlite/local` +// (706→708), `./postgres/pglite` (706→708) — +2 apiece, `IdentityServiceContext` +// and `PlainNodeRef` exactly. The `identityTransitions`/`identityTransitionRetention` +// string fields introduce no new symbol names, so they move no entrypoint's +// debt on their own. Gate: every moved entrypoint's delta is exactly +2, +// both added names are `IdentityServiceContext`/`PlainNodeRef`, and no other +// entrypoint moved. +// +// This is internal-convenience debt, not a user-facing requirement: +// `identityContext()`'s two consumers, replay and prune, are plain functions +// over `IdentityServiceContext`. The release batch below evaluates, and +// declines, both ways of retiring these seven +2 entries — see that batch's +// own comment for the reasoning; this one stays permanent, not staged. +// +// `ensureSchema`'s `historyEnabled` extra moved off the public +// `SchemaManagerOptions` onto an unexported `EnsureSchemaInternalOptions` +// that only `ensureSchemaInternal` (imported directly by `store.ts`, never +// re-exported) accepts, so it renders at no entrypoint at all — no ledger +// entry to update for that change. +// `IdentityDecisionProvenance` (+1 on `./interchange`, `./postgres/pglite`, +// `./profiler`, `./provenance` and `./sqlite/local`): the graph-merge identity +// apply threads the governing merge decision through +// `StoreRuntime.applyIdentityMergeAtTarget`, so the runtime port's declared +// parameter type renders at every entrypoint that projects `StoreRuntime`. +// Naming the type on the port is the point — a structurally-inlined copy of the +// decision shape would be a second spelling of "what a governing decision is". +// It is exported from the package barrel and from `src/graph-merge/index.ts`, +// which is what keeps `.` and `./graph-merge` off this list; the five above +// each carry their own narrow barrel that has no business re-exporting an +// identity type, so their entry is the honest cost of naming it on the port. +// +// `./graph-merge` exports the identity types its merge report and plan name +// (`IdentityUnresolvedConflict`, `IdentityDecisionProvenance`, +// `IdentityTransferAssertion`, `IdentityRelation`) deliberately from +// `src/graph-merge/index.ts` rather than leaving them as debt. +// +// Decision: the five `IdentityDecisionProvenance` entries above +// stay PERMANENT debt, not retired. The only retirement this ledger's own +// discipline allows is re-exporting the type from each of those five narrow +// barrels — `./interchange`, `./postgres/pglite`, `./profiler`, +// `./provenance`, `./sqlite/local` — purely to move a count, which is +// exactly the "export machinery invented to avoid debt" this file's header +// forbids and the identical `IdentityServiceContext` / `PlainNodeRef` decision +// two comments below already declined for the same reason. None of those +// five barrels has a type-surface reason of its own to name a merge +// decision's shape. +// +// Release batch, publishing replay and archival restore (this had not been +// run since the `identityContext()` batch above landed either, so `.`'s +// baseline already carried that batch's `IdentityServiceContext` / +// `PlainNodeRef`, unmentioned by name below because this batch changes +// neither count on `.`): +// +// `IdentityFacade` gains `replay` / `transitionsOf` +// (`IdentityReplay`, `IdentityReplayOptions`, `IdentityReplayStep`, +// `IdentityTransition`, `IdentityTransitionCause` in their signatures, all +// exported deliberately from the package barrel), and +// `StoreRuntime` gains the archival-restore port members +// `readIdentityTransitionPageAtTarget` / `importIdentityTransitionsAtTarget` +// (`IdentityTransitionCursor` / `IdentityTransitionTransfer`, internal +// wire-transfer shapes owned by `transition-log.ts`, exported nowhere). +// That is seven names, and every one of them renders at the six +// Store-bearing entrypoints exactly as `IdentityDecisionProvenance` already +// does two comments up — `./interchange`, `./profiler`, `./graph-merge`, +// `./provenance`, `./sqlite/local`, `./postgres/pglite`, +7 apiece — because +// none of those narrow barrels re-exports the five deliberately-public +// names, the same "has no business re-exporting an identity type" reasoning +// that already applies to `IdentityDecisionProvenance` there. +// +// `.` moves only +2: the five deliberately-exported names are exported AT +// `.` itself, so they are not forgotten there — only the two never-exported +// transfer types (`IdentityTransitionCursor`, `IdentityTransitionTransfer`) +// newly render, through the same `StoreRuntime` reachability +// `IdentityServiceContext` / `PlainNodeRef` already established. +// +// Narrowing `identityContext()`'s declared return (the retirement path this +// file's own comment above named as "expected") was evaluated and NOT +// taken: `identityContext()`'s sole consumer, `pruneIdentityTransitionsForContext`, +// hands the context straight to `runIdentityMutation` — the identity +// module's central mutation runner, called from every write site — so +// narrowing the return would cascade into re-typing `runIdentityMutation` +// across the whole module, exactly the kind of internal-semantics change +// this release is scoped not to make. Exporting `IdentityServiceContext` / +// `PlainNodeRef` publicly (this file's other named option) was also +// declined: `pruneIdentityTransitions`'s own public signature (`store: +// Store`, an options bag) never mentions either type, so exporting two +// backend-shaped internal types would add public surface with no caller who +// needs it, purely to move a ledger number. That debt stays as documented, +// unretired. +// // TransactionBackend gaining LineageBackend batch. `TransactionBackend` // gained `LineageBackend` (a `Pick`, mirroring the // pre-existing `CatalogBackend`) so `tx.lineage` is type-accessible on a @@ -653,6 +907,121 @@ const EMPTY_FORGOTTEN_EXPORT_DEBT: ForgottenExportDebt = { // GraphBackend. The backend barrel exports those contracts directly; the // remaining entrypoints retain their deliberately narrower public surfaces, // so the exact transitive name sets are booked here rather than widened. +// +// Typed ontology relations and the one expansion option batch. Two source +// changes move this ledger, and both move it for the same mechanical reason: +// a type a PUBLIC signature now names, that no entrypoint exports. +// +// `.` moves +11 (424 → 435), from twelve newly rendered declarations: +// - the eight meta-edge name constants the newly typed factories name in +// their return types (`META_EDGE_BROADER`, `META_EDGE_DISJOINT_WITH`, +// `META_EDGE_HAS_PART`, `META_EDGE_IMPLIES`, `META_EDGE_INVERSE_OF`, +// `META_EDGE_NARROWER`, `META_EDGE_PART_OF`, `META_EDGE_RELATED_TO`). +// `broader`/`narrower`/`relatedTo`/`disjointWith`/`partOf`/`hasPart`/ +// `inverseOf`/`implies` each return `TypedOntologyRelation` now instead of a bare `OntologyRelation`, which is +// exactly how `META_EDGE_EQUIVALENT_TO` and `META_EDGE_SUB_CLASS_OF` +// became debt here when `equivalentTo`/`subClassOf` were typed — same +// category, same accepted cost, eight more of it. +// - `BareOntologyRelation` (the conservative-widening arm of +// `SubsumptionAffected`) and `DefaultAliasExpansionAxis` / +// `SearchExpansionAxis` (the store-default and search-facade spellings of +// the one `expansion` option), each named by a public type — +// `CreateQueryBuilderOptions`/`BaseStoreOptions` and +// `SearchScopeOptions` respectively. `AliasExpansionAxis` was already +// debt here for the same reason. +// `SubgraphResultEdgeKinds` is the twelfth declaration and is NOT debt at +// `.`: the root barrel exports it deliberately (`src/index.ts`), because a +// caller that stores a `subgraph({ composition })` result needs to be able +// to name that result's edge-key type. +// +// The six entrypoints that mirror `.`'s Store surface through a narrow +// barrel (`./graph-merge`, `./interchange`, `./postgres/pglite`, +// `./profiler`, `./provenance`, `./sqlite/local`) move +4 apiece, and by the +// same four names every time: `BareOntologyRelation`, +// `DefaultAliasExpansionAxis`, `SearchExpansionAxis` and +// `SubgraphResultEdgeKinds` — the fourth because none of those barrels +// re-exports it, the same "has no business re-exporting" reasoning the +// `IdentityDecisionProvenance` decision above already applies. They do NOT +// move for the eight meta-edge constants: the ontology factories are +// exported from the root barrel alone, so no narrow barrel renders them. +// +// Gate: exactly seven entrypoints move; `.` by +11 with the twelve names +// above (eleven of them forgotten); the six narrow barrels by exactly +4 +// with those four names; no other entrypoint moves, and no backend-adapter +// entrypoint moves at all (none of them renders `QueryBuilder`, +// `BaseStoreOptions` or `SubgraphOptions`). +// +// Expansion-forwarding and projection-key review batch. The SAME seven +// entrypoints move again, +4 apiece (`.` 435 → 439; `./graph-merge` 778 → +// 782; `./interchange` 763 → 767; `./postgres/pglite` and `./sqlite/local` +// 760 → 764; `./profiler` 765 → 769; `./provenance` 771 → 775) — five new +// declarations minus one retired, everywhere: +// - `EndpointKindErased`, `MatchedEndpoints` and +// `SubsumptionLiteralsErased` replace `BareOntologyRelation` as +// `SubsumptionAffected`'s conservative arm. The arm now tests for the +// ERASED kind literals rather than assignability from `OntologyRelation`, +// which needs the endpoint-position and per-endpoint helpers alongside the +// per-element one (net +2 for the three-for-one swap). +// - `AliasExpansionOptions`, because `from`/`to`/`fromDynamic`/`toDynamic` +// now each declare an overload taking the option owner's own bag so a +// caller's options can be forwarded; the type it names, +// `AliasExpansionAxis`, was already debt here. +// - `SubgraphProjectFor`, the projection-over-the-result's-edge-kinds +// constraint every `subgraph` signature and option bag now takes. It is +// treated exactly like `SubgraphProject`, which the same signatures +// already rendered as debt: a constraint type, not a value a caller +// constructs. +// Gate: exactly those seven entrypoints move, each by exactly +4 and by the +// same five-in/one-out name set; no other entrypoint moves (no adapter +// entrypoint renders `QueryBuilder`, `BaseStoreOptions` or +// `SubgraphOptions`), and `SubgraphResultEdgeKinds` stays absent from `.`'s +// forgotten set because the root barrel still exports it. +// +// Union endpoint batch (W3). The SAME seven entrypoints move once more, +1 +// apiece (`.` 437 → 438; `./graph-merge` 784 → 785; `./interchange` 769 → +// 770; `./postgres/pglite` and `./sqlite/local` 766 → 767; `./profiler` +// 771 → 772; `./provenance` 777 → 778), for exactly one new declaration: +// `SubsumptionElementNamesKind`. It replaces the whole-relation `Extract` +// arms `SubsumptionAffected` used to spell inline — those arms named no +// declaration of their own, so nothing retires — and decides each element by +// `Extract`ing over the element's ENDPOINTS, which is what makes an endpoint +// kind UNION decidable. Gate: every moved entrypoint's delta is exactly +1, +// and removing `SubsumptionElementNamesKind` from each new symbol set +// reproduces that entrypoint's previous `sha256` exactly; no other +// entrypoint moves (none of the rest renders `QueryBuilder`). +// +// Ontology-semantics re-baseline batch (#637). The branch that carries the +// batches above was rebased from 0.57.0 onto 0.68.1, and the rebase kept the +// branch's own copy of this table, whose values matched neither build. Every +// count below is re-measured on the rebased tree, including the review fixes +// that landed after the rebase (`setActiveVersionWithPreflight`, +// `buildReadEdgeClaimIncumbents`, the single identity-transition base-schema +// release, `reviveNode` on the transaction runtime, composition generics on +// `TransactionContext.subgraph`, `IdentityServiceContext. +// recordedTimeOwnership`, and the `ontology-tightening` evolution +// requirement). Those fixes add no forgotten name: every name in the measured +// sets was already declared before them, and each entrypoint's delta against +// 0.68.1 equals the delta the branch measured against 0.57.0 before the +// rebase. Measured against 0.68.1: `.` 501 → 535 (+34); `./graph-merge` +// 866 → 921 (+55); `./interchange` 849 → 906, `./postgres/pglite` and +// `./sqlite/local` 855 → 912, `./profiler` 851 → 908, `./provenance` +// 864 → 921 (+57 apiece); `./schema` 288 → 301 (+13); the five +// `./adapters/drizzle/*` backend entrypoints +8 apiece (`sqlite` 266 → 274, +// `postgres` 265 → 273, `postgres/pglite`, `sqlite/local` and +// `sqlite/libsql` 269 → 277); `./adapters/drizzle/engine` 336 → 343 (+7); +// `./backend` 18 → 24 (+6); `./graph-extension` 16 → 20 (+4); `./core` +// 72 → 73 (+1); `./indexes` and `./adapters/drizzle/indexes` do not move. +// The added names are the composition, cardinality, ontology-probe, +// identity-transition/replay, meta-edge-constant, subsumption and expansion +// types the batches above book. The removed names are `CountEdgesFromParams` +// (renamed `CountEdgesAtEndpointParams`) and `InferenceType` (meta-edge +// removal) at every entrypoint that rendered them, `EdgeCardinalityDeclaration` +// at `.`, and `IdentityRelation` and `MergeErrorOptions` at `./graph-merge`. +// Gate: the per-entrypoint deltas above are exact, and no entrypoint's delta +// differs from the branch's pre-rebase delta against 0.57.0. +// +// Changes merged from main after that re-baseline: // Graph-extension introspection now exports its function and two result types // from the root, removing those three names from root forgotten-export debt. // Identity class paging and same-class explanations export their four result @@ -669,94 +1038,228 @@ const EMPTY_FORGOTTEN_EXPORT_DEBT: ForgottenExportDebt = { // GraphBackend/engine strategy surfaces. Internal query-builder aliases are // intentionally not re-exported from the backend barrel; the engine's exact // transitive forgotten-export set is recorded here. +// +// Every count and fingerprint in the table below is measured on the tree that +// carries both sets of changes, so it supersedes the per-batch figures quoted +// above, which describe each side before they met. The review format +// constants renamed in that merge are exported from `./graph-merge` directly +// and add no forgotten name. const FORGOTTEN_EXPORT_DEBT: Readonly> = { + // Meta-edge removal: removing the public `InferenceType` + // union (never re-exported from most entrypoints, only pulled in + // transitively through `MetaEdgeProperties`/`SerializedMetaEdge`) dropped + // exactly one forgotten export apiece from every entrypoint below that + // saw its count change under THIS ONE batch — that batch only ever + // lowered a count, never raised one. That is not a standing guarantee + // for the ledger as a whole: later batches below (the composition existence batch among them) + // document counts this ledger RAISES, each with its own gate on the + // exact delta and the exact new names responsible. + // + // Composition existence: `CompositionExistence` (the `existence: "optional" | "required"` + // union) is a new public type, re-exported directly only from `.` and + // `./ontology` (alongside its existing sibling `CompositionPartSide`). + // Every OTHER entrypoint below reaches it only transitively — through a + // public signature that names `CompositionOptions`/`OntologyRelation`/ + // `CompositionPair`-derived shapes it never itself exports — so it + // registers as a NEW forgotten export everywhere those signatures are + // reachable (+1 apiece). A few entrypoints (`./backend`, + // `./adapters/drizzle/*`, `./adapters/drizzle/postgres/pglite`, + // `./adapters/drizzle/sqlite/local`, `./adapters/drizzle/sqlite/libsql`, + // `./adapters/drizzle/engine`) also newly forgotten-export + // `CompositionPartSide` for the first time at +1 each (it was already + // reachable at every OTHER entrypoint below before this lane), because + // the new `ConstraintFenceViolation.compositionExistence` member and the + // `ReadConstraintFenceViolationsParams`-adjacent surface it rides in on + // make the composition-relation types reachable from those backend + // entrypoints for the first time. + // `NodeCreateOptions`, `NodeReparentOptions` and `NodeReparentResult` are + // exported directly from `.` (see `src/index.ts`). `CompositionWholeRef` + // is not: it was an alias of `CompositionNodeRef` and callers import + // `CompositionAttachment`. + // Typed composition/ontology aliases (`CompositionAttachmentProps`, + // `TypedOntologyRelation`, `SubClassOfCheck`, `EquivalentToCheck`, + // `PolymorphicNodeType`, `StructuralSubtypeMismatch`) are now exported from + // `.`, which drops that entrypoint's forgotten-export count. Other + // entrypoints still reach the renamed probe kinds and attachment props only + // transitively, so their counts hold and only the fingerprint moves. + // `CompositionRealization` is the shared `{ via?, partSide?, existence? }` + // fragment intersected into every ontology relation shape (declared, typed, + // named, serialized and extension), so the composition fields are documented + // once. It is exported from no entrypoint, so it registers as one new + // forgotten name at each of the eighteen entrypoints that reach an ontology + // relation type: +1 apiece, no other name moved. `./adapters/drizzle/indexes` + // and `./indexes` never reach it and are unchanged. + // `EdgeKindFacts` is the return type of the new `KindRegistry.edgeKindFacts` + // reader. It is exported from no entrypoint, so it registers as one new + // forgotten name wherever `KindRegistry` is reachable: `.`, `./schema` and + // the seven Store-bearing entrypoints. `IdentityLineageIncompleteDiscovery` + // is exported from `.` directly; those same seven Store-bearing entrypoints + // (`./graph-merge`, `./interchange`, `./profiler`, `./provenance`, + // `./sqlite/local`, `./postgres/pglite`, + // `./adapters/drizzle/postgres/working-copy`) reach it only transitively + // through the identity lineage reads and gain it as a second name. Delta: +1 at + // `.` and `./schema`, +2 at each of the seven; no other entrypoint moved. + // `IdentityLineageIncompleteDiscovery` was then replaced by the page + // completeness pair `IdentityHistoryCompleteness` and + // `IdentityHistoryIncompleteReason`, both exported from `.`; the same seven + // Store-bearing entrypoints reach them only transitively, so each loses one + // forgotten name and gains two (+1 apiece); no other entrypoint moved. + // `SchemaGraphWriteFenceParams` is the parameter type of + // `GraphBackend.lockSchemaVersionAndGraphWrite`, exported from no entrypoint + // like its sibling `SchemaWriteFenceParams`, and `GraphWriteFenceAcquisition` + // is that member's result, exported from `.` and `./backend` only. Every + // entrypoint that reaches `GraphBackend` carries the first as a forgotten + // name, and every one but `.` and `./backend` the second: +1 at those two, + // +2 at the other fourteen; no other name moved. + // Delete plans: `NodeCollection.planDelete` returns the new `NodeDeletePlan`, + // and `cascadedParts` is typed by the new `CompositionCascadedPart`. `.` + // exports both; the seven Store-bearing entrypoints reach them only through + // `Store`: +2 apiece. + // Expanded list sources: `from([...], alias, options?)` is typed through + // three non-exported names (`SourceKindList`, `ListAliasNodeType`, + // `ListPolymorphicNodeType`), +3 at `.` and at the seven Store-bearing + // entrypoints. + // Typed edge endpoints under subsumption: `.` exports `SubsumedNodeType`, + // `EdgeEndpointRef`, `NodeReferenceLookup`, `AssignableNodeKinds`, + // `EdgeInGraph` and `GraphNodeForKinds`, and gains the nine unexported + // helpers they are spelled with (`AssignableKeyClosure`, + // `AssignableNodeTypes`, `DirectlyAssignableKeys`, `EdgeFromNodeTypes`, + // `EdgeToNodeTypes`, `GraphWithoutOntology`, `IriEndpointKey`, + // `KindNodeTypes`, `SubsumptionEndpointKeys`): +9. The seven Store-bearing + // entrypoints reach all fifteen only transitively: +15 apiece. + // Together: +12 at `.`, +20 at each of the seven; no other entrypoint moved. + // Widened endpoint references as inputs: `EdgeEndpointRef`, the bulk + // endpoint reads' `BulkEdgeSourceGroup` and the `neighbors` source spell + // their widened arm with `SubsumedNodeReference` (the `neighbors` source as + // `GraphNodeReferenceInput`). `.` exports both and gains the two unexported + // helpers they are spelled with (`BrandedNodeReference`, + // `NodeReferenceGroup`): +2. The seven Store-bearing entrypoints reach all + // four only transitively: +4 apiece. + // Guarded deletes: `NodeCollection.delete(id, options?)` takes the new + // `NodeDeleteOptions`. `.` exports it; the seven Store-bearing entrypoints + // reach it only through `Store`: +1 apiece, no other name moved. ".": { - count: 500, - sha256: "62e325f352ed2a6ff47081301d548aa1a7a0091d1282028a1aa4c9c3d71116a5", + count: 559, + sha256: "533f97abe45082d0d0810b620ba467c0120579ffbbab34ea3d74de261bbd0be7", }, "./adapters/drizzle/engine": { - count: 338, - sha256: "8a3135da686dc142f7a44c5007b0d4e33f9312aca060d16c3da852dc5f485c97", + count: 348, + sha256: "b8ac23fe80cedc5479e95f29c123b1ef4bbfb20e206ce4eaa18297eda9e23e23", }, "./adapters/drizzle/indexes": { count: 24, sha256: "6c11a8d2c13c886a2d6473f8af99d9c4988c7bbfe97545a6a6f748cdd18bf6d8", }, "./adapters/drizzle/postgres": { - count: 267, - sha256: "b55da11ff55ec6cef66aef6ea2c114f87f982ce3ebd044831401bba4770c47d1", + count: 278, + sha256: "5a2dfb318758f4d554b6c30eea472b4262af1a9b68e3226a4a7ed8560f2d3684", }, // The dedicated working-copy adapter exposes Store and durable strategy // signatures, making their portable implementation types transitively // visible here. Keep that large type graph isolated from the established // PostgreSQL adapter and pin its exact symbol set. + // `IdentityRestoreBaseline` is the destination snapshot an archival + // transition restore decides on, named only by the internal + // `StoreRuntime.readIdentityRestoreBaselineAtTarget` and + // `importIdentityTransitionsAtTarget`. No entrypoint exports it: `.` and + // the seven Store-bearing entrypoints (`./adapters/drizzle/postgres/working-copy`, + // `./graph-merge`, `./interchange`, `./postgres/pglite`, `./profiler`, + // `./provenance`, `./sqlite/local`) reach it only through the runtime port + // and carry it as a forgotten name: +1 apiece, no other name moved. + // Restored identity transitions: `IdentityTransition` became the union of + // `RecordedIdentityTransition` and `RestoredIdentityTransition`, whose + // `recorded` is a `RestoredRecordedInstant` (a source graph's instant that + // `asOfRecorded` refuses at the type level). `.` and `./core` export the + // new names; the same seven Store-bearing entrypoints reach them only + // through the identity facade and gain exactly those three forgotten names + // (+3 apiece), no other name moved. "./adapters/drizzle/postgres/working-copy": { - count: 876, - sha256: "0693b4b6f2768f3d19ebadb05bd2728083eb8b71ff2085836e48d4bab53c285c", + count: 974, + sha256: "06aab06582c990a36bbd0b34565259a37a284306446b1f67d0d6b709ca76e9db", }, "./adapters/drizzle/postgres/pglite": { - count: 271, - sha256: "2ad46f21aac8a3f0326abbc01ef3ce5e8cd9f9ad6dad2ee0f1606e6551f87bd9", + count: 282, + sha256: "5b72eac85055ed54bfd8b8b153769897fbe759e5bf825a5b1115bc1a0feae08f", }, "./adapters/drizzle/sqlite": { - count: 268, - sha256: "b45a0946c6112c386bed33d23429b4fffe1f3bd20f804bd9ff54e6f13a6f4076", + count: 279, + sha256: "ca21556afda99d45ac40288f23669f53062f77eaa174ba8c60bdf2942204fd0e", }, "./adapters/drizzle/sqlite/libsql": { - count: 271, - sha256: "efb847db078349a0ed272928b0990da9fbea595f42b89a6e6372b862eb780d65", + count: 282, + sha256: "bc6b26a92e7f7a59f7c039d98ccbaa33295a5fde1e616b7c470dabdc66b4d423", }, "./adapters/drizzle/sqlite/local": { - count: 271, - sha256: "efb847db078349a0ed272928b0990da9fbea595f42b89a6e6372b862eb780d65", + count: 282, + sha256: "bc6b26a92e7f7a59f7c039d98ccbaa33295a5fde1e616b7c470dabdc66b4d423", }, "./backend": { - count: 18, - sha256: "febe6415eed00c5e97431d9a311d9a443cebd1015c2c053ac777d1bb245dbadd", + count: 26, + sha256: "3d657aa71ed47e43904143825eb9a60e957194664a5e4d821f7256b822b350e9", }, "./core": { - count: 72, - sha256: "bf73c4f71677d2b3ec2e36bfd37e9ede5c3f57377fc923f0df2eb1b500cfc84d", + count: 74, + sha256: "735bff147c58e240841403d054dcc94e30e1b4d447664febcf56ba4ae7fe1326", }, // ExtensionIndexWhere makes NullCheckOp reachable through this entrypoint. "./graph-extension": { - count: 17, - sha256: "4b4cedb3e4d62be38f1b9d82e55a7aeaca681847dd99285e13a524064e9c2ab3", + count: 22, + sha256: "275e9f2bdb2b75721e5be5275e18a8cf75209bf4207847cd1c403052286fab62", }, + // Paged identity history: `IdentityFacade.transitionsOf` now returns the + // named `IdentityTransitionHistory` instead of a bare array. The root + // entrypoint `.` already exports the type directly, so its debt is + // unchanged; the six entrypoints below reach it only transitively through + // `Store`'s identity facade and each gains exactly that one name + // (+1 apiece). Gate: no entrypoint's debt decreased, no seventh entrypoint + // moved, and the single added symbol at each of the six is + // `IdentityTransitionHistory`. + // Delta: `./graph-merge` 774→775, `./interchange` 759→760, + // `./postgres/pglite` 756→757, `./profiler` 761→762, `./provenance` + // 767→768, `./sqlite/local` 756→757. // MergePlanReadContext derives its read-only surface from the runtime method // lists: EDGE_TEMPORAL_READ_NAMES, IDENTITY_READ_NAMES, and NODE_READ_NAMES. // These three implementation constants are referenced, not public exports. "./graph-merge": { - count: 871, - sha256: "3d5867de3fe9f7cc1d84b523b4291c0c92d7ff13745841b0c1b88f2279a02bc6", + count: 968, + sha256: "50036d152983d10fe2ca169752639b1f90affc603a9b0afc77d1d93ee1ece911", }, "./indexes": { count: 46, sha256: "5a43d419097711d242c6208632e7e498374a5977eb10a7faba904b10e13f35cd", }, "./interchange": { - count: 855, - sha256: "96c5564c531a6697560fec8292ef8c34a6117d8d2b7cd6eed4b5e802e2304f70", + count: 953, + sha256: "d0f456a5c48220ab455306f6f9739883957a3cf9b29b8fe3f7ce4a0f60af8592", }, "./postgres/pglite": { - count: 861, - sha256: "8c0582c84a81ed44eb8913e3c262ab0783f454a9f8f702744ab9fa2c0624206a", + count: 959, + sha256: "24b5d07a1ca46dff5eb714ab0a8c8f89451d49ebd5f08e46343677671b33475e", }, "./profiler": { - count: 857, - sha256: "73c9f4414abacdac3f5dbecf620c5911b122e1bafb494ac1ec45089bca64c67f", + count: 955, + sha256: "a84ff2cf8f9d6d160755219ea23173a2fa0aed111d5aa585d94850d617e6c859", }, "./provenance": { - count: 870, - sha256: "f34b5201f7e07c2b87c9ac738e3935224c75b5464ef7dec0c112ee08d355779a", + count: 968, + sha256: "dd786c9144a7934c8e754ae0e820c4519aa50c98da45aec635f2a7bb4196d7d8", }, + // Identity transition log: `ensureSchema`'s inline `{ preloaded?: ... }` + // options type was extracted into the named (but non-exported) + // `EnsureSchemaPreloadedOptions` alias so `EnsureSchemaInternalOptions` + // could extend it for `ensureSchemaInternal` (the new export `store.ts` + // calls directly with `historyEnabled`, never re-exported from a public + // entry point). An anonymous inline type never registers as a forgotten + // export; a named non-exported one used in `ensureSchema`'s public + // signature does. +1, only on `./schema` — the sole entrypoint that + // names `ensureSchema`. "./schema": { - count: 290, - sha256: "c4030c73857791586eb0134a1947ed4a5c540176d83103437b9d7cbbc2d62a16", + count: 307, + sha256: "cb84675941b4f1bc7c184d783f28f56d18c5d2e7d0fa36fbbd57fd470a047557", }, "./sqlite/local": { - count: 861, - sha256: "8c0582c84a81ed44eb8913e3c262ab0783f454a9f8f702744ab9fa2c0624206a", + count: 959, + sha256: "24b5d07a1ca46dff5eb714ab0a8c8f89451d49ebd5f08e46343677671b33475e", }, }; diff --git a/packages/typegraph/scripts/build.ts b/packages/typegraph/scripts/build.ts new file mode 100644 index 000000000..f382acac3 --- /dev/null +++ b/packages/typegraph/scripts/build.ts @@ -0,0 +1,199 @@ +/** + * The package build: emit declarations with `tsc`, then run tsup. + * + * tsup bundles the JavaScript from `src` and rolls each entrypoint's `.d.ts` + * out of the declaration tree this script emits first (see + * `scripts/declaration-emit.ts` for why the rollup no longer derives + * declarations from source itself). The two stages therefore have a hard + * order, which is what this script owns. + * + * `--watch` keeps both stages live with ONE compile: `tsc --watch` owns the + * declaration tree, and tsup's rollup watches the files it writes, so a source + * edit refreshes the bundles and the declarations together. A watch session + * survives a type error the way `tsc --watch` does — it keeps watching and + * recovers on the next save — so the one-shot compile, whose non-zero exit is + * fatal, runs only for a non-watch build. Every argument other than `--watch` + * is forwarded to tsup unchanged. + */ +import { + type ChildProcess, + spawn, + type SpawnOptions, +} from "node:child_process"; +import { access, rm } from "node:fs/promises"; +import path from "node:path"; + +import { + assertNoDeclarationOverride, + DECLARATION_EMIT_DIR, + DECLARATION_ENTRY_FILES, + DECLARATION_HEAP_MB, + DECLARATION_TSCONFIG, + PACKAGE_ROOT, +} from "./declaration-emit"; + +const WATCH_FLAG = "--watch"; + +const TSC_BIN = path.join(PACKAGE_ROOT, "node_modules/typescript/lib/tsc.js"); +const TSUP_BIN = path.join( + PACKAGE_ROOT, + "node_modules/tsup/dist/cli-default.js", +); +const HEAP_ARGUMENT = `--max-old-space-size=${DECLARATION_HEAP_MB}`; + +/** How long a watch session waits for `tsc --watch`'s first declaration emit. */ +const FIRST_EMIT_TIMEOUT_MS = 300_000; +const FIRST_EMIT_POLL_MS = 100; + +const SPAWN_OPTIONS: SpawnOptions = { + cwd: PACKAGE_ROOT, + stdio: "inherit", +}; + +const children = new Set(); +let shuttingDown = false; + +function spawnNode(scriptPath: string, args: readonly string[]): ChildProcess { + const child = spawn( + process.execPath, + [HEAP_ARGUMENT, scriptPath, ...args], + SPAWN_OPTIONS, + ); + children.add(child); + child.on("close", () => children.delete(child)); + return child; +} + +function killChildren(): void { + shuttingDown = true; + for (const child of children) child.kill("SIGTERM"); + children.clear(); +} + +function whenClosed(child: ChildProcess): Promise { + return new Promise((resolve, reject) => { + child.on("error", (error) => { + reject(error); + }); + child.on("close", (exitCode) => { + resolve(exitCode ?? 1); + }); + }); +} + +async function runToCompletion( + label: string, + scriptPath: string, + args: readonly string[], +): Promise { + const code = await whenClosed(spawnNode(scriptPath, args)); + if (code !== 0) { + throw new Error(`${label} failed with exit code ${code}.`); + } +} + +function declarationEmitArguments(watch: boolean): readonly string[] { + return [ + "--project", + DECLARATION_TSCONFIG, + ...(watch ? ["--watch", "--preserveWatchOutput"] : []), + ]; +} + +/** + * Rejects if the declaration watcher ever closes. A watcher that exits has + * stopped refreshing the tree tsup rolls, so the session would silently serve + * declarations frozen at the last emit; fail the pipeline instead. Stays + * pending through a deliberate shutdown, where the exit is expected. + */ +function superviseWatcher(child: ChildProcess): Promise { + return new Promise((_resolve, reject) => { + child.on("error", (error) => { + reject(error); + }); + child.on("close", (code) => { + // A shutdown killed it on purpose: stay pending and let the exit path run. + if (shuttingDown) return; + reject( + new Error( + `Declaration watcher (tsc --watch) closed with exit code ${code ?? 1}; declarations would stop refreshing.`, + ), + ); + }); + }); +} + +async function fileExists(filePath: string): Promise { + try { + await access(filePath); + return true; + } catch { + return false; + } +} + +/** + * Resolves once every declaration entry tsup rolls exists, so the rollup's + * first pass can resolve its inputs. The emit directory is removed before the + * watcher starts, so presence means this session's watcher wrote them — and + * `tsc --watch` writes declarations even for a source tree with type errors, + * which is what lets a watch session start on one and recover. + */ +async function waitForFirstDeclarationEmit(): Promise { + const deadline = Date.now() + FIRST_EMIT_TIMEOUT_MS; + for (;;) { + const present = await Promise.all( + DECLARATION_ENTRY_FILES.map((filePath) => fileExists(filePath)), + ); + if (present.every(Boolean)) return; + if (Date.now() > deadline) { + throw new Error( + `Declaration watcher wrote no complete declaration tree within ${FIRST_EMIT_TIMEOUT_MS / 1000}s.`, + ); + } + await new Promise((resolve) => setTimeout(resolve, FIRST_EMIT_POLL_MS)); + } +} + +async function main(): Promise { + const args = process.argv.slice(2); + assertNoDeclarationOverride(args); + const watch = args.includes(WATCH_FLAG); + + await rm(DECLARATION_EMIT_DIR, { force: true, recursive: true }); + + if (!watch) { + await runToCompletion( + "Declaration emit (tsc)", + TSC_BIN, + declarationEmitArguments(false), + ); + await runToCompletion("Bundle (tsup)", TSUP_BIN, args); + return; + } + + const watcher = spawnNode(TSC_BIN, declarationEmitArguments(true)); + const watcherClosed = superviseWatcher(watcher); + await Promise.race([waitForFirstDeclarationEmit(), watcherClosed]); + await Promise.race([ + runToCompletion("Bundle (tsup)", TSUP_BIN, args), + watcherClosed, + ]); +} + +for (const signal of ["SIGINT", "SIGTERM"] as const) { + process.on(signal, () => { + killChildren(); + process.exit(0); + }); +} + +try { + await main(); +} catch (error) { + console.error(error instanceof Error ? error.message : error); + killChildren(); + process.exit(1); +} finally { + killChildren(); +} diff --git a/packages/typegraph/scripts/bundle-member-access-scan.ts b/packages/typegraph/scripts/bundle-member-access-scan.ts index 609070059..d17f71d03 100644 --- a/packages/typegraph/scripts/bundle-member-access-scan.ts +++ b/packages/typegraph/scripts/bundle-member-access-scan.ts @@ -427,9 +427,9 @@ function deriveOptionalGraphBackendMembers( .getPropertiesOfType(graphBackendType) .filter((property) => (property.flags & ts.SymbolFlags.Optional) !== 0) .map((property) => property.name); - if (optionalNames.length !== 107) { + if (optionalNames.length !== 108) { throw new Error( - `Expected exactly 107 optional GraphBackend members (the scan's own precondition); found ${optionalNames.length}. GraphBackend's optional surface has changed — re-derive every partition constant before trusting this scan.`, + `Expected exactly 108 optional GraphBackend members (the scan's own precondition); found ${optionalNames.length}. GraphBackend's optional surface has changed — re-derive every partition constant before trusting this scan.`, ); } return new Set(optionalNames); diff --git a/packages/typegraph/scripts/declaration-emit.ts b/packages/typegraph/scripts/declaration-emit.ts new file mode 100644 index 000000000..c7c10bc04 --- /dev/null +++ b/packages/typegraph/scripts/declaration-emit.ts @@ -0,0 +1,82 @@ +/** + * The declaration-pipeline facts `scripts/build.ts` runs on. + * + * The package's declarations are emitted ONCE by `tsc` into the directory + * `tsup.config.ts` names, and tsup's declaration rollup then bundles the + * per-entrypoint `.d.ts` out of that emit instead of re-deriving declarations + * from source inside the rollup graph. Rolling from source made + * rollup-plugin-dts build a live TypeScript program and call `program.emit()` + * once per module, which peaked near 10 GiB of resident memory regardless of + * which entrypoint was built; the same rollup over an existing `.d.ts` tree + * needs no program at all — a declarations-only tsup run over the emitted tree + * measured a 0.46 GiB peak for the whole process, rollup worker included — and + * emits semantically identical declarations. The one textual difference is + * member ORDER inside two inferred enum literals on the `interchange` + * entrypoint, which whole-program emit orders differently from the per-file + * emit the rollup used to drive. + * + * The emit directory and the entry mapping belong to `tsup.config.ts`, which + * the rollup reads directly; this module resolves them against the package root + * and adds the facts only the pipeline needs. + */ +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { + DECLARATION_EMIT_DIR as RELATIVE_DECLARATION_EMIT_DIR, + DECLARATION_ENTRY, +} from "../tsup.config"; + +export const PACKAGE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "..", +); + +/** The emit directory `tsup.config.ts` names, resolved for file-system use. */ +export const DECLARATION_EMIT_DIR = path.resolve( + PACKAGE_ROOT, + RELATIVE_DECLARATION_EMIT_DIR, +); + +/** + * Every declaration file the rollup takes as an entry, resolved for + * file-system use. A watch session waits for exactly these before starting + * tsup, so the files it waits on are the files the rollup will open. + */ +export const DECLARATION_ENTRY_FILES: readonly string[] = Object.values( + DECLARATION_ENTRY, +).map((entryPath) => path.resolve(PACKAGE_ROOT, entryPath)); + +/** The `tsc` project that emits the declaration tree. */ +export const DECLARATION_TSCONFIG = path.join( + PACKAGE_ROOT, + "tsconfig.declarations.json", +); + +/** + * The heap ceiling, in MiB, that both stages of the declaration pipeline run + * under. The pipeline script passes it to the `tsc` and tsup child processes + * explicitly, so a local `pnpm build` never depends on an ambient + * `NODE_OPTIONS`; CI's workflow-wide ceiling covers the remaining steps and + * must not fall below this one. + */ +export const DECLARATION_HEAP_MB = 8192; + +/** + * tsup's declaration CLI flags (`--dts`, `--dts-only`, `--dts-resolve`, + * `--experimental-dts`) REPLACE the config's `dts` option, which would discard + * the emitted declaration roots and silently re-root the rollup at `src` — the + * ~10 GiB path this pipeline exists to avoid, which then dies on the heap + * ceiling. Refuse them by name instead of forwarding them. + */ +export function assertNoDeclarationOverride(args: readonly string[]): void { + const DECLARATION_FLAG_PREFIXES = ["--dts", "--experimental-dts"] as const; + const override = args.find((argument) => + DECLARATION_FLAG_PREFIXES.some((prefix) => argument.startsWith(prefix)), + ); + if (override !== undefined) { + throw new Error( + `${override} cannot be passed to the build: it replaces the declaration roots this pipeline emits, re-rooting the declaration rollup at "src". Edit tsup.config.ts's declaration entry instead.`, + ); + } +} diff --git a/packages/typegraph/scripts/drizzle-claim-inventory.ts b/packages/typegraph/scripts/drizzle-claim-inventory.ts index c9d719f98..1ac640399 100644 --- a/packages/typegraph/scripts/drizzle-claim-inventory.ts +++ b/packages/typegraph/scripts/drizzle-claim-inventory.ts @@ -46,10 +46,16 @@ export const CLAIM_PHRASE_PATTERN = new RegExp( "i", ); -/** Directories the scan never descends into. */ +/** + * Directories the scan never descends into — build outputs and tool state, all + * of them gitignored. `.declaration-emit` is the declaration tree + * `scripts/build.ts` emits for the declaration rollup: it mirrors `src`, so + * every claim site in a source doc comment appears there a second time. + */ export const EXCLUDED_DIRECTORY_NAMES: readonly string[] = [ "node_modules", "dist", + ".declaration-emit", ".git", ".turbo", "coverage", diff --git a/packages/typegraph/scripts/test-types.ts b/packages/typegraph/scripts/test-types.ts index 99083ac74..fbc15723f 100644 --- a/packages/typegraph/scripts/test-types.ts +++ b/packages/typegraph/scripts/test-types.ts @@ -179,7 +179,7 @@ async function runConsumerTypeSmokeTest( const tarballPath = path.join(temporaryDir, "typegraph.tgz"); const fixtureDir = path.join(packageDir, "type-smoke"); - const zodVersion = packageJson.devDependencies?.["zod"] ?? "^4.0.0"; + const zodVersion = packageJson.devDependencies?.["zod"] ?? "^4.5.0"; const drizzleVersion = packageJson.devDependencies?.["drizzle-orm"] ?? ">=0.35.0"; diff --git a/packages/typegraph/src/backend/capabilities/bundle-registry.ts b/packages/typegraph/src/backend/capabilities/bundle-registry.ts index ef1ef7fee..932a429a3 100644 --- a/packages/typegraph/src/backend/capabilities/bundle-registry.ts +++ b/packages/typegraph/src/backend/capabilities/bundle-registry.ts @@ -817,7 +817,7 @@ export const CAPABILITY_BUNDLES = [ export type CapabilityBundleId = (typeof CAPABILITY_BUNDLES)[number]["id"]; // --------------------------------------------------------------------------- -// UNBUNDLED_OPTIONAL_MEMBERS — the other 90, both kinds classified (I5, I6). +// UNBUNDLED_OPTIONAL_MEMBERS — the other 92, both kinds classified (I5, I6). // --------------------------------------------------------------------------- /** No bundle should ever own this member; the reason is the fact to preserve. */ @@ -897,8 +897,8 @@ export const UNBUNDLED_OPTIONAL_MEMBERS = { adoptBaseSchema: { kind: "reasoned", reason: - "Privileged deployment-wide physical-schema adoption. Bundled backends implement the versioned marker lifecycle; custom backends may omit it when they provision their own base relations.", - accesses: 2, + "Privileged deployment-wide physical-schema adoption. Bundled backends implement the versioned marker lifecycle; custom backends may omit it when they provision their own base relations. Reopening a durable PostgreSQL working copy whose allocation predates the current base schema checks for it and adopts the allocation in place.", + accesses: 4, }, assertBaseSchemaCurrent: { kind: "reasoned", @@ -939,13 +939,13 @@ export const UNBUNDLED_OPTIONAL_MEMBERS = { tableNames: { kind: "reasoned", reason: - "Physical names read by the compiler and schema-checked reads. The optional schema-version binding is required only by checked reads; its absence refuses that operation.", - // 28, including the namespace fork's and managed PostgreSQL copy's backend table-name probes and the graph storage inventory's one resolution of the graph-relation names. Previously 25, not the grep tier's 23: store/store.ts holds two `backend.tableNames` + "Physical names read by the compiler and schema-checked reads. The optional schema-version binding is required only by checked reads; its absence refuses that operation. Edge acyclicity adds three readers; the schema-commit tightening probe reads the proposed acyclic and composition relations through one of them.", + // 31, including the namespace fork's and managed PostgreSQL copy's backend table-name probes, the graph storage inventory's one resolution of the graph-relation names, and three acyclicity readers. Previously 25, not the grep tier's 23: store/store.ts holds two `backend.tableNames` // accesses on one physical line, which a line-keyed grep counts once but // the type-aware scanner counts as two access nodes (§Baselines). The // forked working-copy strategy and managed PostgreSQL copy read the // connected backend's names to fence physical target bindings. - accesses: 28, + accesses: 31, }, fenceSql: { kind: "reasoned", @@ -965,6 +965,12 @@ export const UNBUNDLED_OPTIONAL_MEMBERS = { "Same schema-version write-fence family as commitSchemaVersionIfKindsEmpty.", accesses: 4, }, + setActiveVersionWithPreflight: { + kind: "reasoned", + reason: + "Same schema-version write-fence family as commitSchemaVersionWithPreflight; rollbackSchema refuses with the tightening capability error when it is absent, and a fixed-schema working copy refuses it when present.", + accesses: 2, + }, lockSchemaVersionForWrite: { kind: "reasoned", reason: @@ -1261,7 +1267,13 @@ export const UNBUNDLED_OPTIONAL_MEMBERS = { bundle: "heterogeneousEndpointSetRead", // Import prefetches existing endpoint pairs through the same bulk-read // capability rather than issuing one probe per incoming edge. - ceiling: 5, + // `store/operations/composition-cascade.ts`'s `planCompositionCascade` + // reads one round's whole-side composition edges the same way + // `findConnectedEdgesForNodeBatch` (`node-operations.ts`) already does. + // `composition-create.ts`'s `readCompositionUnattachedParts` reads one + // page's attachment candidates per orientation instead of one connected-edges + // read per row. + ceiling: 7, }, fulltextSearch: { kind: "deferred", diff --git a/packages/typegraph/src/backend/capabilities/recorded-time-ownership.ts b/packages/typegraph/src/backend/capabilities/recorded-time-ownership.ts index aa8023a38..c5fd604d0 100644 --- a/packages/typegraph/src/backend/capabilities/recorded-time-ownership.ts +++ b/packages/typegraph/src/backend/capabilities/recorded-time-ownership.ts @@ -32,6 +32,23 @@ export function resolveRecordedTimeOwnership( : "engine-native"; } +/** + * Whether TypeGraph itself captures recorded history for a store over + * `backend` that requested `history`: only under TypeGraph-owned recorded + * time. Engine-native `history: true` is honored by the engine and gets none + * of TypeGraph's capture. Shared by Store construction and the schema commits + * a store open drives before the Store exists, so both decide it one way. + */ +export function capturesTypeGraphRecordedHistory( + historyRequested: boolean, + backend: Pick, +): boolean { + return ( + historyRequested && + resolveRecordedTimeOwnership(backend) === "typegraph-relations" + ); +} + /** * THE one check for "is this recorded read reached under engine-native * ownership," for the callers that hold a read binding rather than a @@ -80,13 +97,52 @@ export function assertRecordedInstantOwnershipMatch( const parts = parseRecordedInstant(instant, surface); const expectedKind = ownership === "engine-native" ? "engine" : "typegraph"; if (parts.kind === expectedKind) return; - throw new ConfigurationError( - `${surface} requires a recorded instant minted under this store's own recorded-time ownership ("${ownership}"), but got a "${parts.kind}"-form instant.`, + throw recordedInstantOwnershipMismatch(ownership, parts.kind, surface); +} + +/** + * The TypeGraph-owned numeric revision behind `instant`, for the surfaces + * whose storage IS a TypeGraph relation keyed by that number — today the + * identity transition log's replay window and its retention watermark. Those + * surfaces are reachable only from a capture-enabled (`historyEnabled`) + * context, which exists only under `"typegraph-relations"` ownership, so an + * `e1:` anchor there is a caller error rather than an unreachable state, and + * it is refused through {@link recordedInstantOwnershipMismatch} — the same + * typed refusal {@link assertRecordedInstantOwnershipMatch} raises, so the + * two entry points cannot drift into two different mismatch errors. They + * differ only in what they hand back: the assertion returns nothing, this + * returns the narrowed revision its callers came for. + */ +export function requireTypeGraphRecordedRevision( + instant: string, + surface: string, +): number { + const parts = parseRecordedInstant(instant, surface); + if (parts.kind === "typegraph") return parts.revision; + throw recordedInstantOwnershipMismatch( + "typegraph-relations", + parts.kind, + surface, + ); +} + +/** + * The one refusal for "this recorded instant was minted under the other + * ownership form", shared by {@link assertRecordedInstantOwnershipMatch} and + * {@link requireTypeGraphRecordedRevision}. + */ +function recordedInstantOwnershipMismatch( + ownership: RecordedTimeOwnership, + instantKind: "typegraph" | "engine", + surface: string, +): ConfigurationError { + return new ConfigurationError( + `${surface} requires a recorded instant minted under this store's own recorded-time ownership ("${ownership}"), but got a "${instantKind}"-form instant.`, { code: "RECORDED_INSTANT_OWNERSHIP_MISMATCH", surface, ownership, - instantKind: parts.kind, + instantKind, }, { suggestion: diff --git a/packages/typegraph/src/backend/capabilities/write-fence.ts b/packages/typegraph/src/backend/capabilities/write-fence.ts index 2850a8d0b..12c47b410 100644 --- a/packages/typegraph/src/backend/capabilities/write-fence.ts +++ b/packages/typegraph/src/backend/capabilities/write-fence.ts @@ -11,6 +11,7 @@ import { type SqlTableNames } from "../../query/compiler/schema"; import { type SqlDialect } from "../../query/dialect/types"; import { sql, type SqlFragment } from "../../query/sql-fragment"; import { requireDefined } from "../../utils/presence"; +import { FENCE_ISOLATION_COLUMN } from "../command-contract"; import { type BackendCapabilities } from "../types"; /** @@ -102,6 +103,32 @@ export type FenceStatements = FenceSql & namespace: string, key: string | number, ) => SqlFragment; + /** + * The acquisition of a fence holder that goes on to make a decision + * nothing but the fence protects — on the per-graph write fence, an + * edge-acyclicity probe, a composition whole's parts closure, a match-key + * convergence. One statement that takes the keyed exclusion, reads the + * session's isolation-level fact, AND advances the key's row in the + * fences relation (reported as `generation`). + * + * The row write is what makes such a decision hold for a session at a + * snapshot isolation: a lock cannot refresh a snapshot taken before the + * wait, but a session whose snapshot predates another decider's commit + * cannot update a row that decider advanced, and fails on it with a + * serialization failure. Only deciders write the row; a holder that + * decides nothing takes + * {@link FenceStatements.acquireKeyedWithIsolation} and leaves it alone. + * Re-issuing this statement in a transaction that already holds the + * exclusion is how a holder that turns out to decide advances the row. + * + * Under `row` this is the acquisition itself. Under `advisory` the row + * write is taken after the lock, in the same statement, and the member is + * ABSENT when the target names no fences relation (`tableNames.fences`): + * such a fence cannot advance a row, so its holder's evidence stays "no + * fence row" and a snapshot isolation is refused on it. + */ + acquireKeyedSnapshotFence?: + ((namespace: string, key: string | number) => SqlFragment) | undefined; /** * The bare session isolation-level read, with no acquisition. Yields no * row when the target supplies no `isolationFactExpression` — the same @@ -128,7 +155,7 @@ function advisoryAcquireKeyedWithIsolationStatement( return sql` SELECT ${advisoryLockExpression(namespace, key)}, - ${isolationFactExpression()} AS transaction_isolation + ${isolationFactExpression()} AS ${sql.raw(FENCE_ISOLATION_COLUMN)} `; } @@ -143,32 +170,105 @@ function fenceRowKey(namespace: string, key: string | number): string { } /** - * The portable acquisition statement every `row`-mechanism keyed site - * shares: an UPSERT that always advances the row's own stored generation - * (never the literal `1` this statement inserts), so two acquirers of the - * same key always observe a strictly increasing sequence regardless of - * which one the engine admits first. `isolationFactExpression`, when - * supplied, rides the same `RETURNING` clause the generation does, so the - * fact is read on the exact statement that took the row. + * THE one spelling of a fence-row write: an UPSERT that always advances the + * row's own stored generation (never the literal `1` a first acquirer + * inserts), so two acquirers of the same key always observe a strictly + * increasing sequence regardless of which one the engine admits first. + * `proposedRow` is the row source — a `VALUES` list for a standalone + * acquisition, a `SELECT` over a preceding lock for one that must follow it. + * `isolationFactExpression`, when supplied, rides the same `RETURNING` clause + * the generation does, so the fact is read on the exact statement that took + * the row. */ -function fenceRowAcquireStatement( +function fenceRowUpsertStatement( fencesTable: SqlFragment, - namespace: string, - key: string | number, + proposedRow: SqlFragment, isolationFactExpression?: FenceSql["isolationFactExpression"], ): SqlFragment { const isolationColumn = isolationFactExpression === undefined ? sql`` - : sql`, ${isolationFactExpression()} AS transaction_isolation`; + : sql`, ${isolationFactExpression()} AS ${sql.raw(FENCE_ISOLATION_COLUMN)}`; return sql` INSERT INTO ${fencesTable} (key, generation) - VALUES (${fenceRowKey(namespace, key)}, 1) + ${proposedRow} ON CONFLICT (key) DO UPDATE SET generation = ${fencesTable}.generation + 1 RETURNING generation${isolationColumn} `; } +/** + * The portable acquisition statement every `row`-mechanism keyed site + * shares: {@link fenceRowUpsertStatement} over the key's own literal row. + */ +function fenceRowAcquireStatement( + fencesTable: SqlFragment, + namespace: string, + key: string | number, + isolationFactExpression?: FenceSql["isolationFactExpression"], +): SqlFragment { + return fenceRowUpsertStatement( + fencesTable, + sql`VALUES (${fenceRowKey(namespace, key)}, 1)`, + isolationFactExpression, + ); +} + +/** + * The fence-row write of an acquisition that takes another exclusion FIRST: + * the row is proposed once per row of `acquired`, so it is written only after + * — and only if — that relation produced its lock. Used by both statements + * that pair an advisory lock with the fence row: the standalone + * {@link FenceStatements.acquireKeyedSnapshotFence} below and PostgreSQL's + * fused schema + graph-write fence (`postgres-schema-write-fence.ts`), which + * embeds this as a data-modifying CTE. One owner, so the row key and the + * generation rule cannot differ between the two. + * + * The `WHERE TRUE` keeps `ON CONFLICT` from being parsed as a join + * constraint of the `SELECT` on an engine with that grammar ambiguity. + */ +export function fenceRowAdvanceAfter( + fencesTable: SqlFragment, + namespace: string, + key: string | number, + acquired: SqlFragment, + isolationFactExpression?: FenceSql["isolationFactExpression"], +): SqlFragment { + return fenceRowUpsertStatement( + fencesTable, + sql`SELECT ${fenceRowKey(namespace, key)}, 1 FROM ${acquired} WHERE TRUE`, + isolationFactExpression, + ); +} + +const ADVISORY_FENCE_LOCK_RELATION = sql.raw('"fence_lock"'); + +/** + * The advisory form of {@link FenceStatements.acquireKeyedSnapshotFence}: the + * lock is a `MATERIALIZED` CTE the row write reads from, so the engine must + * hold the lock before it proposes the row. + */ +function advisoryAcquireKeyedSnapshotFenceStatement( + advisoryLockExpression: NonNullable, + isolationFactExpression: NonNullable, + fencesTable: SqlFragment, + namespace: string, + key: string | number, +): SqlFragment { + return sql` + WITH ${ADVISORY_FENCE_LOCK_RELATION} AS MATERIALIZED ( + SELECT ${advisoryLockExpression(namespace, key)} AS "lock_token" + ) + ${fenceRowAdvanceAfter( + fencesTable, + namespace, + key, + ADVISORY_FENCE_LOCK_RELATION, + isolationFactExpression, + )} + `; +} + /** * The bare session isolation-level read shared by both mechanisms: wraps * `isolationFactExpression` in a standalone `SELECT` when the target @@ -181,8 +281,8 @@ function isolationFactStatement( isolationFactExpression?: FenceSql["isolationFactExpression"], ): SqlFragment { return isolationFactExpression === undefined ? - sql`SELECT NULL AS transaction_isolation WHERE 1 = 0` - : sql`SELECT ${isolationFactExpression()} AS transaction_isolation`; + sql`SELECT NULL AS ${sql.raw(FENCE_ISOLATION_COLUMN)} WHERE 1 = 0` + : sql`SELECT ${isolationFactExpression()} AS ${sql.raw(FENCE_ISOLATION_COLUMN)}`; } /** @@ -201,9 +301,16 @@ function isolationFactStatement( * renders identically under either derivation — so the default costs them * nothing. */ -type FenceStatementsStyle = - | Readonly<{ mechanism: "advisory" }> - | Readonly<{ mechanism: "row"; fencesTableName?: string | undefined }>; +type FenceStatementsStyle = Readonly<{ + mechanism: "advisory" | "row"; + /** + * The physical name of the fences relation. Required to acquire under + * `row`; under `advisory` it is what lets + * {@link FenceStatements.acquireKeyedSnapshotFence} advance the fence row + * behind the lock, and its absence leaves that member undefined. + */ + fencesTableName?: string | undefined; +}>; const ADVISORY_FENCE_STATEMENTS_STYLE: FenceStatementsStyle = { mechanism: "advisory", @@ -225,6 +332,7 @@ export function resolveFenceStatements( style: FenceStatementsStyle = ADVISORY_FENCE_STATEMENTS_STYLE, ): FenceStatements { const { isolationFactExpression } = fenceSql; + const { fencesTableName } = style; if (style.mechanism === "advisory") { // Resolved lazily, inside the two acquisition closures below, rather // than eagerly here: `guards.ts`'s session-fact read calls this with the @@ -267,6 +375,17 @@ export function resolveFenceStatements( namespace, key, ), + acquireKeyedSnapshotFence: + fencesTableName === undefined ? undefined : ( + (namespace: string, key: string | number) => + advisoryAcquireKeyedSnapshotFenceStatement( + requiredAdvisoryLockExpression(), + requiredIsolationFactExpression(), + sql.identifier(fencesTableName), + namespace, + key, + ) + ), isolationFact: () => isolationFactStatement(isolationFactExpression), }; } @@ -278,7 +397,6 @@ export function resolveFenceStatements( // therefore fires the first time one of those two is actually CALLED, not // when this function returns — `WriteFenceTarget.tableNames`' own doc // names this same deferral. - const { fencesTableName } = style; function requiredFencesTable(): SqlFragment { if (fencesTableName === undefined) { throw new ConfigurationError( @@ -294,17 +412,23 @@ export function resolveFenceStatements( } return sql.identifier(fencesTableName); } + const acquireKeyedWithIsolation = ( + namespace: string, + key: string | number, + ): SqlFragment => + fenceRowAcquireStatement( + requiredFencesTable(), + namespace, + key, + isolationFactExpression, + ); return { ...fenceSql, acquireKeyed: (namespace: string, key: string | number) => fenceRowAcquireStatement(requiredFencesTable(), namespace, key), - acquireKeyedWithIsolation: (namespace: string, key: string | number) => - fenceRowAcquireStatement( - requiredFencesTable(), - namespace, - key, - isolationFactExpression, - ), + acquireKeyedWithIsolation, + // A `row` acquisition IS the fence-row write. + acquireKeyedSnapshotFence: acquireKeyedWithIsolation, isolationFact: () => isolationFactStatement(isolationFactExpression), }; } @@ -315,7 +439,11 @@ export function resolveFenceStatements( * * `mechanism` is the exclusion primitive: `"advisory"` is a keyed * `pg_advisory_xact_lock`-style lock a caller takes explicitly (and needs - * `fenceSql` to spell); `"row"` is a keyed exclusion spelled by TypeGraph + * `fenceSql` to spell) — a holder of the per-graph write fence that makes a + * decision the fence alone protects additionally advances the key's row in + * the fences relation behind that lock when the target names one + * (`tableNames.fences`), which is what admits a snapshot isolation to that + * decision; `"row"` is a keyed exclusion spelled by TypeGraph * itself against a never-dropped relation of fence rows, for an engine with * no advisory-lock primitive; `"engine-serialized"` is the engine's own * single writer slot (SQLite); `"caller-serialized"` is a promise the @@ -1030,6 +1158,10 @@ function planFromWriteFenceDeclaration( target.fenceSql, "resolveWriteFencePlan: fenceSql was validated present above", ), + { + mechanism: "advisory", + fencesTableName: target.tableNames?.fences, + }, ), }; } diff --git a/packages/typegraph/src/backend/command-contract.ts b/packages/typegraph/src/backend/command-contract.ts index ddfc97f8b..0d26c3c1a 100644 --- a/packages/typegraph/src/backend/command-contract.ts +++ b/packages/typegraph/src/backend/command-contract.ts @@ -11,6 +11,7 @@ import type { GraphCommandPort, GraphCommandResult, GraphCommandSession, + GraphWriteFenceAcquisition, NodeCreateCommand, NodeCreateCommandResult, } from "./types"; @@ -19,7 +20,7 @@ const graphCommandCoordinationBindings = new WeakMap< object, Readonly<{ graphId: string; - isolation: GraphCommandIsolation; + acquisition: GraphWriteFenceAcquisition; sessionIdentity: object; }> >(); @@ -30,6 +31,7 @@ export type { GraphCommandExecutionContext, GraphCommandIsolation, GraphCommandSession, + GraphWriteFenceAcquisition, } from "./types"; /** @@ -59,19 +61,21 @@ function graphCommandPortSessionIdentity(port: GraphCommandPort): object { } /** - * Internal evidence mint used only after a graph's advisory lock has been + * Internal evidence mint used only after a graph's write fence has been * acquired on the command port's transaction. The binding prevents a real - * lock from transaction A (or graph A) authorizing a command on B. + * acquisition from transaction A (or graph A) authorizing a command on B, and + * carries what that acquisition observed and did so every later decision + * reads the facts of the statement that earned them. */ export function mintGraphCommandCoordination( port: GraphCommandPort, graphId: string, - isolation: GraphCommandIsolation, + acquisition: GraphWriteFenceAcquisition, ): GraphCommandCoordination { const coordination = Object.freeze({}) as GraphCommandCoordination; graphCommandCoordinationBindings.set(coordination, { graphId, - isolation, + acquisition, sessionIdentity: graphCommandPortSessionIdentity(port), }); return coordination; @@ -106,6 +110,35 @@ export function normalizeGraphCommandIsolation( } } +/** The column a fence acquisition reports its session isolation under. */ +export const FENCE_ISOLATION_COLUMN = "transaction_isolation"; +/** The column a fence acquisition reports its advanced fence row under. */ +export const FENCE_ROW_GENERATION_COLUMN = "generation"; + +/** + * THE one reader of a graph-write fence acquisition's result row, shared by + * the portable acquisition and the fused schema + graph fence so neither can + * read the session's isolation or the fence-row evidence differently. A + * missing row or column is the absence of the fact, never a default: an + * unreported isolation is `unknown`, and an unreported generation means the + * statement advanced no fence row. + */ +export function readGraphWriteFenceAcquisition( + row: Readonly> | undefined, +): GraphWriteFenceAcquisition { + const generation = row?.[FENCE_ROW_GENERATION_COLUMN]; + return { + isolation: normalizeGraphCommandIsolation(row?.[FENCE_ISOLATION_COLUMN]), + fenceRow: + generation === undefined || generation === null ? "none" : "advanced", + }; +} + +const UNOBSERVED_FENCE_ACQUISITION: GraphWriteFenceAcquisition = { + isolation: "unknown", + fenceRow: "none", +}; + function boundGraphCommandCoordination( port: GraphCommandPort, coordination: GraphCommandCoordination, @@ -120,45 +153,165 @@ function boundGraphCommandCoordination( : undefined; } -/** Effective isolation of coordination earned by this graph and transaction. */ -export function graphCommandCoordinationIsolation( +/** + * What the acquisition behind `coordination` observed and did, when it was + * earned on this graph and transaction; an unobserved acquisition otherwise — + * coordination minted for another graph or session is evidence of nothing. + */ +export function graphCommandCoordinationAcquisition( port: GraphCommandPort, - graphId: string, coordination: GraphCommandCoordination, -): GraphCommandIsolation { + graphId: string, +): GraphWriteFenceAcquisition { return ( - boundGraphCommandCoordination(port, coordination, graphId)?.isolation ?? - "unknown" + boundGraphCommandCoordination(port, coordination, graphId)?.acquisition ?? + UNOBSERVED_FENCE_ACQUISITION + ); +} + +/** + * Whether a session that acquired the per-graph write fence this way reads + * everything committed by EVERY earlier holder of the fence, whatever that + * holder did under it. + * + * Only an isolation that takes a fresh snapshot per statement does. The fence + * row is advanced by the holders that make a lock-only decision, not by every + * holder, so advancing it proves nothing about a holder that only captured + * history or advanced a revision: a snapshot isolation is never admitted + * here. A stricter question than {@link observesPostFenceCommits}, for a + * holder whose reads span the whole graph rather than one lock-only decision — + * a merge plan applied inside a caller's transaction, or a PostgreSQL schema + * write under the advisory schema fence, whose earlier holders lock the + * schema row `FOR SHARE` and advance nothing. + */ +export function observesEveryEarlierFenceHolder( + acquisition: GraphWriteFenceAcquisition, +): boolean { + return acquisition.isolation === "read_committed"; +} + +/** + * Whether a session that acquired the per-graph write fence this way reads + * every lock-only decision committed by the holders that had the fence before + * it. + * + * `read_committed` takes a fresh snapshot per statement, so every statement + * after the acquisition sees what committed while it waited. A snapshot + * isolation (`repeatable_read`, `serializable`) keeps the snapshot it took at + * its first statement, however long it then waits — and a lock cannot change + * that. PostgreSQL's serialization-failure detection does not help either: it + * arbitrates serializable transactions against each other only, so a + * serializable session and a read-committed writer can both commit. What does + * hold at those levels is first-updater-wins on a row both sessions write: + * every holder that makes a lock-only decision advances the graph's fence row + * (`fenceLockOnlyDecision`), so an acquisition that advanced it succeeds only + * when no such holder committed after this session's snapshot, and fails with + * a serialization failure otherwise. So a snapshot isolation observes the + * earlier deciders exactly when its own acquisition advanced the fence row. + * Everything else — including `unknown` — fails closed. + * + * THE one predicate every "must observe the earlier deciders" check consults: + * match-key convergence ({@link assertGraphCommandConvergenceIsolation}) and + * the lock-only reads of {@link assertFencedSnapshotIsFresh} both need "did + * this session's snapshot start after the last decision under the fence", and + * a second inline spelling of the isolation set is exactly the copy that + * drifts. A holder that must observe EVERY earlier holder asks + * {@link observesEveryEarlierFenceHolder} instead. + */ +export function observesPostFenceCommits( + acquisition: GraphWriteFenceAcquisition, +): boolean { + switch (acquisition.isolation) { + case "read_committed": { + return true; + } + case "repeatable_read": + case "serializable": { + return acquisition.fenceRow === "advanced"; + } + case "unknown": { + return false; + } + default: { + return acquisition.isolation satisfies never; + } + } +} + +/** What a caller changes to pass {@link observesPostFenceCommits}. */ +const FRESH_SNAPSHOT_SUGGESTION = + "Use read_committed transaction isolation. Snapshot isolation levels " + + "(repeatable_read, serializable) are supported only on a backend whose " + + "graph-write fence advances the graph's fence row (`tableNames.fences`) " + + "and reports the effective transaction isolation."; + +/** + * THE fresh-snapshot guard of a decision the per-graph write fence alone + * protects — a read with no claim row behind it, such as the edge-acyclicity + * probe or a whole delete's parts closure. Such a read is only as good as the + * snapshot it runs on, so the session that took the fence must observe what + * committed while it waited. + * + * `coordination` is `undefined` exactly when the store's write-fence plan is + * `engine-serialized` or `caller-serialized` (one writer at a time, nothing + * to observe). Every frame of a transaction that holds a keyed fence carries + * the coordination its acquisition minted, the nested and sibling frames + * included, so this cannot be skipped by where in the transaction it runs. + * + * @throws ConfigurationError carrying `refusal.code` + */ +export function assertFencedSnapshotIsFresh( + port: GraphCommandPort, + graphId: string, + coordination: GraphCommandCoordination | undefined, + refusal: Readonly<{ code: string; subject: string }>, +): void { + if (coordination === undefined) return; + const acquisition = graphCommandCoordinationAcquisition( + port, + coordination, + graphId, + ); + if (observesPostFenceCommits(acquisition)) return; + throw new ConfigurationError( + `${refusal.subject} requires a transaction isolation that observes ` + + "writes committed while this session waited for the per-graph write fence.", + { + code: refusal.code, + graphId, + isolation: acquisition.isolation, + fenceRow: acquisition.fenceRow, + }, + { suggestion: FRESH_SNAPSHOT_SUGGESTION }, ); } /** * A match-key convergence must observe the winner after waiting for its graph - * lock. Repeatable-read snapshots cannot do that; serializable can instead - * force a database serialization retry. Adopted/custom transaction ports - * without an audited isolation level fail closed for the same reason. + * lock. A snapshot isolation can do that only when its acquisition advanced + * the graph's fence row; adopted/custom transaction ports without an audited + * isolation level fail closed for the same reason. */ export function assertGraphCommandConvergenceIsolation( port: GraphCommandPort, graphId: string, coordination: GraphCommandCoordination, ): void { - const isolation = graphCommandCoordinationIsolation( + const acquisition = graphCommandCoordinationAcquisition( port, - graphId, coordination, + graphId, ); - if (isolation === "read_committed" || isolation === "serializable") return; + if (observesPostFenceCommits(acquisition)) return; throw new ConfigurationError( - "Match-key convergence requires read-committed or serializable transaction isolation.", + "Match-key convergence requires a transaction isolation that observes " + + "writes committed while this session waited for the per-graph write fence.", { code: "MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT", - isolation, - }, - { - suggestion: - "Use read_committed, retry a serializable transaction as a whole, or configure a custom PostgreSQL graph-write fence to report the effective transaction isolation.", + isolation: acquisition.isolation, + fenceRow: acquisition.fenceRow, }, + { suggestion: FRESH_SNAPSHOT_SUGGESTION }, ); } diff --git a/packages/typegraph/src/backend/drizzle/base-schema.ts b/packages/typegraph/src/backend/drizzle/base-schema.ts index f97945399..82ff16024 100644 --- a/packages/typegraph/src/backend/drizzle/base-schema.ts +++ b/packages/typegraph/src/backend/drizzle/base-schema.ts @@ -62,6 +62,16 @@ export const BASE_SCHEMA_RELEASES = [ "1af1dd3e48559a59034490abd728290ca46ec8b4c1912b9a4b24880a918cc739", }, }, + { + version: 6, + id: "identity-transition-log", + orderedShapeDigests: { + postgres: + "6c0977cd0c0fd2a73b685fcbc6b502f429f69704342d795b0f56e9cb790e1492", + sqlite: + "37978036648df400a50e0b59337b3c29ca66fdc16f69b927d645b6ec6cba0c0d", + }, + }, ] as const; function currentBaseSchemaVersion(): number { diff --git a/packages/typegraph/src/backend/drizzle/ddl.ts b/packages/typegraph/src/backend/drizzle/ddl.ts index 9e94757a7..2cc18ccfc 100644 --- a/packages/typegraph/src/backend/drizzle/ddl.ts +++ b/packages/typegraph/src/backend/drizzle/ddl.ts @@ -53,6 +53,18 @@ type CustomColumnType = Readonly<{ const EDGE_MATCH_IDENTITY_NAME_COLUMN = "match_identity_name"; const EDGE_MATCH_IDENTITY_KEY_COLUMN = "match_identity_key"; +/** + * The columns {@link planSqliteEdgeMatchIdentityAdoption} can `ADD`, in the + * order it adds them. Exported so the adoption call sites classify a + * concurrent adopter's duplicate-column failure against this list instead of + * re-spelling the column names beside their own `isSqliteDuplicateColumnError` + * call. + */ +export const EDGE_MATCH_IDENTITY_ADOPTION_COLUMNS: readonly string[] = [ + EDGE_MATCH_IDENTITY_NAME_COLUMN, + EDGE_MATCH_IDENTITY_KEY_COLUMN, +]; + export function quoteDdlIdentifier(identifier: string): string { return `"${identifier.replaceAll('"', '""')}"`; } diff --git a/packages/typegraph/src/backend/drizzle/engine/create-sql-backend.ts b/packages/typegraph/src/backend/drizzle/engine/create-sql-backend.ts index 74954674f..b6cd3c828 100644 --- a/packages/typegraph/src/backend/drizzle/engine/create-sql-backend.ts +++ b/packages/typegraph/src/backend/drizzle/engine/create-sql-backend.ts @@ -639,7 +639,7 @@ export function createSqlBackend( // `SchemaCommitPreflightBackend` preflight parameter) rather than left to // infer `SchemaVersionMembers`. Once this group is spread into the // `backend` literal below, that inference can no longer catch a - // divergence between `SchemaVersionMembers` and the four keys it fills — + // divergence between `SchemaVersionMembers` and the five keys it fills — // this annotation is what still does. Every other group assembled here // gets the same treatment implicitly, through the literal's own // `satisfies AdapterBackend` check below. @@ -650,6 +650,7 @@ export function createSqlBackend( | "commitSchemaVersionIfKindsEmpty" | "commitSchemaVersionWithPreflight" | "setActiveVersion" + | "setActiveVersionWithPreflight" > > = createSchemaVersionMembers({ runSchemaWriteTransaction: late.fence.runSchemaWriteTransaction, diff --git a/packages/typegraph/src/backend/drizzle/engine/members/base-schema-members.ts b/packages/typegraph/src/backend/drizzle/engine/members/base-schema-members.ts index cd4f5a4e9..b1884d5a0 100644 --- a/packages/typegraph/src/backend/drizzle/engine/members/base-schema-members.ts +++ b/packages/typegraph/src/backend/drizzle/engine/members/base-schema-members.ts @@ -104,8 +104,46 @@ export type CreateBaseSchemaMembersDeps = Readonly<{ * index and reads every anchor row without it. */ graphIdOrderIndexDdl?: readonly string[]; + /** + * Idempotent `CREATE TABLE ...` followed by its `CREATE INDEX ...` + * statements for the identity transition log, the version-6 adoption + * step — rendered once by the caller from its own dialect's DDL + * generators, the same way `fencesTableDdl` is. A brand-new relation + * needs no ALTER-shaped migration, so this step's `bootstrap` is + * `"covered-by-generated-ddl"`; `adopt()` still ensures it for the + * OFFLINE adoption path, which never calls `generateDdl()`. Scoped to + * exactly this relation (not the identity-enablement `ensureIdentityTables` + * port) because the transition log, like `fences`, is a DEPLOYMENT-wide + * relation: a database that predates this release owes it regardless of + * whether any graph in it has Operational Identity enabled. Older callers + * may omit it; version-6 adoption then refuses with a clear error. + */ + identityTransitionsTableDdl?: readonly string[]; + /** + * Idempotent `CREATE TABLE ...` for the transition log's per-graph + * retention watermark, the version-6 adoption step's other half. + */ + identityTransitionRetentionTableDdl?: string; }>; +/** + * The refusal an adoption step raises when the profile omitted DDL it needs. + * A dep added after a profile was written is optional so that profile still + * constructs; the step that needs it is where its absence is an error. + */ +function missingAdoptionDeps( + adoptionVersion: number, + deps: Readonly>, +): ConfigurationError { + const missingDependencies = Object.entries(deps) + .filter(([, value]) => value === undefined) + .map(([name]) => name); + return new ConfigurationError( + `Base-schema version ${adoptionVersion} adoption requires ${missingDependencies.join(" and ")}.`, + { missingDependencies, adoptionVersion }, + ); +} + export type BaseSchemaMembers = Readonly<{ adoptBaseSchema: () => Promise; assertBaseSchemaCurrent: () => Promise; @@ -119,8 +157,9 @@ export type BaseSchemaMembers = Readonly<{ * adoption, run before bootstrap's generated DDL), version 2 (the fence * rows table), version 3 (the recorded-relations' and recorded * identity-assertions relation's `since_idx` indexes), version 4 (the - * revision-changes relation), and version 5 (the byte-ordered `graph_id` - * indexes) all follow the same prepare/adopt-before/adopt-after bootstrap + * revision-changes relation), version 5 (the byte-ordered `graph_id` + * indexes), and version 6 (the identity transition log plus its retention + * watermark) all follow the same prepare/adopt-before/adopt-after bootstrap * sequencing. */ export function createBaseSchemaMembers( @@ -140,6 +179,8 @@ export function createBaseSchemaMembers( revisionChangesTableDdl, revisionChangesIndexDdl, graphIdOrderIndexDdl = [], + identityTransitionsTableDdl, + identityTransitionRetentionTableDdl, } = deps; const baseSchemaLifecycle: BaseSchemaLifecycle = createBaseSchemaLifecycle({ @@ -183,20 +224,10 @@ export function createBaseSchemaMembers( revisionChangesTableDdl === undefined || revisionChangesIndexDdl === undefined ) { - throw new ConfigurationError( - "Base-schema version 4 adoption requires revision-change table and index DDL.", - { - missingDependencies: [ - ...(revisionChangesTableDdl === undefined ? - ["revisionChangesTableDdl"] - : []), - ...(revisionChangesIndexDdl === undefined ? - ["revisionChangesIndexDdl"] - : []), - ], - adoptionVersion: 4, - }, - ); + throw missingAdoptionDeps(4, { + revisionChangesTableDdl, + revisionChangesIndexDdl, + }); } await ensureTable(revisionChangesTableDdl); for (const ddl of revisionChangesIndexDdl) { @@ -214,6 +245,25 @@ export function createBaseSchemaMembers( }, bootstrap: { phase: "covered-by-generated-ddl" }, }, + { + version: 6, + async adopt(): Promise { + if ( + identityTransitionsTableDdl === undefined || + identityTransitionRetentionTableDdl === undefined + ) { + throw missingAdoptionDeps(6, { + identityTransitionsTableDdl, + identityTransitionRetentionTableDdl, + }); + } + for (const ddl of identityTransitionsTableDdl) { + await ensureTable(ddl); + } + await ensureTable(identityTransitionRetentionTableDdl); + }, + bootstrap: { phase: "covered-by-generated-ddl" }, + }, ], }); @@ -224,9 +274,7 @@ export function createBaseSchemaMembers( async bootstrapTables(): Promise { const startingBaseSchemaVersion = await baseSchemaLifecycle.prepareBootstrap(); - await baseSchemaLifecycle.adoptBeforeBootstrap( - startingBaseSchemaVersion, - ); + await baseSchemaLifecycle.adoptBeforeBootstrap(startingBaseSchemaVersion); const statements = generateDdl(); for (const statement of statements) { await ensureTable(statement); diff --git a/packages/typegraph/src/backend/drizzle/engine/members/graph-template-members.ts b/packages/typegraph/src/backend/drizzle/engine/members/graph-template-members.ts index 47add0eb7..c2f20ea2c 100644 --- a/packages/typegraph/src/backend/drizzle/engine/members/graph-template-members.ts +++ b/packages/typegraph/src/backend/drizzle/engine/members/graph-template-members.ts @@ -7,8 +7,8 @@ * because `PgTable` and `SQLiteTable` share no common supertype — the same * reason `kind-removal-members.ts` binds its row access per dialect. * - * `registerGraphTemplate`'s insert-then-verify sequence and its - * content-conflict refusal are identical on both dialects; only the row's + * `registerGraphTemplate`'s insert-then-read sequence is identical on both + * dialects; only the row's * timestamp and JSON-document encoding differ, threaded through as the * `rowAccess` dep the same way `kind-removal-members.ts` and * `index-materialization-members.ts` do it. @@ -35,7 +35,7 @@ * confirmed. That asymmetry is threaded through as the optional * `copyContributionMarkers` dep, present only on the SQLite profile. */ -import { ConfigurationError } from "../../../../errors"; +import { DatabaseOperationError } from "../../../../errors"; import { type CompiledRowsSql } from "../../../../query/sql-intent"; import type { SerializedSchema } from "../../../../schema/types"; import { @@ -228,12 +228,13 @@ export function createGraphTemplateMembers( ): Promise { await rowAccess.insertIgnoringConflict(params); const row = await rowAccess.selectByTemplateId(params.templateId); - if (row?.schemaHash !== params.schemaHash) { - throw new ConfigurationError( - `Graph template "${params.templateId}" already exists with different schema content.`, + if (row === undefined) { + throw new DatabaseOperationError( + `Graph template "${params.templateId}" was not readable after registration.`, { - code: "GRAPH_TEMPLATE_CONTENT_CONFLICT", - templateId: params.templateId, + operation: "select", + entity: "graph-template", + reason: "no_row_returned", }, ); } diff --git a/packages/typegraph/src/backend/drizzle/engine/members/identity-members.ts b/packages/typegraph/src/backend/drizzle/engine/members/identity-members.ts index 5fbeb3a28..2eb9e6581 100644 --- a/packages/typegraph/src/backend/drizzle/engine/members/identity-members.ts +++ b/packages/typegraph/src/backend/drizzle/engine/members/identity-members.ts @@ -25,17 +25,20 @@ import type { } from "../../../types"; /** - * Barrel keys (contribution logical names) of the four relations that hold + * Barrel keys (contribution logical names) of the six relations that hold * Operational Identity state: current assertions, recorded-time assertions, - * the derived closure, and the derived separation relation. - * `ensureIdentityTables()` scopes its idempotent CREATE TABLE / CREATE INDEX - * to exactly these when identity is first enabled on an existing database. + * the derived closure, the derived separation relation, and the transition + * log plus its retention watermark. `ensureIdentityTables()` scopes its + * idempotent CREATE TABLE / CREATE INDEX to exactly these when identity is + * first enabled on an existing database. */ const IDENTITY_TABLE_LOGICAL_NAMES: ReadonlySet = new Set([ "identityAssertions", "recordedIdentityAssertions", "identityClosure", "identitySeparation", + "identityTransitions", + "identityTransitionRetention", ]); /** @@ -229,7 +232,9 @@ export function createIdentityMembers( return missing; }, - identityTableDdl(identityTableNames: IdentityTableNames): readonly string[] { + identityTableDdl( + identityTableNames: IdentityTableNames, + ): readonly string[] { return identityContributionsFor(identityTableNames).flatMap( (contribution) => [...contribution.createDdl], ); diff --git a/packages/typegraph/src/backend/drizzle/engine/members/schema-version-members.ts b/packages/typegraph/src/backend/drizzle/engine/members/schema-version-members.ts index a9aac315b..cb481b6d3 100644 --- a/packages/typegraph/src/backend/drizzle/engine/members/schema-version-members.ts +++ b/packages/typegraph/src/backend/drizzle/engine/members/schema-version-members.ts @@ -1,7 +1,8 @@ /** - * The four schema-commit members every SQL engine profile exposes: + * The five schema-commit members every SQL engine profile exposes: * `commitSchemaVersion`, `commitSchemaVersionIfKindsEmpty`, - * `commitSchemaVersionWithPreflight`, and `setActiveVersion`. All four are a + * `commitSchemaVersionWithPreflight`, `setActiveVersion`, and + * `setActiveVersionWithPreflight`. All five are a * full mirror — same body, same delegation — because every dialect * difference already lives one layer down, inside the write-fence-holding * transaction runner this group calls through `runSchemaWriteTransaction` @@ -70,12 +71,17 @@ export type SchemaVersionMembers = Readonly<{ preflight: (target: SchemaWriteTransactionBackend) => Promise, ) => Promise; setActiveVersion: (params: SetActiveVersionParams) => Promise; + setActiveVersionWithPreflight: ( + params: SetActiveVersionParams, + preflight: (target: SchemaWriteTransactionBackend) => Promise, + ) => Promise; }>; /** * Builds the schema-commit member group. Moved out of the two dialect files * unchanged: same fenced commit, same populated-kind guard, same - * preflight-then-commit sequencing, same fenced `setActiveVersion`. + * preflight-then-commit sequencing, same fenced `setActiveVersion`, whose + * preflight form sequences its flip exactly as a commit does. */ export function createSchemaVersionMembers( deps: CreateSchemaVersionMembersDeps, @@ -115,5 +121,15 @@ export function createSchemaVersionMembers( target.setActiveVersion(params), ); }, + + async setActiveVersionWithPreflight( + params: SetActiveVersionParams, + preflight: (target: SchemaWriteTransactionBackend) => Promise, + ): Promise { + await runSchemaWriteTransaction(params.graphId, async (target) => { + await preflight(target); + await target.setActiveVersion(params); + }); + }, }; } diff --git a/packages/typegraph/src/backend/drizzle/operation-backend-core.ts b/packages/typegraph/src/backend/drizzle/operation-backend-core.ts index f94856883..50cdee18f 100644 --- a/packages/typegraph/src/backend/drizzle/operation-backend-core.ts +++ b/packages/typegraph/src/backend/drizzle/operation-backend-core.ts @@ -25,7 +25,7 @@ import { isSameClaimOwner, } from "../../store/claims/axis"; import { - type ConstrainedCardinality, + type EdgeCardinalityAxisRef, edgeCardinalityClaimTarget, edgeClaimRelationMissing, } from "../../store/claims/edge-claims"; @@ -88,7 +88,7 @@ import { assertGraphCommandCoordination, assertGraphCommandExecutionContext, type GraphCommandExecutionContext, - normalizeGraphCommandIsolation, + readGraphWriteFenceAcquisition, } from "../command-contract"; import { resolveEdgeEndpointIds, @@ -104,10 +104,11 @@ import type { CommitSchemaVersionIfKindsEmptyResult, CommitSchemaVersionParams, CompareAndSetNodeParams, + CompositionClaimScope, ConstraintFenceViolationRows, ContendedEdgeRow, + CountEdgesAtEndpointParams, CountEdgesByKindParams, - CountEdgesFromParams, CountNodesByKindParams, DeleteEdgeParams, DeleteEdgesBatchParams, @@ -115,10 +116,12 @@ import type { DeleteUniqueParams, DisjointOverlapRow, DurableEdgeBatchMembers, + EdgeCardinalityDeclaration, EdgeClaimOutcome, EdgeConvergeCreateCommand, EdgeConvergeCreateCommandResult, EdgeCreateCommandResult, + EdgeEndpointAllowance, EdgeExistsBetweenParams, EdgeRow, FindActiveEdgesBySourceV1Params, @@ -142,6 +145,7 @@ import type { InsertUniqueParams, ManagedEdgeCreatePlan, ManagedNodeCreatePlan, + MisassignedEdgeEndpointRow, NodeCreateCommandResult, NodeRow, NodeRowLock, @@ -149,6 +153,7 @@ import type { PurgeEdgeClaimsParams, ReadConstraintFenceViolationsParams, ResolvedNodeUpdateBatchParams, + SchemaGraphWriteFenceParams, SchemaKindEmptinessProbe, SchemaVersionRow, SchemaWriteFenceParams, @@ -171,7 +176,10 @@ import { ATOMIC_CONTRIBUTION_ASSERTION_FIXED_BIND_COUNT, ATOMIC_CONTRIBUTION_EVIDENCE_BIND_COUNT, } from "./operations/contribution-evidence"; -import { ATOMIC_EDGE_CLAIM_PROPOSED_COLUMN_COUNT } from "./operations/edge-claims"; +import { + ATOMIC_EDGE_CLAIM_PROPOSED_COLUMN_COUNT, + claimPredicateShapeKey, +} from "./operations/edge-claims"; import { type CommonOperationStrategy, createCachedTableExistence, @@ -201,6 +209,10 @@ const ATOMIC_NODE_WRITE_FIXED_PARAM_COUNT = 4; const ATOMIC_NODE_PROJECTION_FIXED_PARAM_COUNT = 4; const ATOMIC_NODE_PROJECTION_MAX_PARAM_COUNT_PER_ENTRY = 6; +function claimTargetKey(axis: string, key: string): string { + return encodeTupleKey([axis, key]); +} + function chunkAtomicNodeEntriesByIdSource( entries: readonly AtomicNodeBatchEntry[], chunkSize: number, @@ -288,10 +300,34 @@ export function atomicNodeReplacementSubmissionMaxEntries( ); } +/** + * The belt behind the composition-eligibility gates (`resolveAtomicEdgeBatchExecutor` + * et al., `src/store/operations/atomic-mutation-program.ts`): a fused command + * is an optimization attempt, not evidence that its dimensions ran, and a + * composition claim (`scope.kind === "composition"`) must NEVER + * reach a fused write — the fused single-claim path (`createEdgeWithPlan`) + * carries exactly one claim by construction, and even the batch path's + * generic multi-claim support is a policy choice this assertion enforces + * rather than a capability this library exercises. A composition edge + * always owes at least two claims (its own declared axis, plus this one), + * so every gate that reaches this function should already have routed such + * an edge kind through the portable path; this throws if one ever does not. + */ function assertMatchingFusedEdgeClaim( params: InsertEdgeParams, claim: ClaimEdgeCardinalityParams, ): void { + if (claim.scope?.kind === "composition") { + throw new CompilerInvariantError( + "A fused edge write can never apply a composition claim: a composition " + + "edge kind always owes at least two claims, which every static " + + "eligibility gate must route through the portable write path instead.", + { + edgeId: claim.edgeId, + edgeKind: claim.edgeKind, + }, + ); + } const matchesEdge = claim.graphId === params.graphId && claim.edgeId === params.id && @@ -545,6 +581,33 @@ function claimOwnerOf(params: InsertUniqueParams): ClaimOwner { return { concreteKind: params.concreteKind, nodeId: params.nodeId }; } +/** + * Narrows a fence-audit declaration to the axis ref alone, dropping + * `edgeKind`. + * + * A switch on the discriminant, the same shape {@link edgeCardinalityAxisName} + * uses and for the same stated reason: reading `declaration.cardinality` + * outside a narrowed branch widens to the union of both directions' + * cardinality types (`unique` included), so building the object field-by-field + * would need an `as EdgeCardinalityAxisRef` assertion — defeating the very + * pairing {@link EdgeCardinalityAxisRef} exists to make unspellable. The + * switch keeps the compiler correlating `direction` and `cardinality` instead + * of trusting an assertion that a wrongly-paired future declaration would + * silently pass. + */ +function axisRefFromDeclaration( + declaration: EdgeCardinalityDeclaration, +): EdgeCardinalityAxisRef { + switch (declaration.direction) { + case "source": { + return { direction: "source", cardinality: declaration.cardinality }; + } + case "target": { + return { direction: "target", cardinality: declaration.cardinality }; + } + } +} + /** * The internal operation backend — what `createCommonOperationBackend` * returns. Includes `commitSchemaVersion` and `setActiveVersion` so the @@ -561,7 +624,7 @@ export type CommonOperationBackend = Pick< | "clearGraphPreservingContributionMaterializations" | "compareAndSetNode" | "countEdgesByKind" - | "countEdgesFrom" + | "countEdgesAtEndpoint" | "countNodesByKind" | "deleteEdge" | "deleteEdgesBatch" @@ -1033,6 +1096,101 @@ function pairAtomicMutationChunksWithIds( }); } +// `graphId` and `edgeKind` are fixed per statement; each admitted pair costs +// two more (`fromKind`, `toKind`). +const MISASSIGNED_EDGE_ENDPOINT_FIXED_PARAM_COUNT = 2; +const MISASSIGNED_EDGE_ENDPOINT_PAIR_PARAM_COUNT = 2; + +/** + * How many admitted pairs one `buildMisassignedEdgeEndpointAudit` statement + * may render, sized to the connection's bound-parameter budget. Unlike the + * disjunction this statement used to render, a `VALUES` derived table does + * not grow SQLite's expression-tree depth with the pair count, so the bound + * budget is the only ceiling left to respect. + */ +function misassignedEdgeEndpointPairChunkSize( + maxBindParameters: number, +): number { + return Math.max( + 1, + Math.floor( + (maxBindParameters - MISASSIGNED_EDGE_ENDPOINT_FIXED_PARAM_COUNT) / + MISASSIGNED_EDGE_ENDPOINT_PAIR_PARAM_COUNT, + ), + ); +} + +/** + * The projection every edge-family fence-audit statement returns + * (`src/backend/drizzle/operations/constraint-fence-audit.ts` + * `edgeAuditProjection`). One declaration, so an added audit column is a type + * error at every reader rather than an `undefined` field on a reported row. + */ +type EdgeAuditRow = Readonly<{ + edge_id: string; + edge_kind: string; + from_kind: string; + from_id: string; + to_kind: string; + to_id: string; +}>; + +/** The audit row's snake_case columns as the reported row names them. */ +function edgeAuditRowFields(row: EdgeAuditRow): MisassignedEdgeEndpointRow { + return { + edgeKind: row.edge_kind, + edgeId: row.edge_id, + fromKind: row.from_kind, + fromId: row.from_id, + toKind: row.to_kind, + toId: row.to_id, + }; +} + +/** + * The live edges of one edge kind whose endpoints match none of its + * allowance's admitted pairs, chunking the pairs across several statements + * when the allowance exceeds `pairChunkSize`. + * + * A row is truly misassigned only when it fails EVERY chunk's admitted-pairs + * predicate — failing one chunk means only that the row is not admitted by + * that chunk's pairs, not that no pair anywhere admits it. So the final + * answer is the INTERSECTION, by edge id, of what each chunk's statement + * reports; a chunk that already leaves no candidates ends the loop early, + * since intersecting with the empty set can only stay empty. + */ +async function readMisassignedEdgeEndpointRows( + execution: OperationBackendExecution, + operationStrategy: CommonOperationStrategy, + graphId: string, + allowance: EdgeEndpointAllowance, + pairChunkSize: number, +): Promise { + const pairChunks = + allowance.allowedPairs.length === 0 ? + [[]] + : chunkArray(allowance.allowedPairs, pairChunkSize); + let candidates: Map | undefined; + for (const pairChunk of pairChunks) { + const rows = await execution.execAll( + operationStrategy.buildMisassignedEdgeEndpointAudit( + graphId, + allowance.edgeKind, + pairChunk, + ), + ); + const rowsByEdgeId = new Map(rows.map((row) => [row.edge_id, row])); + candidates = + candidates === undefined ? rowsByEdgeId : ( + new Map([...candidates].filter(([edgeId]) => rowsByEdgeId.has(edgeId))) + ); + if (candidates.size === 0) break; + } + return [...(candidates?.values() ?? [])].map((row) => + edgeAuditRowFields(row), + ); +} + function assembleAtomicResolvedMutationSet( entity: "node" | "edge", results: readonly AtomicResolvedMutationSlot[], @@ -1745,7 +1903,9 @@ export function createCommonOperationBackend( * COMMITTED, so two concurrent writers would both see "the incumbent is not * live yet" and both commit. Statement 1 is therefore decision-free — it only * makes the row exist, takes its lock and reports the COMMITTED holder — and - * statement 2 re-evaluates liveness after that lock is held. + * statement 2 re-evaluates liveness after that lock is held. Between them, + * {@link readClaimlessIncumbents} refuses a composition claim whose axis a + * claimless live edge already occupies. * * Duplicate conflict targets are refused rather than collapsed: a multi-row * upsert cannot affect one row twice, so two entries claiming one axis would @@ -1760,7 +1920,7 @@ export function createCommonOperationBackend( const targetKey = (entry: ClaimEdgeCardinalityParams): string => { const target = edgeCardinalityClaimTarget(entry); - return `${target.axis}\u0000${target.key}`; + return claimTargetKey(target.axis, target.key); }; const seen = new Set(); for (const entry of entries) { @@ -1776,8 +1936,11 @@ export function createCommonOperationBackend( } const outcomes = new Map(); - // One claim row per inserted edge, so the edge-insert budget is the right - // ceiling for the multi-row lock statement. + // Chunked by CLAIM entries, not edges — a two-axis edge kind emits two + // claims per inserted edge. The edge-insert budget is still a safe + // ceiling here: a claim row binds fewer parameters than an edge row, so + // chunking claims at the same size never exceeds the bind budget an edge + // insert of that size already clears. for (const chunk of chunkArray(entries, batchConfig.edgeInsertBatchSize)) { const lockQuery = operationStrategy.buildLockEdgeClaims(chunk, nowIso()); const rows = await execution.execAll<{ @@ -1786,10 +1949,19 @@ export function createCommonOperationBackend( holder_edge_id: string; }>(lockQuery); const holderByTarget = new Map( - rows.map((row) => [`${row.axis}\u0000${row.key}`, row.holder_edge_id]), + rows.map((row) => [ + claimTargetKey(row.axis, row.key), + row.holder_edge_id, + ]), ); + const incumbentByTarget = await readClaimlessIncumbents(chunk); for (const entry of chunk) { const key = targetKey(entry); + const incumbent = incumbentByTarget.get(key); + if (incumbent !== undefined) { + outcomes.set(key, { status: "refused", holderEdgeId: incumbent }); + continue; + } const holder = holderByTarget.get(key); if (holder === undefined || holder === entry.edgeId) { outcomes.set(key, { status: "claimed" }); @@ -1811,9 +1983,42 @@ export function createCommonOperationBackend( ); } + /** + * The live edges already occupying a composition claim's axis, keyed like + * the lock's holders. An ordinary axis is read-probed by the store before + * it is claimed; a composition axis is relation-wide and has no read probe, + * so a live attachment that predates its `partOf` declaration holds no claim + * row and only this read can see it — the batch counterpart of the + * single-row guard's `incumbent_edge_id`. + */ + async function readClaimlessIncumbents( + entries: readonly ClaimEdgeCardinalityParams[], + ): Promise> { + const compositionEntries = entries.filter( + (entry) => entry.scope !== undefined, + ); + const incumbents = new Map(); + if (compositionEntries.length === 0) return incumbents; + for (const query of operationStrategy.buildReadEdgeClaimIncumbents( + compositionEntries, + )) { + const rows = await execution.execAll<{ + axis: string; + key: string; + incumbent_edge_id: string; + }>(query); + for (const row of rows) { + const key = claimTargetKey(row.axis, row.key); + if (!incumbents.has(key)) incumbents.set(key, row.incumbent_edge_id); + } + } + return incumbents; + } + /** * Single-claim fast path. The lock statement reports both the committed - * claim holder and whether a claimless live edge already occupies the axis. + * claim holder and the live edge, claimed or not, already occupying the + * axis. * A stale foreign holder is taken over only through a second guarded * statement whose fresh snapshot rechecks the entire axis. */ @@ -1822,7 +2027,7 @@ export function createCommonOperationBackend( ): Promise { const rows = await execution.execAll<{ holder_edge_id: string; - has_incumbent: boolean | number; + incumbent_edge_id: string | null; }>(operationStrategy.buildLockEdgeClaimGuarded(params, nowIso())); const locked = rows[0]; if (locked === undefined) { @@ -1831,15 +2036,12 @@ export function createCommonOperationBackend( { operation: "insert", entity: "edge" }, ); } - const hasIncumbent = - locked.has_incumbent === true || locked.has_incumbent === 1; - if (locked.holder_edge_id === params.edgeId) { - return hasIncumbent ? - { status: "refused", holderEdgeId: params.edgeId } - : { status: "claimed" }; + const incumbent = locked.incumbent_edge_id ?? undefined; + if (incumbent !== undefined) { + return { status: "refused", holderEdgeId: incumbent }; } - if (hasIncumbent) { - return { status: "refused", holderEdgeId: locked.holder_edge_id }; + if (locked.holder_edge_id === params.edgeId) { + return { status: "claimed" }; } const takeOver = await execution.execAll<{ holder_edge_id: string }>( @@ -3315,8 +3517,9 @@ export function createCommonOperationBackend( compareCodePoints(left, right), ); const lockSlots = ( - options.writersSerialize ? [] : chunkArray(orderedIds, chunkSize) - ).map((ids) => ({ + options.writersSerialize ? + [] + : chunkArray(orderedIds, chunkSize)).map((ids) => ({ statement: execution.compile( operationStrategy.buildLockNodesForAtomicDeleteWithSchemaFence( { ...input, ids }, @@ -4028,7 +4231,9 @@ export function createCommonOperationBackend( ) ? {} : { - async lockSchemaVersionAndGraphWrite(params: SchemaWriteFenceParams) { + async lockSchemaVersionAndGraphWrite( + params: SchemaGraphWriteFenceParams, + ) { const row = await execution.execGet>( buildLockSchemaVersionAndGraphWrite( params, @@ -4036,9 +4241,7 @@ export function createCommonOperationBackend( schemaGraphWriteFenceSql, ), ); - if (row !== undefined) { - return normalizeGraphCommandIsolation(row["transaction_isolation"]); - } + if (row !== undefined) return readGraphWriteFenceAcquisition(row); // A blocked `FOR SHARE` can recheck the old active row out of its // statement snapshot without substituting the winner's new row. As @@ -4339,6 +4542,7 @@ export function createCommonOperationBackend( plan: ManagedEdgeCreatePlan, ): Promise { const { params } = plan; + const claims = plan.cardinalityClaims ?? []; if ( plan.schemaFence !== undefined && plan.schemaFence.graphId !== params.graphId @@ -4352,7 +4556,7 @@ export function createCommonOperationBackend( }, ); } - if (plan.schemaFence !== undefined && plan.cardinalityClaim !== undefined) { + if (plan.schemaFence !== undefined && claims.length > 0) { return { outcome: "unsupported", entity: "edge", @@ -4369,9 +4573,14 @@ export function createCommonOperationBackend( dimensions: ["schemaFence"], }; } + // v1 fuses at most one claim: the fused CTE + // (`buildInsertEdgeIfEndpointsLiveWithCardinalityClaim`) reserves exactly + // one axis row. A two-axis plan takes the portable claim-then-insert + // path on every engine — refused here, before any SQL runs, never a + // silently-dropped second axis. if ( - plan.cardinalityClaim !== undefined && - executeEdgeCardinalityInsert === undefined + claims.length > 1 || + (claims.length === 1 && executeEdgeCardinalityInsert === undefined) ) { return { outcome: "unsupported", @@ -4383,7 +4592,7 @@ export function createCommonOperationBackend( let row: EdgeRow | undefined; if (plan.schemaFence !== undefined) { row = await executeEdgeSchemaFencedInsert(params, plan.schemaFence); - } else if (plan.cardinalityClaim === undefined) { + } else if (claims.length === 0) { row = await executeEdgeEndpointInsert(params); } else { // The unsupported case is returned above before any SQL executes. @@ -4397,7 +4606,8 @@ export function createCommonOperationBackend( }, ); } - row = await executeEdgeCardinalityInsert(params, plan.cardinalityClaim); + const claim = requireDefined(claims[0]); + row = await executeEdgeCardinalityInsert(params, claim); } return row === undefined ? @@ -4429,7 +4639,7 @@ export function createCommonOperationBackend( // Durable convergence delegates that decision to the row-level unique // arbiter and may therefore execute directly on a root session. if ( - plan.cardinalityClaim !== undefined || + (plan.cardinalityClaims ?? []).length > 0 || operationStrategy.buildConvergeEdgeCreate === undefined || (!durable && !options.dynamicEdgeConvergence) || (!durable && context.coordination === "none") || @@ -5105,8 +5315,10 @@ export function createCommonOperationBackend( } }, - async countEdgesFrom(params: CountEdgesFromParams): Promise { - const query = operationStrategy.buildCountEdgesFrom(params); + async countEdgesAtEndpoint( + params: CountEdgesAtEndpointParams, + ): Promise { + const query = operationStrategy.buildCountEdgesAtEndpoint(params); const row = await execution.execGet<{ count: string | number }>(query); return Number(row?.count ?? 0); }, @@ -5197,7 +5409,7 @@ export function createCommonOperationBackend( } return edgeRows; }, - }), + }), ...(buildFindEdgesAcrossKinds === undefined ? {} @@ -5449,42 +5661,55 @@ export function createCommonOperationBackend( nodeId: row.node_id, })); - // One statement per declared cardinality, because that is the - // granularity at which the population's key and liveness differ. - const edgeKindsByCardinality = new Map< - ConstrainedCardinality, - string[] + // One statement per declared axis, because that is the granularity at + // which the population's key and liveness differ — and a composition + // declaration (`declaration.scope !== undefined`) is its own axis even + // under the same axis name, because its population is relation-wide: + // the peer test is the oriented two-arm union, not exact-kind equality + // (see `buildContendedCompositionEdgeRowAudit`). + const edgeAxes = new Map< + string, + Readonly<{ + ref: EdgeCardinalityAxisRef; + edgeKinds: string[]; + scope: CompositionClaimScope | undefined; + }> >(); for (const declaration of params.edgeCardinalities) { - const kinds = edgeKindsByCardinality.get(declaration.cardinality) ?? []; - kinds.push(declaration.edgeKind); - edgeKindsByCardinality.set(declaration.cardinality, kinds); + const axisKey = claimPredicateShapeKey(declaration); + const entry = edgeAxes.get(axisKey) ?? { + // Narrowed to the axis ref alone: `declaration` also carries + // `edgeKind`, and keeping that field on `ref` would let a later + // `{...ref, edgeKind: row.edge_kind}` merge silently depend on + // spread ORDER to discard it instead of the type excluding it. + ref: axisRefFromDeclaration(declaration), + edgeKinds: [], + scope: declaration.scope, + }; + entry.edgeKinds.push(declaration.edgeKind); + edgeAxes.set(axisKey, entry); } const contendedEdgeRows: ContendedEdgeRow[] = []; - for (const [cardinality, edgeKinds] of edgeKindsByCardinality) { - const rows = await execution.execAll<{ - edge_id: string; - edge_kind: string; - from_kind: string; - from_id: string; - to_kind: string; - to_id: string; - }>( - operationStrategy.buildContendedEdgeRowAudit( - params.graphId, - cardinality, - edgeKinds, - ), + for (const { ref, edgeKinds, scope } of edgeAxes.values()) { + const rows = await execution.execAll( + scope === undefined ? + operationStrategy.buildContendedEdgeRowAudit( + params.graphId, + ref, + edgeKinds, + ) + : operationStrategy.buildContendedCompositionEdgeRowAudit( + params.graphId, + ref, + scope.holders, + edgeKinds, + ), ); for (const row of rows) { contendedEdgeRows.push({ - edgeKind: row.edge_kind, - cardinality, - edgeId: row.edge_id, - fromKind: row.from_kind, - fromId: row.from_id, - toKind: row.to_kind, - toId: row.to_id, + ...ref, + ...edgeAuditRowFields(row), + scope, }); } } @@ -5498,7 +5723,35 @@ export function createCommonOperationBackend( disjointOverlaps.push({ kinds, nodeId: row.node_id }); } - return { contendedUniqueRows, contendedEdgeRows, disjointOverlaps }; + // Present whenever the caller asked for the family (the key is + // defined, even if empty) — never present otherwise, so + // `auditConstraintFences`'s "asked and got nothing" refusal stays + // meaningful. + const misassignedEdgeEndpointRows: MisassignedEdgeEndpointRow[] = []; + if (params.edgeEndpointAllowances !== undefined) { + const pairChunkSize = + misassignedEdgeEndpointPairChunkSize(maxBindParameters); + for (const allowance of params.edgeEndpointAllowances) { + for (const row of await readMisassignedEdgeEndpointRows( + execution, + operationStrategy, + params.graphId, + allowance, + pairChunkSize, + )) { + misassignedEdgeEndpointRows.push(row); + } + } + } + + return { + contendedUniqueRows, + contendedEdgeRows, + disjointOverlaps, + ...(params.edgeEndpointAllowances === undefined ? + {} + : { misassignedEdgeEndpointRows }), + }; }, async checkUnique( diff --git a/packages/typegraph/src/backend/drizzle/operations/collections.ts b/packages/typegraph/src/backend/drizzle/operations/collections.ts index 36ae525bc..d7be2b790 100644 --- a/packages/typegraph/src/backend/drizzle/operations/collections.ts +++ b/packages/typegraph/src/backend/drizzle/operations/collections.ts @@ -1,10 +1,7 @@ import { type SQL, sql, type SQLWrapper } from "drizzle-orm"; import { type TemporalMode } from "../../../core/types"; -import { - EDGE_CARDINALITY_SPECS, - type EdgeCardinalitySpec, -} from "../../../store/claims/edge-claims"; +import { edgeCardinalitySpec } from "../../../store/claims/edge-claims"; import type { CountEdgesByKindParams, CountNodesByKindParams, @@ -134,6 +131,31 @@ export function buildFindNodesAcrossKinds( return sql`SELECT * FROM ${nodes} WHERE ${sql.join(conditions, sql` AND `)} ORDER BY ${nodes.kind} ASC, ${nodes.id} ASC LIMIT ${params.limit}`; } +/** + * `COUNT(*)` over `table` filtered by `whereClause`. With `limit`, the count + * stops at that many rows, so a caller that only needs "is there one" reads a + * single row instead of the whole population. + */ +function buildCountQuery( + table: SQLWrapper, + whereClause: SQL, + limit: number | undefined, +): SQL { + if (limit === undefined) { + return sql` + SELECT COUNT(*) as count FROM ${table} + WHERE ${whereClause} + `; + } + return sql` + SELECT COUNT(*) as count FROM ( + SELECT 1 FROM ${table} + WHERE ${whereClause} + LIMIT ${limit} + ) AS bounded + `; +} + /** * Builds a query to count nodes by kind. */ @@ -149,12 +171,11 @@ export function buildCountNodesByKind( ...buildTemporalConditions(nodes, params), ]; - const whereClause = sql.join(conditions, sql` AND `); - - return sql` - SELECT COUNT(*) as count FROM ${nodes} - WHERE ${whereClause} - `; + return buildCountQuery( + nodes, + sql.join(conditions, sql` AND `), + params.limit, + ); } /** Builds an `IN (...)` membership predicate over a non-empty id list. */ @@ -270,7 +291,10 @@ export function buildFindActiveEdgesBySourceV1( params: FindActiveEdgesBySourceV1Params, ): SQL { const { edges } = tables; - const spec: EdgeCardinalitySpec = EDGE_CARDINALITY_SPECS.oneActive; + const spec = edgeCardinalitySpec({ + direction: "source", + cardinality: "oneActive", + }); const activeCondition = spec.holderLiveness === "liveAndActive" ? sql`AND ${edges.validTo} IS NULL` @@ -507,10 +531,9 @@ export function buildCountEdgesByKind( conditions.push(sql`${edges.toId} = ${params.toId}`); } - const whereClause = sql.join(conditions, sql` AND `); - - return sql` - SELECT COUNT(*) as count FROM ${edges} - WHERE ${whereClause} - `; + return buildCountQuery( + edges, + sql.join(conditions, sql` AND `), + params.limit, + ); } diff --git a/packages/typegraph/src/backend/drizzle/operations/constraint-fence-audit.ts b/packages/typegraph/src/backend/drizzle/operations/constraint-fence-audit.ts index 1d2886736..f494aebe7 100644 --- a/packages/typegraph/src/backend/drizzle/operations/constraint-fence-audit.ts +++ b/packages/typegraph/src/backend/drizzle/operations/constraint-fence-audit.ts @@ -18,21 +18,24 @@ import { getTableName, type SQL, sql } from "drizzle-orm"; import { - type ConstrainedCardinality, - EDGE_CARDINALITY_SPECS, + axisRowMembership, + type EdgeCardinalityAxisRef, + edgeCardinalitySpec, } from "../../../store/claims/edge-claims"; -import { quotedColumn, type Tables } from "./shared"; +import type { CompositionClaimScope } from "../../types"; +import { + claimHolderTerms, + endpointTerms, + holderLivenessTerm, + partEndpoint, +} from "./edge-claims"; +import { qualifiedColumn, quotedColumn, type Tables } from "./shared"; /** The alias the correlated subquery reads the same relation under. */ const PEER = "peer"; -/** Qualifies a column with a relation name, the rendering both dialects read. */ -function qualified( - relation: string, - column: Readonly<{ name: string }>, -): SQL { - return sql.raw(`"${relation}"."${column.name}"`); -} +/** The alias the misassigned-endpoint audit's `VALUES` derived table reads under. */ +const ALLOWED_PAIR_ALIAS = "tg_allowed_pair"; /** A bound-parameter `IN (…)` list; callers never pass an empty one. */ function inList(values: readonly string[]): SQL { @@ -42,6 +45,41 @@ function inList(values: readonly string[]): SQL { ); } +/** + * THE population a row-shape audit family reads: every row that is not + * tombstoned, whatever its validity window. That is what the write path admits + * and keeps fencing — a write validates a future-dated or already-ended row + * like any other, and a later current read or an `asOf` read returns it — so an + * audit that read only the current window would pass a commit over a row that + * becomes a violation once the clock reaches it. A family whose declaration + * additionally retires ended rows (a `oneActive` axis) layers its own term on + * top of this one. + */ +function auditedRowTerm( + relation: string, + table: Readonly<{ deletedAt: Readonly<{ name: string }> }>, +): SQL { + return sql`${qualifiedColumn(relation, table.deletedAt)} IS NULL`; +} + +/** + * The row every edge-family audit reports, in one place: the edge's identity + * and both endpoints, named as the caller's row type spells them + * ({@link file://../../operation-backend-core.ts readConstraintFenceViolations}). + * An added audit column is then one edit here and one in that row type, rather + * than one per statement. + */ +function edgeAuditProjection(edges: Tables["edges"]): SQL { + return sql` + ${quotedColumn(edges.id)} as edge_id, + ${quotedColumn(edges.kind)} as edge_kind, + ${quotedColumn(edges.fromKind)} as from_kind, + ${quotedColumn(edges.fromId)} as from_id, + ${quotedColumn(edges.toKind)} as to_kind, + ${quotedColumn(edges.toId)} as to_id + `; +} + /** * Live `uniques` rows sharing a `(constraint_name, key)` with another live row, * restricted to constraint names the graph declares. @@ -72,75 +110,158 @@ export function buildContendedUniqueRowAudit( ${quotedColumn(uniques.concreteKind)} as concrete_kind, ${quotedColumn(uniques.nodeId)} as node_id FROM ${uniques} - WHERE ${qualified(relation, uniques.graphId)} = ${graphId} - AND ${qualified(relation, uniques.constraintName)} IN (${inList(constraintNames)}) - AND ${qualified(relation, uniques.deletedAt)} IS NULL + WHERE ${qualifiedColumn(relation, uniques.graphId)} = ${graphId} + AND ${qualifiedColumn(relation, uniques.constraintName)} IN (${inList(constraintNames)}) + AND ${auditedRowTerm(relation, uniques)} AND EXISTS ( SELECT 1 FROM ${uniques} AS ${sql.raw(`"${PEER}"`)} - WHERE ${qualified(PEER, uniques.graphId)} = ${qualified(relation, uniques.graphId)} - AND ${qualified(PEER, uniques.constraintName)} = ${qualified(relation, uniques.constraintName)} - AND ${qualified(PEER, uniques.key)} = ${qualified(relation, uniques.key)} - AND ${qualified(PEER, uniques.deletedAt)} IS NULL - AND ${qualified(PEER, uniques.nodeKind)} <> ${qualified(relation, uniques.nodeKind)} + WHERE ${qualifiedColumn(PEER, uniques.graphId)} = ${qualifiedColumn(relation, uniques.graphId)} + AND ${qualifiedColumn(PEER, uniques.constraintName)} = ${qualifiedColumn(relation, uniques.constraintName)} + AND ${qualifiedColumn(PEER, uniques.key)} = ${qualifiedColumn(relation, uniques.key)} + AND ${auditedRowTerm(PEER, uniques)} + AND ${qualifiedColumn(PEER, uniques.nodeKind)} <> ${qualifiedColumn(relation, uniques.nodeKind)} ) `; } /** - * Live edges of the named kinds sharing one declared cardinality's population - * with another live edge. + * Live edges of the named kinds sharing one declared cardinality axis's + * population with another live edge. * - * Which endpoints the population is keyed by and what a member must still BE - * are read from {@link EDGE_CARDINALITY_SPECS} — the same table the probe and - * the claim's SQL read — so the audit cannot report a population the fence does - * not fence, or miss one it does. One statement per cardinality, because that - * is the granularity at which the spec differs. + * Which endpoint the population is keyed by (`keyShape`) and what a member + * must still BE (`holderLiveness`) are read from + * {@link edgeCardinalitySpec} — the same table the probe and the claim's SQL + * read — so the audit cannot report a population the fence does not fence, or + * miss one it does: `"from"` emits from-terms only, `"to"` emits to-terms + * only, `"fromAndTo"` emits both. One statement per axis, because that is the + * granularity at which the spec differs. */ export function buildContendedEdgeRowAudit( tables: Tables, graphId: string, - cardinality: ConstrainedCardinality, + ref: EdgeCardinalityAxisRef, edgeKinds: readonly string[], ): SQL { const { edges } = tables; const relation = getTableName(edges); - const spec = EDGE_CARDINALITY_SPECS[cardinality]; - const activeOnly = - spec.holderLiveness === "liveAndActive" ? - sql` AND ${qualified(relation, edges.validTo)} IS NULL` - : sql.empty(); - const peerActiveOnly = - spec.holderLiveness === "liveAndActive" ? - sql` AND ${qualified(PEER, edges.validTo)} IS NULL` - : sql.empty(); - const peerToEndpoints = - spec.keyShape === "fromAndTo" ? - sql` - AND ${qualified(PEER, edges.toKind)} = ${qualified(relation, edges.toKind)} - AND ${qualified(PEER, edges.toId)} = ${qualified(relation, edges.toId)} - ` - : sql.empty(); + const spec = edgeCardinalitySpec(ref); + const activeOnly = holderLivenessTerm( + axisRowMembership(ref), + relation, + edges, + ); + const peerActiveOnly = holderLivenessTerm( + axisRowMembership(ref), + PEER, + edges, + ); + // The endpoint fold belongs to `endpointTerms` — the same renderer the + // write-path fence reads it through — given the OUTER row's own qualified + // columns in place of a write's bound literals, so the audit cannot key the + // population on a different subset of endpoints than the fence does. + const peerEndpoints = endpointTerms(PEER, edges, spec.keyShape, { + fromKind: qualifiedColumn(relation, edges.fromKind), + fromId: qualifiedColumn(relation, edges.fromId), + toKind: qualifiedColumn(relation, edges.toKind), + toId: qualifiedColumn(relation, edges.toId), + }); return sql` - SELECT - ${quotedColumn(edges.id)} as edge_id, - ${quotedColumn(edges.kind)} as edge_kind, - ${quotedColumn(edges.fromKind)} as from_kind, - ${quotedColumn(edges.fromId)} as from_id, - ${quotedColumn(edges.toKind)} as to_kind, - ${quotedColumn(edges.toId)} as to_id + SELECT${edgeAuditProjection(edges)} + FROM ${edges} + WHERE ${qualifiedColumn(relation, edges.graphId)} = ${graphId} + AND ${qualifiedColumn(relation, edges.kind)} IN (${inList(edgeKinds)}) + AND ${auditedRowTerm(relation, edges)}${activeOnly} + AND EXISTS ( + SELECT 1 FROM ${edges} AS ${sql.raw(`"${PEER}"`)} + WHERE ${qualifiedColumn(PEER, edges.graphId)} = ${qualifiedColumn(relation, edges.graphId)} + AND ${qualifiedColumn(PEER, edges.kind)} = ${qualifiedColumn(relation, edges.kind)}${peerEndpoints} + AND ${auditedRowTerm(PEER, edges)}${peerActiveOnly} + AND ${qualifiedColumn(PEER, edges.id)} <> ${qualifiedColumn(relation, edges.id)} + ) + `; +} + +/** + * Live composition-scoped edges sharing one PART identity with another live + * edge, across every realizing edge kind and orientation. + * + * `buildContendedEdgeRowAudit`'s peer test (`peer.kind = relation.kind`) is + * wrong here: the composition claim's axis is relation-wide, so a `Chapter` + * attached via `chapterOf` (`partSide: "from"`) and the SAME `Chapter` + * attached via `includedIn` (`partSide: "to"`) must be found contending even + * though they are different edge kinds in different orientations. The peer + * test below IS {@link file://./edge-claims.ts claimHolderTerms} — the same + * function the write-path fence calls — given the OUTER row's own qualified + * part column instead of a write's bound literal, so this audit and the + * fence can never render two different answers to "does this row hold the + * axis this claim contends for". + * + * `ref` fixes which side of the OUTER row is the part (`edgeCardinalitySpec`'s + * `keyShape`, always `"from"` or `"to"` for a composition ref — never + * `"fromAndTo"`), so only the outer row's own side needs qualifying; the + * PEER may be either side, which `claimHolderTerms`' two-arm OR already + * expresses. + */ +export function buildContendedCompositionEdgeRowAudit( + tables: Tables, + graphId: string, + ref: EdgeCardinalityAxisRef, + holders: CompositionClaimScope["holders"], + reportedEdgeKinds: readonly string[], +): SQL { + const { edges } = tables; + const relation = getTableName(edges); + const spec = edgeCardinalitySpec(ref); + const outerPartKindColumn = partEndpoint( + spec.keyShape, + edges.fromKind, + edges.toKind, + ); + const outerPartIdColumn = partEndpoint( + spec.keyShape, + edges.fromId, + edges.toId, + ); + const activeOnly = holderLivenessTerm( + axisRowMembership(ref), + relation, + edges, + ); + const peerActiveOnly = holderLivenessTerm( + axisRowMembership(ref), + PEER, + edges, + ); + + // The composition overload of `claimHolderTerms`: given a part identity in + // place of a claim value source, it needs nothing beyond the axis ref and + // `scope` — no fabricated `edgeKind`/`fromKind`/`fromId`/`toKind`/`toId` + // for a caller to invent or a reader to check is unread. + const peerHolderTerms = claimHolderTerms( + PEER, + edges, + { ...ref, scope: { kind: "composition", holders } }, + { + part: { + kind: qualifiedColumn(relation, outerPartKindColumn), + id: qualifiedColumn(relation, outerPartIdColumn), + }, + }, + ); + + return sql` + SELECT${edgeAuditProjection(edges)} FROM ${edges} - WHERE ${qualified(relation, edges.graphId)} = ${graphId} - AND ${qualified(relation, edges.kind)} IN (${inList(edgeKinds)}) - AND ${qualified(relation, edges.deletedAt)} IS NULL${activeOnly} + WHERE ${qualifiedColumn(relation, edges.graphId)} = ${graphId} + AND ${qualifiedColumn(relation, edges.kind)} IN (${inList(reportedEdgeKinds)}) + AND ${auditedRowTerm(relation, edges)}${activeOnly} AND EXISTS ( SELECT 1 FROM ${edges} AS ${sql.raw(`"${PEER}"`)} - WHERE ${qualified(PEER, edges.graphId)} = ${qualified(relation, edges.graphId)} - AND ${qualified(PEER, edges.kind)} = ${qualified(relation, edges.kind)} - AND ${qualified(PEER, edges.fromKind)} = ${qualified(relation, edges.fromKind)} - AND ${qualified(PEER, edges.fromId)} = ${qualified(relation, edges.fromId)}${peerToEndpoints} - AND ${qualified(PEER, edges.deletedAt)} IS NULL${peerActiveOnly} - AND ${qualified(PEER, edges.id)} <> ${qualified(relation, edges.id)} + WHERE ${qualifiedColumn(PEER, edges.graphId)} = ${qualifiedColumn(relation, edges.graphId)} + AND ${peerHolderTerms} + AND ${auditedRowTerm(PEER, edges)}${peerActiveOnly} + AND ${qualifiedColumn(PEER, edges.id)} <> ${qualifiedColumn(relation, edges.id)} ) `; } @@ -163,9 +284,71 @@ export function buildDisjointOverlapAudit( const liveIdsOfKind = (kind: string): SQL => sql` SELECT ${quotedColumn(nodes.id)} as node_id FROM ${nodes} - WHERE ${qualified(relation, nodes.graphId)} = ${graphId} - AND ${qualified(relation, nodes.kind)} = ${kind} - AND ${qualified(relation, nodes.deletedAt)} IS NULL + WHERE ${qualifiedColumn(relation, nodes.graphId)} = ${graphId} + AND ${qualifiedColumn(relation, nodes.kind)} = ${kind} + AND ${auditedRowTerm(relation, nodes)} `; return sql`${liveIdsOfKind(kinds[0])} INTERSECT ${liveIdsOfKind(kinds[1])}`; } + +/** + * Live edges of one kind whose `(from_kind, to_kind)` matches no declared + * pair. + * + * A pair list rather than a per-side kind list because a source-dependent + * target map (`targetKindsBySource`) admits pairs, not a Cartesian product — + * and because the caller already expanded subsumption + * (`expandEdgeEndpointAllowance`), so this statement compares literals only. + * An EMPTY allowance list means the declaration admits nothing: every live + * edge of the kind is returned, and the statement renders no pair predicate + * at all. + * + * The admitted-pairs list is rendered as a `VALUES` derived table joined by + * `NOT EXISTS`, never as a disjunction of bound equality pairs: a flat `OR` + * chain nests one boolean operator per pair, so its parsed expression tree + * grows with the pair count and exceeds SQLite's `SQLITE_MAX_EXPR_DEPTH` + * (1000) on an ordinary subclass hierarchy — a root with ~40 direct + * subclasses and one edge kind over it already renders (41)² ≈ 1681 pairs. + * `VALUES` rows are siblings in the parse tree, not nested expressions, so + * this predicate's depth is constant in the pair count; the caller + * ({@link file://../../operation-backend-core.ts readConstraintFenceViolations}) + * still chunks `allowedPairs` to the connection's bound-parameter budget and + * intersects the per-chunk results, because that budget (not expression + * depth) is what an unbounded pair count can still exceed on every dialect. + * + * "Live" is {@link auditedRowTerm}, the population every row-shape family + * reads: a future-dated edge becomes current once the clock reaches it, and an + * ended one is what an `asOf` read inside its window returns, so neither may + * sit on a pair the declaration no longer admits. + */ +export function buildMisassignedEdgeEndpointAudit( + tables: Tables, + graphId: string, + edgeKind: string, + allowedPairs: readonly (readonly [string, string])[], +): SQL { + const { edges } = tables; + const relation = getTableName(edges); + const admittedPredicate = + allowedPairs.length === 0 ? + sql.empty() + : sql` + AND NOT EXISTS ( + SELECT 1 FROM (VALUES ${sql.join( + allowedPairs.map( + ([fromKind, toKind]) => sql`(${fromKind}, ${toKind})`, + ), + sql`, `, + )}) AS ${sql.raw(`"${ALLOWED_PAIR_ALIAS}"`)} + WHERE ${sql.raw(`"${ALLOWED_PAIR_ALIAS}".column1`)} = ${qualifiedColumn(relation, edges.fromKind)} + AND ${sql.raw(`"${ALLOWED_PAIR_ALIAS}".column2`)} = ${qualifiedColumn(relation, edges.toKind)} + ) + `; + return sql` + SELECT${edgeAuditProjection(edges)} + FROM ${edges} + WHERE ${qualifiedColumn(relation, edges.graphId)} = ${graphId} + AND ${qualifiedColumn(relation, edges.kind)} = ${edgeKind} + AND ${auditedRowTerm(relation, edges)}${admittedPredicate} + `; +} diff --git a/packages/typegraph/src/backend/drizzle/operations/edge-claims.ts b/packages/typegraph/src/backend/drizzle/operations/edge-claims.ts index 794bd69e6..cda60e626 100644 --- a/packages/typegraph/src/backend/drizzle/operations/edge-claims.ts +++ b/packages/typegraph/src/backend/drizzle/operations/edge-claims.ts @@ -1,22 +1,30 @@ import { getTableName, type SQL, sql } from "drizzle-orm"; +import { sql as fragmentSql } from "../../../query/sql-fragment"; +import { type RowMembership } from "../../../registry/row-membership"; import { - type ConstrainedCardinality, - EDGE_CARDINALITY_SPECS, + axisRowMembership, + edgeCardinalityAxisName, + type EdgeCardinalityAxisRef, edgeCardinalityClaimTarget, type EdgeCardinalitySpec, + edgeCardinalitySpec, } from "../../../store/claims/edge-claims"; import { resolveStampedValidityLowerBound } from "../../../utils/date"; import type { ClaimEdgeCardinalityParams, + CompositionClaimScope, InsertEdgeParams, PurgeEdgeClaimsParams, SchemaWriteFenceParams, + SqlDialect, } from "../../types"; import { buildLiveEndpointPredicate, liveEndpointLockOrder } from "./edges"; import { castBoundValueForColumn, + codePointOrderKey, edgeColumnList, + qualifiedColumn, quotedColumn, quotedTableName, type RowLockClause, @@ -24,23 +32,6 @@ import { type Tables, } from "./shared"; -/** - * Qualifies a column with its relation, the one rendering both dialects read - * identically. The claim statements name TWO relations (the claim rows and the - * edges their holders are), so a bare column name would be ambiguous in the - * takeover statement's correlated subquery. - */ -function qualified(tableName: string, column: Readonly<{ name: string }>): SQL { - return sql.raw(`"${tableName}"."${column.name}"`); -} - -function qualifiedAlias( - alias: string, - column: Readonly<{ name: string }>, -): SQL { - return sql.raw(`"${alias}"."${column.name.replaceAll('"', '""')}"`); -} - /** * WHERE a claim statement reads the per-edge values its predicates compare * against. @@ -61,7 +52,9 @@ type ClaimValueSource = Readonly<{ toId: SQL; }>; -function boundClaimValues(params: ClaimEdgeCardinalityParams): ClaimValueSource { +function boundClaimValues( + params: ClaimEdgeCardinalityParams, +): ClaimValueSource { return { graphId: sql`${params.graphId}`, edgeId: sql`${params.edgeId}`, @@ -124,7 +117,7 @@ export const ATOMIC_EDGE_CLAIM_PROPOSED_COLUMN_COUNT = PROPOSED_COLUMN_ORDER.length; function proposedValue(column: ProposedColumn): SQL { - return qualifiedAlias(PROPOSED_RELATION, { name: PROPOSED_COLUMNS[column] }); + return qualifiedColumn(PROPOSED_RELATION, { name: PROPOSED_COLUMNS[column] }); } const PROPOSED_CLAIM_VALUES: ClaimValueSource = { @@ -166,9 +159,9 @@ function proposedRelationCte( ): SQL { const columnTypes = proposedColumnTypeSources(tables); const header = sql.raw( - PROPOSED_COLUMN_ORDER.map( - (column) => `"${PROPOSED_COLUMNS[column]}"`, - ).join(", "), + PROPOSED_COLUMN_ORDER.map((column) => `"${PROPOSED_COLUMNS[column]}"`).join( + ", ", + ), ); const rows = entries.map((entry) => { const target = edgeCardinalityClaimTarget(entry); @@ -197,39 +190,272 @@ function proposedRelationCte( `; } +/** + * The axis a claim statement's holder predicate is rendered from: the declared + * population, plus the composition scope when the axis is the reserved + * relation-wide one. Predicate SHAPE — never a per-row value — which is why it + * is a group key below rather than a column of the `proposed` relation. + */ +type ClaimHolderAxis = EdgeCardinalityAxisRef & + Readonly<{ scope?: CompositionClaimScope }>; + +/** + * What distinguishes two claims whose predicates cannot share one statement. + * + * The claim predicates are shaped by {@link EdgeCardinalitySpec} — which + * endpoints the axis key covers, and what a holder must still be, both read + * off the axis NAME, so a source axis and a target axis of the same + * cardinality are different shapes — and, for a composition claim, by the + * oriented holder kinds its scope names. None of that is a value, so it + * cannot ride in the `proposed` relation without becoming an OR-guarded term + * the planner can no longer seek on, which is the cost the relation shape + * exists to remove. + * + * The scope's holders enter the key IN ORDER, because that is the order + * {@link claimHolderTerms} renders its arms in: two entries share a statement + * only when that statement's text is the one both of them need. + */ +export function claimPredicateShapeKey(entry: ClaimHolderAxis): string { + return JSON.stringify([ + edgeCardinalityAxisName(entry), + entry.scope?.kind, + entry.scope?.holders.map((holder) => [holder.partSide, holder.edgeKind]), + ]); +} + /** * Splits a chunk into the groups that can share one statement. * - * The claim predicates are shaped by {@link EdgeCardinalitySpec}: which - * endpoints the axis key covers, and what a holder must still be. Those are - * predicate SHAPE, not values, so they cannot ride in the `proposed` relation - * without becoming OR-guarded terms the planner can no longer seek on — which - * is the cost this rewrite exists to remove. One statement per distinct - * cardinality keeps every term index-seekable; a chunk of one edge kind, the - * ordinary case, still renders exactly one statement. + * One statement per distinct predicate shape keeps every term index-seekable; + * a chunk of one edge kind, the ordinary case, still renders exactly one. * * First-appearance order, so a rendered program is deterministic. */ -function groupEntriesByCardinality( +function groupEntriesByPredicateShape( entries: readonly ClaimEdgeCardinalityParams[], ): readonly (readonly ClaimEdgeCardinalityParams[])[] { - const groups = new Map(); + const groups = new Map(); for (const entry of entries) { - const group = groups.get(entry.cardinality); - if (group === undefined) groups.set(entry.cardinality, [entry]); + const shapeKey = claimPredicateShapeKey(entry); + const group = groups.get(shapeKey); + if (group === undefined) groups.set(shapeKey, [entry]); else group.push(entry); } return [...groups.values()]; } -function specOf( +/** + * The group's shape witness: the entry whose axis ref and composition scope + * every other entry in the group shares by construction + * ({@link claimPredicateShapeKey}). + * + * The axis itself rather than a spec derived from it, because the holder + * predicate reads both halves of the shape — the spec's `keyShape` and + * `holderLiveness`, and the scope's oriented holder kinds — and + * {@link claimHolderTerms} is the one function allowed to fold them. + */ +function axisOf( entries: readonly ClaimEdgeCardinalityParams[], -): EdgeCardinalitySpec { +): ClaimHolderAxis { const [first] = entries; if (first === undefined) { - throw new TypeError("A cardinality group is never empty."); + throw new TypeError("A claim group is never empty."); + } + return first; +} + +/** + * The endpoint columns an endpoint term compares against: a write's bound + * literals, a batch statement's `proposed` columns, or — for the read-only + * audit — the outer row's own qualified columns. Narrower than + * {@link ClaimValueSource} so the compiler, not a comment, is what proves this + * fold reads no other field. + */ +type EndpointValueSource = Pick< + ClaimValueSource, + "fromKind" | "fromId" | "toKind" | "toId" +>; + +/** + * The endpoint terms {@link EdgeCardinalitySpec.keyShape} says a predicate + * must read: from-terms for `"from"`, to-terms for `"to"`, both for + * `"fromAndTo"`. The one renderer of that fold, so a source-axis predicate, a + * target-axis predicate and the read-only audit's correlated peer test cannot + * spell two different subsets of these columns. + */ +export function endpointTerms( + edgesName: string, + edges: Tables["edges"], + keyShape: EdgeCardinalitySpec["keyShape"], + values: EndpointValueSource, +): SQL { + const fromTerms = + keyShape === "from" || keyShape === "fromAndTo" ? + sql` AND ${qualifiedColumn(edgesName, edges.fromKind)} = ${values.fromKind} AND ${qualifiedColumn(edgesName, edges.fromId)} = ${values.fromId}` + : sql``; + const toTerms = + keyShape === "to" || keyShape === "fromAndTo" ? + sql` AND ${qualifiedColumn(edgesName, edges.toKind)} = ${values.toKind} AND ${qualifiedColumn(edgesName, edges.toId)} = ${values.toId}` + : sql``; + return sql`${fromTerms}${toTerms}`; +} + +/** + * Where a composition arm reads the PART's identity, for the one caller that + * cannot supply a {@link ClaimValueSource}: the read-only audit + * ({@link file://./constraint-fence-audit.ts + * buildContendedCompositionEdgeRowAudit}) correlates its peer test to the + * OUTER row's own qualified part columns rather than to a write's bound + * literal. Distinct from `ClaimValueSource` so the compiler, not a comment, is + * what proves that branch reads no other field — the audit has no `edgeKind`, + * no endpoint tuple and no edge id to offer. + */ +type ClaimPartIdentity = Readonly<{ part: Readonly<{ kind: SQL; id: SQL }> }>; + +/** + * THE rows that can hold this claim: which edge kinds, and — for a + * composition claim — on which endpoint. The one owner of that decision, so + * {@link competingLiveEdgePredicate}, {@link recordedClaimHolderIsLivePredicate} + * and the read-only audit's correlated peer test + * ({@link file://./constraint-fence-audit.ts + * buildContendedCompositionEdgeRowAudit}) cannot render two different + * answers to "does this row hold the axis this claim contends for". + * + * The values come from {@link ClaimValueSource}, so the single-row statements + * (which bind them) and the batch statements (which read them off the + * `proposed` relation) get the same predicate from the same renderer. + * + * `scope === undefined` (the ordinary case): `kind = values.edgeKind`, plus + * the endpoint terms {@link endpointTerms} renders off `keyShape`. + * + * `scope !== undefined` (a composition claim): the claim's key is the PART's + * identity regardless of which orientation wrote it, so a holder is any row + * of ANY holder edge kind whose PART-side endpoint matches that identity — an + * OR over the two oriented arms `scope.holders` carries: + * `kind IN (fromSideKinds) AND from_kind/from_id = the part` for a + * `partSide: "from"` holder, `kind IN (toSideKinds) AND to_kind/to_id = the + * part` for a `partSide: "to"` one. This is what lets `chapterOf` + * (`Chapter -> Book`, part `from`) and `includedIn` (`Anthology -> Chapter`, + * part `to`) contend for the SAME Chapter's one whole even though they are + * different edge kinds in different orientations. + * + * A {@link ClaimPartIdentity} in place of the value source overrides where the + * part's own kind and id come from: every write-path caller passes values, and + * the part is then the `fromKind`/`fromId` or `toKind`/`toId` the axis keys on; + * the correlated audit instead passes the OUTER row's own qualified columns, so + * the peer test reads "matches the part THIS row names" rather than a literal + * captured ahead of time. The two overloads are what let that caller pass a + * scope-only axis with no values at all, rather than fabricating placeholders + * for fields the branch never reads. + * + * Because the holder kinds and the key shape are predicate SHAPE rather than + * values, a batch statement renders this fragment ONCE for its whole group — + * see {@link claimPredicateShapeKey}. + */ +export function claimHolderTerms( + edgesName: string, + edges: Tables["edges"], + axis: ClaimHolderAxis, + values: ClaimValueSource, +): SQL; +export function claimHolderTerms( + edgesName: string, + edges: Tables["edges"], + axis: EdgeCardinalityAxisRef & Readonly<{ scope: CompositionClaimScope }>, + values: ClaimPartIdentity, +): SQL; +export function claimHolderTerms( + edgesName: string, + edges: Tables["edges"], + axis: ClaimHolderAxis, + values: ClaimValueSource | ClaimPartIdentity, +): SQL { + const spec = edgeCardinalitySpec(axis); + if (axis.scope === undefined) { + // The first overload guarantees a full value source whenever `scope` is + // absent — the ordinary claim shape, whose predicate reads the edge kind + // and the endpoints its key covers. + const bound = values as ClaimValueSource; + return sql`${qualifiedColumn(edgesName, edges.kind)} = ${bound.edgeKind}${endpointTerms(edgesName, edges, spec.keyShape, bound)}`; } - return EDGE_CARDINALITY_SPECS[first.cardinality]; + const part = + "part" in values ? + values.part + : { + kind: partEndpoint(spec.keyShape, values.fromKind, values.toKind), + id: partEndpoint(spec.keyShape, values.fromId, values.toId), + }; + // One arm per ORIENTED side, always `from` before `to`, so two axes whose + // holders agree render the identical statement text regardless of the order + // `scope.holders` lists them in. + const sides = [ + { partSide: "from", kindColumn: edges.fromKind, idColumn: edges.fromId }, + { partSide: "to", kindColumn: edges.toKind, idColumn: edges.toId }, + ] as const; + const arms: SQL[] = []; + for (const side of sides) { + const sideKinds = axis.scope.holders + .filter((holder) => holder.partSide === side.partSide) + .map((holder) => holder.edgeKind); + if (sideKinds.length === 0) continue; + arms.push(sql` + ( + ${qualifiedColumn(edgesName, edges.kind)} IN (${sql.join( + sideKinds.map((kind) => sql`${kind}`), + sql`, `, + )}) + AND ${qualifiedColumn(edgesName, side.kindColumn)} = ${part.kind} + AND ${qualifiedColumn(edgesName, side.idColumn)} = ${part.id} + ) + `); + } + // A composition claim always names its own edge kind on the matching side + // (`compositionClaim`, `src/store/claims/composition-claims.ts`), so + // `arms` is never empty in practice; the fallback keeps this total rather + // than emitting invalid SQL for a hand-built axis with no holders. + return arms.length === 0 ? sql`FALSE` : sql`(${sql.join(arms, sql` OR `)})`; +} + +/** + * Which of an axis's two endpoint values is the PART its key covers: `from` + * for a `"from"`-keyed axis, `to` otherwise. The one decision the write-path + * fence and the correlated audit both read, so they cannot pick opposite + * sides. + */ +export function partEndpoint( + keyShape: EdgeCardinalitySpec["keyShape"], + from: From, + to: To, +): From | To { + return keyShape === "from" ? from : to; +} + +/** + * What a member of a cardinality population must still BE: the SQL rendering + * of `membershipCountsRow` under the axis's {@link axisRowMembership} + * rule — an `"openRows"` population additionally requires an open validity + * window. Rendered for the outer row and for the correlated peer from the + * same ternary, so the two sides of one statement cannot disagree. + * + * The second of the rule's two SQL renderings, beside + * `compileEdgeMembershipFilters` (`src/query/compiler/temporal.ts`), and + * deliberately not routed through it: that one renders a relation of several + * edge kinds — a per-kind guard on each `"openRows"` kind, its own + * `deleted_at` term, a read coordinate's instant and recorded narrowing — as + * TypeGraph SQL fragments, while this one renders one axis's population, whose + * statement already pins the edge kind and the `deleted_at` term, as a Drizzle + * fragment over the backend's own table objects on the claim hot path. Both + * read the same {@link RowMembership} value, so neither can choose its own + * rule. + */ +export function holderLivenessTerm( + membership: RowMembership, + relation: string, + edges: Tables["edges"], +): SQL { + return membership === "openRows" ? + sql` AND ${qualifiedColumn(relation, edges.validTo)} IS NULL` + : sql.empty(); } /** @@ -240,26 +466,21 @@ function specOf( function competingLiveEdgePredicate( tables: Tables, values: ClaimValueSource, - spec: EdgeCardinalitySpec, + axis: ClaimHolderAxis, ): SQL { const { edges } = tables; const edgesName = getTableName(edges); - const toEndpointTerms = - spec.keyShape === "fromAndTo" ? - sql` AND ${qualified(edgesName, edges.toKind)} = ${values.toKind} AND ${qualified(edgesName, edges.toId)} = ${values.toId}` - : sql``; - const activeTerm = - spec.holderLiveness === "liveAndActive" ? - sql` AND ${qualified(edgesName, edges.validTo)} IS NULL` - : sql``; + const activeTerm = holderLivenessTerm( + axisRowMembership(axis), + edgesName, + edges, + ); return sql` - ${qualified(edgesName, edges.graphId)} = ${values.graphId} - AND ${qualified(edgesName, edges.id)} <> ${values.edgeId} - AND ${qualified(edgesName, edges.deletedAt)} IS NULL - AND ${qualified(edgesName, edges.kind)} = ${values.edgeKind} - AND ${qualified(edgesName, edges.fromKind)} = ${values.fromKind} - AND ${qualified(edgesName, edges.fromId)} = ${values.fromId}${toEndpointTerms}${activeTerm} + ${qualifiedColumn(edgesName, edges.graphId)} = ${values.graphId} + AND ${qualifiedColumn(edgesName, edges.id)} <> ${values.edgeId} + AND ${qualifiedColumn(edgesName, edges.deletedAt)} IS NULL + AND ${claimHolderTerms(edgesName, edges, axis, values)}${activeTerm} `; } @@ -271,17 +492,17 @@ function proposedEndpointsLivePredicate( return sql` EXISTS ( SELECT 1 FROM ${nodes} AS "from_node" - WHERE ${qualifiedAlias("from_node", nodes.graphId)} = ${values.graphId} - AND ${qualifiedAlias("from_node", nodes.kind)} = ${values.fromKind} - AND ${qualifiedAlias("from_node", nodes.id)} = ${values.fromId} - AND ${qualifiedAlias("from_node", nodes.deletedAt)} IS NULL + WHERE ${qualifiedColumn("from_node", nodes.graphId)} = ${values.graphId} + AND ${qualifiedColumn("from_node", nodes.kind)} = ${values.fromKind} + AND ${qualifiedColumn("from_node", nodes.id)} = ${values.fromId} + AND ${qualifiedColumn("from_node", nodes.deletedAt)} IS NULL ) AND EXISTS ( SELECT 1 FROM ${nodes} AS "to_node" - WHERE ${qualifiedAlias("to_node", nodes.graphId)} = ${values.graphId} - AND ${qualifiedAlias("to_node", nodes.kind)} = ${values.toKind} - AND ${qualifiedAlias("to_node", nodes.id)} = ${values.toId} - AND ${qualifiedAlias("to_node", nodes.deletedAt)} IS NULL + WHERE ${qualifiedColumn("to_node", nodes.graphId)} = ${values.graphId} + AND ${qualifiedColumn("to_node", nodes.kind)} = ${values.toKind} + AND ${qualifiedColumn("to_node", nodes.id)} = ${values.toId} + AND ${qualifiedColumn("to_node", nodes.deletedAt)} IS NULL ) `; } @@ -314,26 +535,21 @@ function schemaFenceCte( function recordedClaimHolderIsLivePredicate( tables: Tables, values: ClaimValueSource, - spec: EdgeCardinalitySpec, + axis: ClaimHolderAxis, ): SQL { const { edgeClaims, edges } = tables; const claimsName = getTableName(edgeClaims); const edgesName = getTableName(edges); - const toEndpointTerms = - spec.keyShape === "fromAndTo" ? - sql` AND ${qualified(edgesName, edges.toKind)} = ${values.toKind} AND ${qualified(edgesName, edges.toId)} = ${values.toId}` - : sql``; - const activeTerm = - spec.holderLiveness === "liveAndActive" ? - sql` AND ${qualified(edgesName, edges.validTo)} IS NULL` - : sql``; + const activeTerm = holderLivenessTerm( + axisRowMembership(axis), + edgesName, + edges, + ); return sql` - ${qualified(edgesName, edges.graphId)} = ${qualified(claimsName, edgeClaims.graphId)} - AND ${qualified(edgesName, edges.id)} = ${qualified(claimsName, edgeClaims.edgeId)} - AND ${qualified(edgesName, edges.deletedAt)} IS NULL - AND ${qualified(edgesName, edges.kind)} = ${values.edgeKind} - AND ${qualified(edgesName, edges.fromKind)} = ${values.fromKind} - AND ${qualified(edgesName, edges.fromId)} = ${values.fromId}${toEndpointTerms}${activeTerm} + ${qualifiedColumn(edgesName, edges.graphId)} = ${qualifiedColumn(claimsName, edgeClaims.graphId)} + AND ${qualifiedColumn(edgesName, edges.id)} = ${qualifiedColumn(claimsName, edgeClaims.edgeId)} + AND ${qualifiedColumn(edgesName, edges.deletedAt)} IS NULL + AND ${claimHolderTerms(edgesName, edges, axis, values)}${activeTerm} `; } @@ -342,7 +558,8 @@ function recordedClaimHolderIsLivePredicate( * The schema and endpoint gates ensure a stale fence remains a side-effect-free * no-op; a later edge refusal rolls this mutation back with the whole program. * - * One statement per cardinality group; see {@link groupEntriesByCardinality}. + * One statement per predicate-shape group; see + * {@link groupEntriesByPredicateShape}. * * The `stale` CTE names its projection distinctly (`stale_graph_id`, …): the * row-value comparison the DELETE ends with is against the target relation's @@ -358,27 +575,27 @@ export function buildDeleteStaleAtomicEdgeClaims( const { edgeClaims, edges } = tables; const claimsName = getTableName(edgeClaims); const values = PROPOSED_CLAIM_VALUES; - return groupEntriesByCardinality(entries).map((group) => { - const spec = specOf(group); + return groupEntriesByPredicateShape(entries).map((group) => { + const axis = axisOf(group); return sql` WITH ${schemaFenceCte(tables, schemaFence, schemaLockClause)}, ${proposedRelationCte(tables, group)}, "stale" AS ( SELECT - ${qualified(claimsName, edgeClaims.graphId)} AS "stale_graph_id", - ${qualified(claimsName, edgeClaims.axis)} AS "stale_axis", - ${qualified(claimsName, edgeClaims.key)} AS "stale_key" + ${qualifiedColumn(claimsName, edgeClaims.graphId)} AS "stale_graph_id", + ${qualifiedColumn(claimsName, edgeClaims.axis)} AS "stale_axis", + ${qualifiedColumn(claimsName, edgeClaims.key)} AS "stale_key" FROM ${edgeClaims} JOIN ${PROPOSED_RELATION_REF} - ON ${qualified(claimsName, edgeClaims.graphId)} = ${values.graphId} - AND ${qualified(claimsName, edgeClaims.axis)} = ${proposedValue("axis")} - AND ${qualified(claimsName, edgeClaims.key)} = ${proposedValue("key")} - WHERE ${qualified(claimsName, edgeClaims.edgeId)} <> ${values.edgeId} + ON ${qualifiedColumn(claimsName, edgeClaims.graphId)} = ${values.graphId} + AND ${qualifiedColumn(claimsName, edgeClaims.axis)} = ${proposedValue("axis")} + AND ${qualifiedColumn(claimsName, edgeClaims.key)} = ${proposedValue("key")} + WHERE ${qualifiedColumn(claimsName, edgeClaims.edgeId)} <> ${values.edgeId} AND EXISTS (SELECT 1 FROM "schema_fence") AND ${proposedEndpointsLivePredicate(tables, values)} AND NOT EXISTS ( SELECT 1 FROM ${edges} - WHERE ${recordedClaimHolderIsLivePredicate(tables, values, spec)} + WHERE ${recordedClaimHolderIsLivePredicate(tables, values, axis)} ) ) DELETE FROM ${edgeClaims} @@ -394,7 +611,8 @@ export function buildDeleteStaleAtomicEdgeClaims( /** * Acquires every still-unowned axis before inserting the guarded edge rows. * - * One statement per cardinality group; see {@link groupEntriesByCardinality}. + * One statement per predicate-shape group; see + * {@link groupEntriesByPredicateShape}. */ export function buildAcquireAtomicEdgeClaims( tables: Tables, @@ -411,8 +629,8 @@ export function buildAcquireAtomicEdgeClaims( `"${edgeClaims.graphId.name}", "${edgeClaims.axis.name}", "${edgeClaims.key.name}"`, ); const values = PROPOSED_CLAIM_VALUES; - return groupEntriesByCardinality(entries).map((group) => { - const spec = specOf(group); + return groupEntriesByPredicateShape(entries).map((group) => { + const axis = axisOf(group); return sql` WITH ${schemaFenceCte(tables, schemaFence, schemaLockClause)}, ${proposedRelationCte(tables, group)} @@ -427,7 +645,7 @@ export function buildAcquireAtomicEdgeClaims( WHERE ${proposedEndpointsLivePredicate(tables, values)} AND NOT EXISTS ( SELECT 1 FROM ${edges} - WHERE ${competingLiveEdgePredicate(tables, values, spec)} + WHERE ${competingLiveEdgePredicate(tables, values, axis)} ) ON CONFLICT (${conflictColumns}) DO NOTHING `; @@ -444,7 +662,8 @@ export function buildAcquireAtomicEdgeClaims( * refusal leg in a rendered program — the backend tests discriminate the three * claim phases by them. * - * One statement per cardinality group; see {@link groupEntriesByCardinality}. + * One statement per predicate-shape group; see + * {@link groupEntriesByPredicateShape}. */ export function buildAssertAtomicEdgeClaimsOwned( tables: Tables, @@ -459,7 +678,7 @@ export function buildAssertAtomicEdgeClaimsOwned( `"${edgeClaims.graphId.name}", "${edgeClaims.axis.name}", "${edgeClaims.key.name}", "${edgeClaims.edgeId.name}", "${edgeClaims.updatedAt.name}"`, ); const values = PROPOSED_CLAIM_VALUES; - return groupEntriesByCardinality(entries).map( + return groupEntriesByPredicateShape(entries).map( (group) => sql` WITH ${schemaFenceCte(tables, schemaFence, schemaLockClause)}, ${proposedRelationCte(tables, group)} @@ -474,10 +693,10 @@ export function buildAssertAtomicEdgeClaimsOwned( WHERE ${proposedEndpointsLivePredicate(tables, values)} AND NOT EXISTS ( SELECT 1 FROM ${edgeClaims} - WHERE ${qualified(claimsName, edgeClaims.graphId)} = ${values.graphId} - AND ${qualified(claimsName, edgeClaims.axis)} = ${proposedValue("axis")} - AND ${qualified(claimsName, edgeClaims.key)} = ${proposedValue("key")} - AND ${qualified(claimsName, edgeClaims.edgeId)} = ${values.edgeId} + WHERE ${qualifiedColumn(claimsName, edgeClaims.graphId)} = ${values.graphId} + AND ${qualifiedColumn(claimsName, edgeClaims.axis)} = ${proposedValue("axis")} + AND ${qualifiedColumn(claimsName, edgeClaims.key)} = ${proposedValue("key")} + AND ${qualifiedColumn(claimsName, edgeClaims.edgeId)} = ${values.edgeId} ) LIMIT 1 `, @@ -525,7 +744,7 @@ export function buildLockEdgeClaims( INSERT INTO ${edgeClaims} (${columns}) VALUES ${valueRows} ON CONFLICT (${conflictColumns}) - DO UPDATE SET ${quotedColumn(edgeClaims.updatedAt)} = ${qualified(claimsName, edgeClaims.updatedAt)} + DO UPDATE SET ${quotedColumn(edgeClaims.updatedAt)} = ${qualifiedColumn(claimsName, edgeClaims.updatedAt)} RETURNING ${quotedColumn(edgeClaims.axis)} as axis, ${quotedColumn(edgeClaims.key)} as key, @@ -533,15 +752,66 @@ export function buildLockEdgeClaims( `; } +/** Incumbent order shared by both incumbent readers: code point, on every engine. */ +function incumbentOrder(tables: Tables, dialect: SqlDialect): SQL { + const { edges } = tables; + const relation = getTableName(edges).replaceAll('"', '""'); + const column = edges.id.name.replaceAll('"', '""'); + return codePointOrderKey(fragmentSql.raw(`"${relation}"."${column}"`), dialect); +} + +/** + * The batch counterpart of {@link buildLockEdgeClaimGuarded}'s + * `incumbent_edge_id`: + * every proposed claim whose axis a live edge OTHER than the proposed one + * already occupies, with that incumbent's id. + * + * {@link buildLockEdgeClaims} reports only the claim relation's holder, so a + * live edge that never wrote a claim row — one that predates the declaration + * fencing its axis — is invisible to it. Rendered from + * {@link competingLiveEdgePredicate}, the fragment the single-row guard reads, + * so the two paths cannot disagree about what an incumbent is. Issued after + * the lock, it reads a snapshot that includes every claimant the lock waited + * on. + * + * Ordered by incumbent id in code-point order on both engines, so a caller keeping the first row per axis reports + * a deterministic one. One statement per predicate-shape group; see + * {@link groupEntriesByPredicateShape}. + */ +export function buildReadEdgeClaimIncumbents( + tables: Tables, + dialect: SqlDialect, + entries: readonly ClaimEdgeCardinalityParams[], +): readonly SQL[] { + const { edges } = tables; + const edgesName = getTableName(edges); + const values = PROPOSED_CLAIM_VALUES; + return groupEntriesByPredicateShape(entries).map((group) => { + const axis = axisOf(group); + return sql` + WITH ${proposedRelationCte(tables, group)} + SELECT + ${proposedValue("axis")} AS axis, + ${proposedValue("key")} AS key, + ${qualifiedColumn(edgesName, edges.id)} AS incumbent_edge_id + FROM ${PROPOSED_RELATION_REF} + JOIN ${edges} ON ${competingLiveEdgePredicate(tables, values, axis)} + ORDER BY ${incumbentOrder(tables, dialect)} + `; + }); +} + /** * Single-row create-or-lock with an entity-relation guard in its RETURNING * projection. The upsert first establishes the cardinality-axis row lock; its - * returned holder resolves concurrent claimants, while `has_incumbent` - * catches rows imported or written before the claim relation existed. + * returned holder resolves concurrent claimants, while `incumbent_edge_id` + * names the live edge already occupying the axis — including one imported or + * written before the claim relation existed — ordered by id exactly as + * {@link buildReadEdgeClaimIncumbents} reports it. * * A matching row with the proposed id is excluded. That is the legitimate * resurrection/reopen case: the same edge is reclaiming the same axis. - * PostgreSQL's READ COMMITTED snapshot does not refresh this `EXISTS` after an + * PostgreSQL's READ COMMITTED snapshot does not refresh this subquery after an * `ON CONFLICT` wait. Managed constrained writes consume the probe-folding * contract only while holding TypeGraph's graph advisory lock, so committed * preexisting rows are visible and peers cannot publish a claimless row during @@ -551,11 +821,13 @@ export function buildLockEdgeClaims( */ export function buildLockEdgeClaimGuarded( tables: Tables, + dialect: SqlDialect, params: ClaimEdgeCardinalityParams, timestamp: string, ): SQL { const { edgeClaims, edges } = tables; const claimsName = getTableName(edgeClaims); + const edgesName = getTableName(edges); const target = edgeCardinalityClaimTarget(params); return sql` @@ -573,13 +845,15 @@ export function buildLockEdgeClaimGuarded( ${sql.identifier(edgeClaims.axis.name)}, ${sql.identifier(edgeClaims.key.name)} ) DO UPDATE SET - ${quotedColumn(edgeClaims.updatedAt)} = ${qualified(claimsName, edgeClaims.updatedAt)} + ${quotedColumn(edgeClaims.updatedAt)} = ${qualifiedColumn(claimsName, edgeClaims.updatedAt)} RETURNING ${quotedColumn(edgeClaims.edgeId)} AS holder_edge_id, - EXISTS ( - SELECT 1 FROM ${edges} - WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(params), EDGE_CARDINALITY_SPECS[params.cardinality])} - ) AS has_incumbent + ( + SELECT ${qualifiedColumn(edgesName, edges.id)} FROM ${edges} + WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(params), params)} + ORDER BY ${incumbentOrder(tables, dialect)} + LIMIT 1 + ) AS incumbent_edge_id `; } @@ -619,7 +893,7 @@ export function buildInsertEdgeIfEndpointsLiveWithCardinalityClaim( FROM live_endpoints WHERE NOT EXISTS ( SELECT 1 FROM ${edges} - WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(claim), EDGE_CARDINALITY_SPECS[claim.cardinality])} + WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(claim), claim)} ) ), claim AS ( @@ -639,7 +913,7 @@ export function buildInsertEdgeIfEndpointsLiveWithCardinalityClaim( ${sql.identifier(edgeClaims.axis.name)}, ${sql.identifier(edgeClaims.key.name)} ) DO UPDATE SET - ${quotedColumn(edgeClaims.updatedAt)} = ${qualified(claimsName, edgeClaims.updatedAt)} + ${quotedColumn(edgeClaims.updatedAt)} = ${qualifiedColumn(claimsName, edgeClaims.updatedAt)} RETURNING ${quotedColumn(edgeClaims.edgeId)} AS holder_edge_id ), @@ -674,13 +948,16 @@ export function buildInsertEdgeIfEndpointsLiveWithCardinalityClaim( * caller-suppliable and graph-unique, so a hard-deleted id can be reused by a * DIFFERENT edge; a claim naming that id would otherwise read as a live holder * and block its axis forever. The extra terms are exactly the components the - * axis and key were built from — and exactly the columns `countEdgesFrom` / + * axis and key were built from — and exactly the columns `countEdgesAtEndpoint` / * `edgeExistsBetween` filter on — so the fence's liveness predicate and the * probe's read the same shape. * - * The `valid_to IS NULL` term and the to-endpoint terms are not spelled here: - * they are read from {@link EDGE_CARDINALITY_SPECS}, the same table the - * TypeScript probe reads. + * The `valid_to IS NULL` term and the endpoint terms are not spelled here: + * `holderLiveness` and `keyShape` are read from {@link edgeCardinalitySpec}, + * the same table the TypeScript probe reads, and {@link claimHolderTerms} + * renders the holder predicate itself — the one seam a new `keyShape` or a + * new claim scope has to extend, since it owns both the ordinary endpoint + * terms ({@link endpointTerms}) and the composition scope's oriented arms. */ export function buildTakeOverEdgeClaim( tables: Tables, @@ -689,20 +966,19 @@ export function buildTakeOverEdgeClaim( ): SQL { const { edgeClaims, edges } = tables; const claimsName = getTableName(edgeClaims); - const spec = EDGE_CARDINALITY_SPECS[params.cardinality]; const target = edgeCardinalityClaimTarget(params); return sql` UPDATE ${edgeClaims} SET ${quotedColumn(edgeClaims.edgeId)} = ${params.edgeId}, ${quotedColumn(edgeClaims.updatedAt)} = ${timestamp} - WHERE ${qualified(claimsName, edgeClaims.graphId)} = ${params.graphId} - AND ${qualified(claimsName, edgeClaims.axis)} = ${target.axis} - AND ${qualified(claimsName, edgeClaims.key)} = ${target.key} - AND ${qualified(claimsName, edgeClaims.edgeId)} <> ${params.edgeId} + WHERE ${qualifiedColumn(claimsName, edgeClaims.graphId)} = ${params.graphId} + AND ${qualifiedColumn(claimsName, edgeClaims.axis)} = ${target.axis} + AND ${qualifiedColumn(claimsName, edgeClaims.key)} = ${target.key} + AND ${qualifiedColumn(claimsName, edgeClaims.edgeId)} <> ${params.edgeId} AND NOT EXISTS ( SELECT 1 FROM ${edges} - WHERE ${recordedClaimHolderIsLivePredicate(tables, boundClaimValues(params), spec)} + WHERE ${recordedClaimHolderIsLivePredicate(tables, boundClaimValues(params), params)} ) RETURNING ${quotedColumn(edgeClaims.edgeId)} as holder_edge_id `; @@ -726,13 +1002,13 @@ export function buildTakeOverEdgeClaimGuarded( UPDATE ${edgeClaims} SET ${quotedColumn(edgeClaims.edgeId)} = ${params.edgeId}, ${quotedColumn(edgeClaims.updatedAt)} = ${timestamp} - WHERE ${qualified(claimsName, edgeClaims.graphId)} = ${params.graphId} - AND ${qualified(claimsName, edgeClaims.axis)} = ${target.axis} - AND ${qualified(claimsName, edgeClaims.key)} = ${target.key} - AND ${qualified(claimsName, edgeClaims.edgeId)} <> ${params.edgeId} + WHERE ${qualifiedColumn(claimsName, edgeClaims.graphId)} = ${params.graphId} + AND ${qualifiedColumn(claimsName, edgeClaims.axis)} = ${target.axis} + AND ${qualifiedColumn(claimsName, edgeClaims.key)} = ${target.key} + AND ${qualifiedColumn(claimsName, edgeClaims.edgeId)} <> ${params.edgeId} AND NOT EXISTS ( SELECT 1 FROM ${edges} - WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(params), EDGE_CARDINALITY_SPECS[params.cardinality])} + WHERE ${competingLiveEdgePredicate(tables, boundClaimValues(params), params)} ) RETURNING ${quotedColumn(edgeClaims.edgeId)} as holder_edge_id `; diff --git a/packages/typegraph/src/backend/drizzle/operations/edges.ts b/packages/typegraph/src/backend/drizzle/operations/edges.ts index 26cf2dcb4..4770f4e7f 100644 --- a/packages/typegraph/src/backend/drizzle/operations/edges.ts +++ b/packages/typegraph/src/backend/drizzle/operations/edges.ts @@ -13,7 +13,7 @@ import type { AtomicEdgeResolvedUpdateEntry, } from "../../capabilities/atomic-mutation-program"; import type { - CountEdgesFromParams, + CountEdgesAtEndpointParams, DeleteEdgeParams, DeleteEdgesBatchParams, EdgeConvergenceMatch, @@ -29,6 +29,7 @@ import { castBoundValueForColumn, edgeColumnList, expectedValidFromPredicate, + qualifiedColumn, quotedColumn, quotedTableName, type RowLockClause, @@ -64,13 +65,6 @@ export type AtomicConvergeEdgesParams = Readonly<{ schemaLockClause: SQL; }>; -function qualifiedColumn( - alias: string, - column: Readonly<{ name: string }>, -): SQL { - return sql.raw(`"${alias}"."${column.name.replaceAll('"', '""')}"`); -} - function atomicEdgeCreatePostimage( params: InsertEdgeParams, timestamp: string, @@ -136,7 +130,8 @@ export function liveEndpointLockOrder( params: Pick, ): readonly string[] { const byKind = compareCodePoints(params.fromKind, params.toKind); - const order = byKind === 0 ? compareCodePoints(params.fromId, params.toId) : byKind; + const order = + byKind === 0 ? compareCodePoints(params.fromId, params.toId) : byKind; return order <= 0 ? LIVE_ENDPOINT_ALIASES : ["to_node", "from_node"]; } @@ -1437,33 +1432,26 @@ export function buildHardDeleteEdgesByNode( } /** - * Builds a query to count edges from a source node. + * Builds a query to count edges at one endpoint (the source or the target). */ -export function buildCountEdgesFrom( +export function buildCountEdgesAtEndpoint( tables: Tables, - params: CountEdgesFromParams, + params: CountEdgesAtEndpointParams, ): SQL { const { edges } = tables; + const kindColumn = params.endpoint === "from" ? edges.fromKind : edges.toKind; + const idColumn = params.endpoint === "from" ? edges.fromId : edges.toId; - if (params.activeOnly) { - return sql` - SELECT COUNT(*) as count FROM ${edges} - WHERE ${edges.graphId} = ${params.graphId} - AND ${edges.kind} = ${params.edgeKind} - AND ${edges.fromKind} = ${params.fromKind} - AND ${edges.fromId} = ${params.fromId} - AND ${edges.deletedAt} IS NULL - AND ${edges.validTo} IS NULL - `; - } + const activeTerm = + params.activeOnly ? sql`\n AND ${edges.validTo} IS NULL` : sql``; return sql` SELECT COUNT(*) as count FROM ${edges} WHERE ${edges.graphId} = ${params.graphId} AND ${edges.kind} = ${params.edgeKind} - AND ${edges.fromKind} = ${params.fromKind} - AND ${edges.fromId} = ${params.fromId} - AND ${edges.deletedAt} IS NULL + AND ${kindColumn} = ${params.endpointKind} + AND ${idColumn} = ${params.endpointId} + AND ${edges.deletedAt} IS NULL${activeTerm} `; } diff --git a/packages/typegraph/src/backend/drizzle/operations/nodes.ts b/packages/typegraph/src/backend/drizzle/operations/nodes.ts index 038f51ba3..1a0382d51 100644 --- a/packages/typegraph/src/backend/drizzle/operations/nodes.ts +++ b/packages/typegraph/src/backend/drizzle/operations/nodes.ts @@ -36,19 +36,13 @@ import { codePointOrderKey, expectedValidFromPredicate, nodeColumnList, + qualifiedColumn, quotedColumn, type RowLockClause, sqlNull, type Tables, } from "./shared"; -function qualifiedColumn( - alias: string, - column: Readonly<{ name: string }>, -): SQL { - return sql.raw(`"${alias}"."${column.name.replaceAll('"', '""')}"`); -} - const INITIAL_NODE_VERSION = 1; const NODE_VERSION_INCREMENT = 1; @@ -1084,11 +1078,7 @@ function buildNodeExpectationPredicate( if (expectation.kind === "absent") { return portableSql`NOT ${adapter.jsonHasPath(propsColumn, pointer)}`; } - return adapter.jsonScalarPathEquals( - propsColumn, - pointer, - expectation.value, - ); + return adapter.jsonScalarPathEquals(propsColumn, pointer, expectation.value); } /** diff --git a/packages/typegraph/src/backend/drizzle/operations/shared.ts b/packages/typegraph/src/backend/drizzle/operations/shared.ts index 0e74ded69..89f7ae994 100644 --- a/packages/typegraph/src/backend/drizzle/operations/shared.ts +++ b/packages/typegraph/src/backend/drizzle/operations/shared.ts @@ -137,6 +137,37 @@ export function quotedColumn(column: { name: string }): SQL { return sql.raw(`"${column.name.replaceAll('"', '""')}"`); } +/** + * The predicate a CURRENT read applies to one temporal relation: not + * tombstoned, and `now` inside the row's validity window — the same window + * `compileTemporalFilter({ mode: "current" })` compiles for an ordinary read + * (`src/query/compiler/temporal.ts`), NOT `valid_to IS NULL`: a + * currently-valid row may carry a bounded FUTURE `valid_to`. + * + * The one owner of "what counts as a currently-valid row" for hand-rendered + * statements, so a change to the window's boundaries cannot reach some of them + * and miss others. `now` stays a bound parameter, so every statement a caller + * renders from one sample reads against the same instant. + */ +function currentWindowPredicate( + relation: string, + columns: Pick, + now: string, +): SQL { + const deletedAt = qualifiedColumn(relation, columns.deletedAt); + const validFrom = qualifiedColumn(relation, columns.validFrom); + const validTo = qualifiedColumn(relation, columns.validTo); + return sql`${deletedAt} IS NULL AND (${validFrom} IS NULL OR ${validFrom} <= ${now}) AND (${validTo} IS NULL OR ${validTo} > ${now})`; +} + +/** Qualifies a column with a relation name, the rendering both dialects read. */ +export function qualifiedColumn( + relation: string, + column: Readonly<{ name: string }>, +): SQL { + return sql`${quotedTableName(relation)}.${quotedColumn(column)}`; +} + /** * Returns a quoted SQL identifier for a bare table name string. * Use when the operation targets a table that isn't represented as a @@ -192,7 +223,10 @@ export function existingColumn( * Forcing the `C` collation on Postgres makes all three agree. The ranking is * sorted anyway (no index supplies the order), so this costs nothing. */ -export function codePointOrderKey(column: SqlFragment, dialect: SqlDialect): SQL { +export function codePointOrderKey( + column: SqlFragment, + dialect: SqlDialect, +): SQL { return toDrizzleSql(getDialect(dialect).binaryText(column), dialect); } @@ -204,7 +238,7 @@ export function codePointOrderKey(column: SqlFragment, dialect: SqlDialect): SQL * tombstoned/expired nodes after (which silently shrinks results below * `limit` under index drift). * - * Currency matches a `current` read: non-tombstoned AND inside the + * Currency is {@link currentWindowPredicate}: non-tombstoned AND inside the * validity window. The instant is BOUND as a parameter (the backend's * clock, same source as its write timestamps) rather than compiled as a * per-row SQL now() call — on SQLite a per-row strftime() across two @@ -216,7 +250,7 @@ export function liveNodeIdsSubquery( nodeKind: string, nowIso: string, ): SQL { - return sql`SELECT ${nodes.id} AS node_id FROM ${nodes} WHERE ${nodes.graphId} = ${graphId} AND ${nodes.kind} = ${nodeKind} AND ${nodes.deletedAt} IS NULL AND (${nodes.validFrom} IS NULL OR ${nodes.validFrom} <= ${nowIso}) AND (${nodes.validTo} IS NULL OR ${nodes.validTo} > ${nowIso})`; + return sql`SELECT ${nodes.id} AS node_id FROM ${nodes} WHERE ${nodes.graphId} = ${graphId} AND ${nodes.kind} = ${nodeKind} AND ${currentWindowPredicate(getTableName(nodes), nodes, nowIso)}`; } export function nodeColumnList(nodes: Tables["nodes"]): SQL { diff --git a/packages/typegraph/src/backend/drizzle/operations/strategy.ts b/packages/typegraph/src/backend/drizzle/operations/strategy.ts index 6912f9f30..e15820b02 100644 --- a/packages/typegraph/src/backend/drizzle/operations/strategy.ts +++ b/packages/typegraph/src/backend/drizzle/operations/strategy.ts @@ -3,7 +3,7 @@ import { getTableName, type SQL, sql } from "drizzle-orm"; import type { FulltextStrategy } from "../../../query/dialect/fulltext-strategy"; import type { VectorStrategy } from "../../../query/dialect/vector-strategy"; import { isSqlFragment, type SqlFragment } from "../../../query/sql-fragment"; -import { type ConstrainedCardinality } from "../../../store/claims/edge-claims"; +import { type EdgeCardinalityAxisRef } from "../../../store/claims/edge-claims"; import { isPresent, requireDefined } from "../../../utils/presence"; import type { PrimaryKeyRelation } from "../../../utils/sql-errors"; import type { @@ -24,9 +24,10 @@ import type { CheckUniqueParams, ClaimEdgeCardinalityParams, CompareAndSetNodeParams, + CompositionClaimScope, ContributionMaterializationIdentity, + CountEdgesAtEndpointParams, CountEdgesByKindParams, - CountEdgesFromParams, CountNodesByKindParams, DeleteEdgeParams, DeleteEdgesBatchParams, @@ -56,6 +57,7 @@ import type { PurgeEdgeClaimsParams, RecordContributionMaterializationParams, ResolvedNodeUpdateBatchParams, + SchemaGraphWriteFenceParams, SchemaWriteFenceParams, SqlDialect, UpdateEdgeParams, @@ -90,9 +92,11 @@ import { buildFindNodesByKind, } from "./collections"; import { + buildContendedCompositionEdgeRowAudit, buildContendedEdgeRowAudit, buildContendedUniqueRowAudit, buildDisjointOverlapAudit, + buildMisassignedEdgeEndpointAudit, } from "./constraint-fence-audit"; import type { AtomicContributionEvidence } from "./contribution-evidence"; import { @@ -103,6 +107,7 @@ import { buildLockEdgeClaimGuarded, buildLockEdgeClaims, buildPurgeEdgeClaims, + buildReadEdgeClaimIncumbents, buildTakeOverEdgeClaim, buildTakeOverEdgeClaimGuarded, } from "./edge-claims"; @@ -117,7 +122,7 @@ import { buildAtomicEdgeDeleteBatchWithSchemaFence, buildAtomicEdgeResolvedUpdateBatch, buildConvergeEdgeCreate, - buildCountEdgesFrom, + buildCountEdgesAtEndpoint, buildDeleteEdge, buildDeleteEdgesBatch, buildEdgeExistsBetween, @@ -525,7 +530,7 @@ export type CommonOperationStrategy = Readonly<{ nodeKind: string, nodeId: string, ) => SQL; - buildCountEdgesFrom: (params: CountEdgesFromParams) => SQL; + buildCountEdgesAtEndpoint: (params: CountEdgesAtEndpointParams) => SQL; buildEdgeExistsBetween: (params: EdgeExistsBetweenParams) => SQL; buildFindEdgesConnectedTo: (params: FindEdgesConnectedToParams) => SQL; buildFindNodesByKind: (params: FindNodesByKindParams) => SQL; @@ -589,15 +594,19 @@ export type CommonOperationStrategy = Readonly<{ buildCheckUnique: (params: CheckUniqueParams) => SQL; buildCheckUniqueBatch: (params: CheckUniqueBatchParams) => SQL; /** - * The two edge-claim statements, in the order the driver issues them: the - * decision-free lock that reports the committed holder, then — only for a - * foreign holder — the conditional takeover. Members of this interface rather - * than dialect helpers, so the type checker forces both dialects to have them. + * The edge-claim statements, in the order the driver issues them: the + * decision-free lock that reports the committed holder, the incumbent read + * for claims no read probe covers, then — only for a foreign holder — the + * conditional takeover. Members of this interface rather than dialect + * helpers, so the type checker forces both dialects to have them. */ buildLockEdgeClaims: ( entries: readonly ClaimEdgeCardinalityParams[], timestamp: string, ) => SQL; + buildReadEdgeClaimIncumbents: ( + entries: readonly ClaimEdgeCardinalityParams[], + ) => readonly SQL[]; buildLockEdgeClaimGuarded: ( params: ClaimEdgeCardinalityParams, timestamp: string, @@ -623,13 +632,37 @@ export type CommonOperationStrategy = Readonly<{ ) => SQL; buildContendedEdgeRowAudit: ( graphId: string, - cardinality: ConstrainedCardinality, + ref: EdgeCardinalityAxisRef, edgeKinds: readonly string[], ) => SQL; + /** + * The composition variant of {@link buildContendedEdgeRowAudit}: + * the peer test is the oriented two-arm union + * {@link file://./edge-claims.ts claimHolderTerms} folds a write's + * liveness predicate over, not exact-kind equality — the composition axis is + * relation-wide, so two different realizing edge kinds must be found + * contending for one part. + */ + buildContendedCompositionEdgeRowAudit: ( + graphId: string, + ref: EdgeCardinalityAxisRef, + holders: CompositionClaimScope["holders"], + reportedEdgeKinds: readonly string[], + ) => SQL; buildDisjointOverlapAudit: ( graphId: string, kinds: readonly [string, string], ) => SQL; + /** + * The fourth read-only fence-audit statement: live edges of one kind + * whose endpoints match no declared pair. A member for the same reason + * the other three are — the type checker forces both dialects to have it. + */ + buildMisassignedEdgeEndpointAudit: ( + graphId: string, + edgeKind: string, + allowedPairs: readonly (readonly [string, string])[], + ) => SQL; buildGetActiveSchema: (graphId: string) => SQL; /** * PostgreSQL-only dependent schema-row + graph-advisory fence. `fenceSql` @@ -639,7 +672,7 @@ export type CommonOperationStrategy = Readonly<{ * key. */ buildLockSchemaVersionAndGraphWrite?: ( - params: SchemaWriteFenceParams, + params: SchemaGraphWriteFenceParams, advisoryLockNamespace: string, fenceSql: FenceSql, ) => SQL; @@ -743,7 +776,7 @@ const COMMON_TABLE_OPERATION_BUILDERS = { buildHardDeleteEdge, buildHardDeleteEdgesBatch, buildHardDeleteEdgesByNode, - buildCountEdgesFrom, + buildCountEdgesAtEndpoint, buildEdgeExistsBetween, buildFindEdgesConnectedTo, buildFindNodesByKind, @@ -1363,11 +1396,16 @@ function createCommonOperationStrategy( ): SQL { return buildLockEdgeClaims(tables, entries, timestamp); }, + buildReadEdgeClaimIncumbents( + entries: readonly ClaimEdgeCardinalityParams[], + ): readonly SQL[] { + return buildReadEdgeClaimIncumbents(tables, dialect, entries); + }, buildLockEdgeClaimGuarded( params: ClaimEdgeCardinalityParams, timestamp: string, ): SQL { - return buildLockEdgeClaimGuarded(tables, params, timestamp); + return buildLockEdgeClaimGuarded(tables, dialect, params, timestamp); }, buildTakeOverEdgeClaim( params: ClaimEdgeCardinalityParams, @@ -1392,14 +1430,23 @@ function createCommonOperationStrategy( }, buildContendedEdgeRowAudit( graphId: string, - cardinality: ConstrainedCardinality, + ref: EdgeCardinalityAxisRef, edgeKinds: readonly string[], ): SQL { - return buildContendedEdgeRowAudit( + return buildContendedEdgeRowAudit(tables, graphId, ref, edgeKinds); + }, + buildContendedCompositionEdgeRowAudit( + graphId: string, + ref: EdgeCardinalityAxisRef, + holders: CompositionClaimScope["holders"], + reportedEdgeKinds: readonly string[], + ): SQL { + return buildContendedCompositionEdgeRowAudit( tables, graphId, - cardinality, - edgeKinds, + ref, + holders, + reportedEdgeKinds, ); }, buildDisjointOverlapAudit( @@ -1408,6 +1455,18 @@ function createCommonOperationStrategy( ): SQL { return buildDisjointOverlapAudit(tables, graphId, kinds); }, + buildMisassignedEdgeEndpointAudit( + graphId: string, + edgeKind: string, + allowedPairs: readonly (readonly [string, string])[], + ): SQL { + return buildMisassignedEdgeEndpointAudit( + tables, + graphId, + edgeKind, + allowedPairs, + ); + }, buildGetActiveSchema(graphId: string): SQL { return buildGetActiveSchema(tables, graphId, dialect); }, diff --git a/packages/typegraph/src/backend/drizzle/postgres-schema-write-fence.ts b/packages/typegraph/src/backend/drizzle/postgres-schema-write-fence.ts index d3e09c0ef..a07b8227c 100644 --- a/packages/typegraph/src/backend/drizzle/postgres-schema-write-fence.ts +++ b/packages/typegraph/src/backend/drizzle/postgres-schema-write-fence.ts @@ -2,7 +2,8 @@ * PostgreSQL's fused schema-version + graph-advisory write fence: a single * statement whose graph lock only acquires once the expected active schema * row has been read `FOR SHARE`, so a stale schema version can never win the - * advisory lock ahead of the read that would have diagnosed it as stale. + * advisory lock ahead of the read that would have diagnosed it as stale, and + * whose optional fence-row advance only runs once that lock is held. * `createPostgresOperationStrategy`'s only PostgreSQL-only strategy * override. * @@ -18,11 +19,19 @@ * portable lock sites take: both read `fenceSql.advisoryLockExpression`, so * neither can drift to a different spelling than the other. */ -import { type SQL, sql } from "drizzle-orm"; +import { getTableName, type SQL, sql } from "drizzle-orm"; +import { sql as portableSql } from "../../query/sql-fragment"; import { requireDefined } from "../../utils/presence"; -import type { FenceSql } from "../capabilities/write-fence"; -import type { SchemaWriteFenceParams } from "../types"; +import { + fenceRowAdvanceAfter, + type FenceSql, +} from "../capabilities/write-fence"; +import { + FENCE_ISOLATION_COLUMN, + FENCE_ROW_GENERATION_COLUMN, +} from "../command-contract"; +import type { SchemaGraphWriteFenceParams } from "../types"; import { toDrizzleSql } from "./execution/types"; import type { PostgresTables } from "./schema/postgres"; @@ -35,6 +44,16 @@ import type { PostgresTables } from "./schema/postgres"; * yields no rows and cannot acquire the graph lock. The caller interprets that * zero-row result through the ordinary active-version diagnostic. * + * With `params.fenceRow: "advance"` — a writer that goes on to make a + * decision only the fence protects — a third CTE, `fence_row`, advances the + * graph's fence row. It reads exclusively from `graph_write_lock`, so the row + * is written only after the advisory lock is held — the same lock-then-row + * order, the same row and the same generation rule as the portable + * acquisition (`fenceRowAdvanceAfter` is the one owner of that write). A + * session at a snapshot isolation whose snapshot predates another decider's + * commit fails on that row instead of proceeding. With `"leave"` the + * statement stops at the lock, writes nothing and reports no `generation`. + * * `fenceSql` is the resolved fence target's OWN spelling — the bundled * `postgresFenceSql` for a bundled backend, or a derived profile's own * `FenceSql` override — never imported directly, so a derived profile's @@ -42,11 +61,11 @@ import type { PostgresTables } from "./schema/postgres"; */ export function buildLockSchemaVersionAndGraphWrite( tables: PostgresTables, - params: SchemaWriteFenceParams, + params: SchemaGraphWriteFenceParams, advisoryLockNamespace: string, fenceSql: FenceSql, ): SQL { - const { schemaVersions } = tables; + const { fences, schemaVersions } = tables; // Both members are optional on `FenceSql` in general (a `row`-mechanism // target supplies neither), but this statement is only ever composed // behind a resolved `mechanism: "advisory"` plan — `postgres.ts` gates @@ -64,7 +83,7 @@ export function buildLockSchemaVersionAndGraphWrite( fenceSql.isolationFactExpression, "buildLockSchemaVersionAndGraphWrite: isolationFactExpression was validated present by the resolved advisory plan", ); - return sql` + const schemaFenceAndGraphLock = sql` WITH "schema_fence" AS MATERIALIZED ( SELECT ${schemaVersions.version} FROM ${schemaVersions} @@ -77,9 +96,49 @@ export function buildLockSchemaVersionAndGraphWrite( SELECT ${toDrizzleSql(advisoryLockExpression(advisoryLockNamespace, params.graphId), "postgres")} AS "lock_token" FROM "schema_fence" ) - SELECT - TRUE AS "fence_acquired", - ${toDrizzleSql(isolationFactExpression(), "postgres")} AS "transaction_isolation" - FROM "graph_write_lock" + `; + const isolationFact = toDrizzleSql(isolationFactExpression(), "postgres"); + const { fenceRowCte, generationColumn, finalRelation } = (() => { + switch (params.fenceRow) { + case "leave": { + return { + fenceRowCte: sql``, + generationColumn: sql``, + finalRelation: sql`"graph_write_lock"`, + }; + } + case "advance": { + return { + fenceRowCte: sql` + , + "fence_row" AS ( + ${toDrizzleSql( + fenceRowAdvanceAfter( + portableSql.identifier(getTableName(fences)), + advisoryLockNamespace, + params.graphId, + portableSql.raw('"graph_write_lock"'), + ), + "postgres", + )} + ) + `, + generationColumn: sql` + "fence_row".${sql.identifier(FENCE_ROW_GENERATION_COLUMN)} AS ${sql.identifier(FENCE_ROW_GENERATION_COLUMN)}, + `, + finalRelation: sql`"fence_row"`, + }; + } + default: { + return params.fenceRow satisfies never; + } + } + })(); + return sql` + ${schemaFenceAndGraphLock}${fenceRowCte} + SELECT + TRUE AS "fence_acquired",${generationColumn} + ${isolationFact} AS ${sql.identifier(FENCE_ISOLATION_COLUMN)} + FROM ${finalRelation} `; } diff --git a/packages/typegraph/src/backend/drizzle/postgres-working-copy-names.ts b/packages/typegraph/src/backend/drizzle/postgres-working-copy-names.ts new file mode 100644 index 000000000..909fad768 --- /dev/null +++ b/packages/typegraph/src/backend/drizzle/postgres-working-copy-names.ts @@ -0,0 +1,86 @@ +/** Physical relation names of one PostgreSQL working-copy allocation. */ +import { CompilerInvariantError } from "../../errors"; +import { sha256Hex, shortHash } from "../../utils/hash"; +import { requireDefined } from "../../utils/presence"; +import { + defaultPostgresTableNames, + type PostgresTableNames, +} from "./schema/postgres"; + +const PHYSICAL_PREFIX_LEAD = "tgw_"; +const ALLOCATION_DIGEST_BYTES = 12; +const RELATION_SUFFIX_LENGTH = 15; + +type RelationKey = keyof PostgresTableNames; + +function truncatedSuffix(key: string): string { + return key.slice(0, RELATION_SUFFIX_LENGTH); +} + +function hashedSuffix(key: string): string { + const hash = shortHash(key); + const readable = key.slice(0, RELATION_SUFFIX_LENGTH - hash.length - 1); + return `${readable}_${hash}`; +} + +/** + * One suffix per relation key, distinct across `keys`. + * + * A key keeps its leading characters while no other key shares them, which is + * what every allocation already on disk was named with. Keys that share them + * are told apart by a hash of the whole key instead. Distinctness is asserted + * rather than assumed: two relations with one physical name would fail the + * allocation's `CREATE TABLE` only after its ledger row was written. + */ +export function allocationRelationSuffixes( + keys: readonly K[], +): ReadonlyMap { + const sharing = new Map(); + for (const key of keys) { + const truncated = truncatedSuffix(key); + sharing.set(truncated, (sharing.get(truncated) ?? 0) + 1); + } + const suffixes = new Map(); + const owners = new Map(); + for (const key of keys) { + const truncated = truncatedSuffix(key); + const suffix = sharing.get(truncated) === 1 ? truncated : hashedSuffix(key); + const owner = owners.get(suffix); + if (owner !== undefined) { + throw new CompilerInvariantError( + `Working-copy relations "${owner}" and "${key}" resolve to the same physical suffix "${suffix}".`, + { owner, key, suffix }, + ); + } + owners.set(suffix, key); + suffixes.set(key, suffix); + } + return suffixes; +} + +const RELATION_SUFFIXES = allocationRelationSuffixes( + Object.keys(defaultPostgresTableNames) as RelationKey[], +); + +/** The ledger-reserved prefix every relation of `allocationId` is named under. */ +async function allocationPhysicalPrefixFor( + allocationId: string, +): Promise { + const digest = await sha256Hex(allocationId, ALLOCATION_DIGEST_BYTES); + return `${PHYSICAL_PREFIX_LEAD}${digest}_`; +} + +export async function allocationNames( + allocationId: string, +): Promise { + const prefix = await allocationPhysicalPrefixFor(allocationId); + return Object.fromEntries( + [...RELATION_SUFFIXES].map(([key, suffix]) => [key, `${prefix}${suffix}`]), + ) as PostgresTableNames; +} + +/** Recovers the allocation prefix from the names {@link allocationNames} built. */ +export function allocationPhysicalPrefix(names: PostgresTableNames): string { + const nodesSuffix = requireDefined(RELATION_SUFFIXES.get("nodes")); + return names.nodes.slice(0, -nodesSuffix.length); +} diff --git a/packages/typegraph/src/backend/drizzle/postgres-working-copy.ts b/packages/typegraph/src/backend/drizzle/postgres-working-copy.ts index b14f48a93..d358e9ffe 100644 --- a/packages/typegraph/src/backend/drizzle/postgres-working-copy.ts +++ b/packages/typegraph/src/backend/drizzle/postgres-working-copy.ts @@ -6,7 +6,7 @@ import { getTableName } from "drizzle-orm"; import type { GraphDef } from "../../core/define-graph"; import { resolveGraphVectorSlots } from "../../core/embedding"; -import { ConfigurationError } from "../../errors"; +import { BaseSchemaMigrationError, ConfigurationError } from "../../errors"; import { compareBaseVersionAtTarget, computeBaseVersion, @@ -82,6 +82,10 @@ import { ALLOCATION_LOCK_TRANSACTION_OPTIONS, lockAllocation, } from "./postgres-working-copy-lock"; +import { + allocationNames, + allocationPhysicalPrefix, +} from "./postgres-working-copy-names"; import { allocationRemovedError, createPostgresOperationCapability, @@ -489,22 +493,6 @@ function assertVectorManifestOwned( } } -function allocationNames(allocationId: string): Promise { - return sha256Hex(allocationId, 12).then( - (digest) => - Object.fromEntries( - Object.keys(defaultPostgresTableNames).map((key) => [ - key, - `tgw_${digest}_${key.slice(0, 15)}`, - ]), - ) as PostgresTableNames, - ); -} - -function allocationPhysicalPrefix(names: PostgresTableNames): string { - return names.nodes.slice(0, -"nodes".length); -} - function assertAllocationPrefix( row: AllocationRow, names: PostgresTableNames, @@ -523,7 +511,7 @@ async function resolveAllocationIndexNames( declarations: readonly IndexDeclaration[], names: PostgresTableNames, ): Promise> { - const prefix = names.nodes.slice(0, -"nodes".length); + const prefix = allocationPhysicalPrefix(names); const reserved = resolveSystemIndexNames(names); const entries = await Promise.all( declarations @@ -681,6 +669,12 @@ function fixedSchemaBackend( commitSchemaVersionWithPreflight: () => Promise.reject(fixedSchemaError("commitSchemaVersionWithPreflight")), }), + ...(backend.setActiveVersionWithPreflight === undefined ? + {} + : { + setActiveVersionWithPreflight: () => + Promise.reject(fixedSchemaError("setActiveVersionWithPreflight")), + }), ...(backend.instantiateGraphTemplate === undefined ? {} : { @@ -705,6 +699,41 @@ function fixedSchemaBackend( return guarded; } +/** + * Brings an allocation's own base schema up to the running release, in place. + * + * An allocation carries its own base-schema marker, stamped by the release + * that provisioned it, and a durable allocation outlives that release. A + * release whose base-schema step adds relations (version 6 adds the identity + * transition log) would otherwise attach to an allocation that lacks them and + * fail on its first write that reaches one. The connection is the + * provisioning role (`assertConnectionRole`), which created every relation in + * the allocation, so it can create the missing ones beside them; a current + * marker costs one read. A marker newer than this release is refused with + * `BaseSchemaMigrationError`, and a failed adoption with a `BranchError` + * naming the allocation, before any store is built over it. + */ +async function adoptAllocationBaseSchema( + backend: GraphBackend, + allocationId: string, +): Promise { + if (backend.adoptBaseSchema === undefined) { + throw new BranchError( + `Working-copy connection for allocation ${allocationId} cannot adopt its base schema.`, + { details: { allocationId } }, + ); + } + try { + await backend.adoptBaseSchema(); + } catch (error) { + if (error instanceof BaseSchemaMigrationError) throw error; + throw new BranchError( + `Working-copy allocation ${allocationId} could not adopt base-schema version ${String(CURRENT_BASE_SCHEMA_VERSION)} in place.`, + { details: { allocationId }, cause: error }, + ); + } +} + function withoutBootstrapDdl(backend: GraphBackend): GraphBackend { // Allocation already installed the complete table inventory. A connect // callback may carry Drizzle graph-index extras with global logical names; @@ -1684,8 +1713,10 @@ export function createPostgresWorkingCopyManager( /** * Connects to a resolved allocation and attests the connection: table - * bindings, shared role, vector strategy and ownership token. The caller owns - * `backend` and must close it. + * bindings, shared role, vector strategy and ownership token. Then brings the + * allocation's base schema up to this release (see + * {@link adoptAllocationBaseSchema}) before any store reads or writes it. The + * caller owns `backend` and must close it. */ async function attachAllocation( graph: G, @@ -1708,6 +1739,7 @@ export function createPostgresWorkingCopyManager( ); } await assertAttachedSession(backend, row); + await adoptAllocationBaseSchema(backend, row.allocation_id); const store = createStore( graph, fixedSchemaBackend(provisionedBackend(backend, indexNames), indexNames), diff --git a/packages/typegraph/src/backend/drizzle/postgres.ts b/packages/typegraph/src/backend/drizzle/postgres.ts index 38f631de4..ea48d4509 100644 --- a/packages/typegraph/src/backend/drizzle/postgres.ts +++ b/packages/typegraph/src/backend/drizzle/postgres.ts @@ -56,7 +56,7 @@ import { type SQL, sql, } from "drizzle-orm"; -import { PgTransaction } from "drizzle-orm/pg-core"; +import { PgTransaction, type PgTransactionConfig } from "drizzle-orm/pg-core"; import { CompilerInvariantError, @@ -140,6 +140,11 @@ import { type WriteFencePlan, type WriteFenceTarget, } from "../capabilities/write-fence"; +import { + FENCE_ISOLATION_COLUMN, + observesEveryEarlierFenceHolder, + readGraphWriteFenceAcquisition, +} from "../command-contract"; import { deriveBackend } from "../derive-backend"; import { FIND_EDGES_ENDPOINT_FIXED_PARAM_COUNT } from "../edge-endpoint-sets"; import { buildLiveNodeCandidates } from "../live-node-candidates"; @@ -172,6 +177,7 @@ import { type DatabaseExtensionName, type EngineRecordedTimeMembers, type GraphBackend, + type GraphWriteFenceAcquisition, type HeterogeneousNodeUpsertParams, type HybridSearchParams, type HybridSearchRow, @@ -511,14 +517,19 @@ END; $tg$`; EXECUTE $definition$ ${functionDdl} $definition$; END IF; END; $install$`; - const triggerDdl = postgresRevisionChangeTargets(names).map(({ entity, table }) => { - const triggerName = revisionChangeTriggerName(entity, table); - const relation = postgresIdentifierRegclassName(table).replaceAll("'", "''"); - const trigger = triggerName.replaceAll("'", "''"); - const journal = names.revisionChanges.replaceAll("'", "''"); - const clock = names.recordedClock.replaceAll("'", "''"); - return `DO $tg$ BEGIN ${postgresDdlLockStatement(REVISION_CHANGE_FUNCTION)} IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = '${trigger}' AND tgrelid = '${relation}'::regclass) THEN CREATE TRIGGER ${quoteRevisionJournalIdentifier(triggerName)} AFTER INSERT OR UPDATE OR DELETE ON ${quoteRevisionJournalIdentifier(table)} FOR EACH ROW EXECUTE FUNCTION ${quoteRevisionJournalIdentifier(REVISION_CHANGE_FUNCTION)}('${journal}', '${clock}', '${entity}'); END IF; END; $tg$`; - }); + const triggerDdl = postgresRevisionChangeTargets(names).map( + ({ entity, table }) => { + const triggerName = revisionChangeTriggerName(entity, table); + const relation = postgresIdentifierRegclassName(table).replaceAll( + "'", + "''", + ); + const trigger = triggerName.replaceAll("'", "''"); + const journal = names.revisionChanges.replaceAll("'", "''"); + const clock = names.recordedClock.replaceAll("'", "''"); + return `DO $tg$ BEGIN ${postgresDdlLockStatement(REVISION_CHANGE_FUNCTION)} IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = '${trigger}' AND tgrelid = '${relation}'::regclass) THEN CREATE TRIGGER ${quoteRevisionJournalIdentifier(triggerName)} AFTER INSERT OR UPDATE OR DELETE ON ${quoteRevisionJournalIdentifier(table)} FOR EACH ROW EXECUTE FUNCTION ${quoteRevisionJournalIdentifier(REVISION_CHANGE_FUNCTION)}('${journal}', '${clock}', '${entity}'); END IF; END; $tg$`; + }, + ); return [installFunctionDdl, ...triggerDdl]; } @@ -657,6 +668,41 @@ function extensionDdlLockKey(extension: DatabaseExtensionName): string { */ const SCHEMA_COMMIT_FENCE_NAMESPACE = "typegraph:schema-commit"; +/** The transaction TypeGraph opens for a schema write under the advisory fence. */ +const SCHEMA_WRITE_ADVISORY_FENCE_TRANSACTION = { + isolationLevel: "read committed", +} as const satisfies PgTransactionConfig; + +/** + * Refuses a schema write, under the advisory schema fence, whose session + * cannot observe every managed writer that held the fence before it. Those + * writers lock the active schema row `FOR SHARE` and change nothing, so only + * an isolation that takes a fresh snapshot per statement sees what they + * committed while the schema writer waited — the rows a tightening preflight + * or an emptiness probe must check. `acquisition` is what the fence statement + * itself read on the session. + * + * @throws ConfigurationError `SCHEMA_WRITE_REQUIRES_READ_COMMITTED` + */ +function assertSchemaWriteObservesEarlierWriters( + graphId: string, + acquisition: GraphWriteFenceAcquisition, +): void { + if (observesEveryEarlierFenceHolder(acquisition)) return; + throw new ConfigurationError( + "A PostgreSQL schema write requires read_committed isolation on the session that holds the schema fence.", + { + code: "SCHEMA_WRITE_REQUIRES_READ_COMMITTED", + graphId, + isolation: acquisition.isolation, + }, + { + suggestion: + "Run the schema write in a read_committed transaction. A snapshot isolation keeps a snapshot older than the writers the schema fence waited for, so its data checks would miss their rows.", + }, + ); +} + /** PostgreSQL renders lock_timeout with a duration suffix, except zero. */ function postgresLockTimeoutMilliseconds(value: string): number | undefined { if (value === "0") return 0; @@ -1147,9 +1193,9 @@ function buildPostgresEngineProfileInternal( // DDL this backend issues then runs where the allocation lives. const allocationSchema = allocationSchemaOfTables(tables); const allocationPin = - allocationSchema === undefined ? - undefined - : toDrizzleSql(allocationSchemaPin(allocationSchema), "postgres"); + allocationSchema === undefined ? undefined : ( + toDrizzleSql(allocationSchemaPin(allocationSchema), "postgres") + ); // `fulltext: false` disables fulltext entirely — mirroring `vector` // below, required for an engine or role with no fulltext implementation // of its own to build a TypeGraph backend without a stub strategy. @@ -1279,13 +1325,17 @@ function buildPostgresEngineProfileInternal( ); const rawClient: unknown = (db as Readonly<{ $client?: unknown }>).$client; const bareClient = - typeof rawClient === "object" && - rawClient !== null && - isBarePgClient(rawClient as Readonly>) ? + ( + typeof rawClient === "object" && + rawClient !== null && + isBarePgClient(rawClient as Readonly>) + ) ? rawClient : undefined; const queueOwner = - transactionScoped ? (getPinnedPostgresTransactionClient(db) ?? db) : bareClient; + transactionScoped ? + (getPinnedPostgresTransactionClient(db) ?? db) + : bareClient; const executionAdapter = queueOwner === undefined ? unqueuedExecutionAdapter @@ -1313,6 +1363,10 @@ function buildPostgresEngineProfileInternal( recordedIdentityAssertions: getTableName(tables.recordedIdentityAssertions), identityClosure: getTableName(tables.identityClosure), identitySeparation: getTableName(tables.identitySeparation), + identityTransitions: getTableName(tables.identityTransitions), + identityTransitionRetention: getTableName( + tables.identityTransitionRetention, + ), fulltext: tables.fulltextTableName, uniques: getTableName(tables.uniques), edgeClaims: getTableName(tables.edgeClaims), @@ -1320,7 +1374,9 @@ function buildPostgresEngineProfileInternal( baseSchemaVersions: getTableName(tables.baseSchemaVersions), graphTemplates: getTableName(tables.graphTemplates), indexMaterializations: getTableName(tables.indexMaterializations), - contributionMaterializations: getTableName(tables.contributionMaterializations), + contributionMaterializations: getTableName( + tables.contributionMaterializations, + ), kindRemovals: getTableName(tables.kindRemovals), reconciliationMarkers: getTableName(tables.reconciliationMarkers), } satisfies PostgresTableNames; @@ -1613,8 +1669,9 @@ function buildPostgresEngineProfileInternal( revisionChangesTriggerDdl: postgresRevisionChangeTriggers(tableNames), async revisionChangesJournalReady(): Promise { const expectedTriggers = portableSql.join( - postgresRevisionChangeTargets(tableNames).map(({ entity, table }) => - portableSql`(${postgresIdentifierRegclassName(table)}, ${revisionChangeTriggerName(entity, table)}, ${entity})`, + postgresRevisionChangeTargets(tableNames).map( + ({ entity, table }) => + portableSql`(${postgresIdentifierRegclassName(table)}, ${revisionChangeTriggerName(entity, table)}, ${entity})`, ), portableSql`, `, ); @@ -1938,6 +1995,13 @@ function buildPostgresEngineProfileInternal( graphIdOrderIndexDdl: graphIdOrderIndexTables(tableNames).map(({ table }) => generateGraphIdOrderIndexDDL(table), ), + identityTransitionsTableDdl: [ + generatePgCreateTableSQL(tables.identityTransitions), + ...generatePgCreateIndexSQL(tables.identityTransitions), + ], + identityTransitionRetentionTableDdl: generatePgCreateTableSQL( + tables.identityTransitionRetention, + ), }; // Deps for `createIndexMaterializationMembers`, beyond `ensureTable` / @@ -2022,6 +2086,36 @@ function buildPostgresEngineProfileInternal( // dialect built its own (stale-prone) copies. const { capabilities, fencePlan, fenceTarget, isFirstParty } = ctx; + /** + * THE one resolution of the PostgreSQL schema-commit fence, refusing an + * unfenced backend. Shared by the transaction a schema write opens, whose + * isolation it decides, and by {@link acquireSchemaWriteFence}, which takes + * it, so the two cannot disagree about which fence is in force. + */ + function resolveSchemaCommitFence() { + return requireWriteFence( + resolveWriteFencePlan(fenceTarget), + "The PostgreSQL schema-commit fence", + "keyed", + ); + } + type SchemaCommitFence = ReturnType; + + /** + * Where the advisory schema fence learns the session's isolation, which it + * must check before trusting the data checks that follow it. + * + * - `read-with-lock`: a transaction TypeGraph opened reads it in the lock + * statement itself. A refusal there rolls that transaction (or, on a + * transaction-scoped backend, its savepoint) back, and the lock with it. + * - `observed`: a caller's adopted transaction outlives a refusal, so its + * isolation is read before anything is acquired or changed on it and the + * fence refuses before its first statement. + */ + type SchemaFenceIsolationSource = + | Readonly<{ kind: "read-with-lock" }> + | Readonly<{ kind: "observed"; acquisition: GraphWriteFenceAcquisition }>; + /** * The per-graph schema-commit fence. * @@ -2054,17 +2148,20 @@ function buildPostgresEngineProfileInternal( async function acquireSchemaWriteFence( tx: AnyPgTransaction, graphId: string, + plan: SchemaCommitFence, + isolation: SchemaFenceIsolationSource, beforeAcquisition?: ( phase: "schema-advisory" | "schema-row", ) => Promise, ): Promise { - const plan = requireWriteFence( - resolveWriteFencePlan(fenceTarget), - "The PostgreSQL schema-commit fence", - "keyed", - ); switch (plan.kind) { case "lock": { + if (isolation.kind === "observed") { + assertSchemaWriteObservesEarlierWriters( + graphId, + isolation.acquisition, + ); + } await beforeAcquisition?.("schema-advisory"); // Advisory lock: hashtext($graphId) is collision-tolerant for the // size of an active graph set; collisions just serialize unrelated @@ -2087,12 +2184,40 @@ function buildPostgresEngineProfileInternal( // takes the same fence row this fence's `"row"` arm takes, as a // preceding statement in its own transaction — no advisory lock is // in play on either side. - await tx.execute( - toDrizzleSql( - portableSql`SELECT ${advisoryLockSingleExpression(graphId)}`, - "postgres", - ), - ); + // + // The session's isolation is checked before the data checks that + // follow: a managed writer holds this schema's row `FOR SHARE` + // without changing it, so a schema writer whose snapshot predates + // that writer's commit gets no serialization failure when its + // `FOR UPDATE` below is granted. Only a fresh snapshot per statement + // lets those checks — a tightening preflight, an emptiness probe — + // see the rows that writer committed while this one waited. A + // transaction TypeGraph opened reads it in this same statement. + if (isolation.kind === "read-with-lock") { + const isolationFactExpression = requireDefined( + plan.sql.isolationFactExpression, + "acquireSchemaWriteFence: isolationFactExpression was validated present by the resolved advisory plan", + ); + const [acquired] = await createPostgresExecutionAdapter( + tx, + adapterOptions, + ).execute>( + asCompiledRowsSql( + portableSql`SELECT ${advisoryLockSingleExpression(graphId)}, ${isolationFactExpression()} AS ${portableSql.raw(FENCE_ISOLATION_COLUMN)}`, + ), + ); + assertSchemaWriteObservesEarlierWriters( + graphId, + readGraphWriteFenceAcquisition(acquired), + ); + } else { + await tx.execute( + toDrizzleSql( + portableSql`SELECT ${advisoryLockSingleExpression(graphId)}`, + "postgres", + ), + ); + } await beforeAcquisition?.("schema-row"); // Managed entity writers lock this row FOR SHARE. Locking it FOR // UPDATE before any emptiness probe makes a writer-first commit @@ -2144,7 +2269,8 @@ function buildPostgresEngineProfileInternal( /** * Runs `fn` inside a Postgres transaction, under whatever fence - * {@link acquireSchemaWriteFence} resolves. On a `lock` backend that is an + * {@link resolveSchemaCommitFence} resolves and {@link acquireSchemaWriteFence} + * takes. On a `lock` backend that is an * `pg_advisory_xact_lock` keyed on the graph id, which serializes all * schema commits per-graph: the read-then-write CAS in * `commitSchemaVersion` is safe even for the initial-commit case where @@ -2202,9 +2328,24 @@ function buildPostgresEngineProfileInternal( // Fresh `tx`/`txBackend` every call, so a replayed attempt never reuses // a rolled-back attempt's connection state (the attempt-factory // contract `runRetriedUnit` requires of `attempt`). + // + // Under the advisory fence the transaction asks for read committed + // rather than inheriting the server's default: that fence refuses a + // session at any other isolation (`acquireSchemaWriteFence`), and a + // deployment whose default is a snapshot isolation would otherwise have + // every schema write refused. The fence-row form needs no such request: + // every managed writer advances the row it takes, so a stale snapshot + // fails there with a serialization failure at any isolation. + const fence = resolveSchemaCommitFence(); + const transactionConfig = + fence.kind === "lock" ? + SCHEMA_WRITE_ADVISORY_FENCE_TRANSACTION + : undefined; const attempt = (): Promise => db.transaction(async (tx) => { - await acquireSchemaWriteFence(tx, graphId); + await acquireSchemaWriteFence(tx, graphId, fence, { + kind: "read-with-lock", + }); // Schema writes run DDL: over an allocation, where it lives. await bindSchemaWriteSessionToAllocation(tx); // The fence resolved above is held here, so the schema-write-capable @@ -2234,7 +2375,7 @@ function buildPostgresEngineProfileInternal( } finally { await drainAndClose(); } - }); + }, transactionConfig); if (!isOptimisticRetryTier(fenceTarget)) return attempt(); return runRetriedUnit( @@ -2511,10 +2652,25 @@ function buildPostgresEngineProfileInternal( "Schema adoption", ); } - const [setting] = await settingAdapter.execute<{ - value: string; - }>(sql`SELECT current_setting('lock_timeout') AS value`); - const previousTimeout = setting?.value; + // The caller's transaction outlives a refusal, so its + // isolation is read here — already fixed by the statements + // above, and before anything is acquired or changed on it — + // for the schema fence to refuse before its first statement. + const isolationFactExpression = + schemaFencePlan.sql.isolationFactExpression; + const [setting] = await settingAdapter.execute< + Record + >( + asCompiledRowsSql( + isolationFactExpression === undefined ? + portableSql`SELECT current_setting('lock_timeout') AS value` + : portableSql`SELECT current_setting('lock_timeout') AS value, ${isolationFactExpression()} AS ${portableSql.raw(FENCE_ISOLATION_COLUMN)}`, + ), + ); + const previousTimeout = + typeof setting?.["value"] === "string" ? + setting["value"] + : undefined; const callerTimeout = previousTimeout === undefined ? undefined : ( postgresLockTimeoutMilliseconds(previousTimeout) @@ -2535,6 +2691,11 @@ function buildPostgresEngineProfileInternal( await acquireSchemaWriteFence( externalTx, graphId, + schemaFencePlan, + { + kind: "observed", + acquisition: readGraphWriteFenceAcquisition(setting), + }, async (acquisitionPhase) => { phase = acquisitionPhase; const remainingMs = deadline - performance.now(); diff --git a/packages/typegraph/src/backend/drizzle/schema/postgres-table-names.ts b/packages/typegraph/src/backend/drizzle/schema/postgres-table-names.ts index 0f39b58c8..8777356b7 100644 --- a/packages/typegraph/src/backend/drizzle/schema/postgres-table-names.ts +++ b/packages/typegraph/src/backend/drizzle/schema/postgres-table-names.ts @@ -11,6 +11,8 @@ export const defaultPostgresTableNames = { recordedIdentityAssertions: "typegraph_recorded_identity_assertions", identityClosure: "typegraph_identity_closure", identitySeparation: "typegraph_identity_separation", + identityTransitions: "typegraph_identity_transitions", + identityTransitionRetention: "typegraph_identity_transition_retention", uniques: "typegraph_node_uniques", edgeClaims: "typegraph_edge_claims", baseSchemaVersions: "typegraph_base_schema_versions", diff --git a/packages/typegraph/src/backend/drizzle/schema/postgres.ts b/packages/typegraph/src/backend/drizzle/schema/postgres.ts index 6ee704110..1a7c34535 100644 --- a/packages/typegraph/src/backend/drizzle/schema/postgres.ts +++ b/packages/typegraph/src/backend/drizzle/schema/postgres.ts @@ -72,6 +72,8 @@ export type PostgresTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -436,6 +438,74 @@ export function createPostgresTables( ], ); + // The identity transition log: an append-only annotation on the recorded + // axis. Carries no membership — `class_kind`/`class_id` name the class + // canonical AFTER the transition, `prior_class_kind`/`prior_class_id` the + // canonical BEFORE (NULL when that class did not exist as a real row). + // Every membership answer still comes from `historicalIdentityReconstructionCtes`. + const identityTransitions = pgTable( + n.identityTransitions, + { + graphId: text("graph_id").notNull(), + transitionId: text("transition_id").notNull(), + recordedRevision: bigint("recorded_revision", { + mode: "number", + }).notNull(), + recordedAt: timestamp("recorded_at", { withTimezone: true }).notNull(), + validAt: timestamp("valid_at", { withTimezone: true }).notNull(), + cause: text("cause").notNull(), + classKind: text("class_kind").notNull(), + classId: text("class_id").notNull(), + priorClassKind: text("prior_class_kind"), + priorClassId: text("prior_class_id"), + assertionIds: jsonb("assertion_ids").notNull(), + decision: jsonb("decision"), + txId: text("tx_id"), + // NULL for a row this graph recorded itself through the live capture + // flush; set to the restore's own wall time for a row an archival + // restore inserted verbatim. `identityReplay` uses ONLY this marker — + // never a revision comparison — to decide whether a row may be paired + // with a reconstructed before/after snapshot, because a restored row's + // `recorded_revision` is the SOURCE graph's own number and interleaves + // arbitrarily with this graph's (see `isRestoredTransitionRow`). + restoredAt: timestamp("restored_at", { withTimezone: true }), + }, + (t) => [ + primaryKey({ columns: [t.graphId, t.transitionId] }), + index(`${n.identityTransitions}_class_idx`).on( + t.graphId, + t.classKind, + t.classId, + t.recordedRevision, + ), + index(`${n.identityTransitions}_revision_idx`).on( + t.graphId, + t.recordedRevision, + ), + index(`${n.identityTransitions}_prior_class_idx`).on( + t.graphId, + t.priorClassKind, + t.priorClassId, + t.recordedRevision, + ), + ], + ); + + // One row per graph: the transition-retention high-water mark + // `pruneIdentityTransitions` advances. `0` (absent row) means nothing has + // been pruned. + const identityTransitionRetention = pgTable( + n.identityTransitionRetention, + { + graphId: text("graph_id").notNull(), + prunedBeforeRevision: bigint("pruned_before_revision", { + mode: "number", + }).notNull(), + prunedAt: timestamp("pruned_at", { withTimezone: true }).notNull(), + }, + (t) => [primaryKey({ columns: [t.graphId] })], + ); + const uniques = pgTable( n.uniques, { @@ -707,6 +777,8 @@ export function createPostgresTables( recordedIdentityAssertions, identityClosure, identitySeparation, + identityTransitions, + identityTransitionRetention, uniques, edgeClaims, fences, diff --git a/packages/typegraph/src/backend/drizzle/schema/sqlite.ts b/packages/typegraph/src/backend/drizzle/schema/sqlite.ts index 9245d9ceb..06300d6cc 100644 --- a/packages/typegraph/src/backend/drizzle/schema/sqlite.ts +++ b/packages/typegraph/src/backend/drizzle/schema/sqlite.ts @@ -55,6 +55,8 @@ export type SqliteTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; uniques: string; edgeClaims: string; baseSchemaVersions: string; @@ -97,6 +99,8 @@ const DEFAULT_TABLE_NAMES: SqliteTableNames = { recordedIdentityAssertions: "typegraph_recorded_identity_assertions", identityClosure: "typegraph_identity_closure", identitySeparation: "typegraph_identity_separation", + identityTransitions: "typegraph_identity_transitions", + identityTransitionRetention: "typegraph_identity_transition_retention", uniques: "typegraph_node_uniques", edgeClaims: "typegraph_edge_claims", baseSchemaVersions: "typegraph_base_schema_versions", @@ -421,6 +425,70 @@ export function createSqliteTables( ], ); + // The identity transition log: an append-only annotation on the recorded + // axis. Carries no membership — `class_kind`/`class_id` name the class + // canonical AFTER the transition, `prior_class_kind`/`prior_class_id` the + // canonical BEFORE (NULL when that class did not exist as a real row). + // Every membership answer still comes from `historicalIdentityReconstructionCtes`. + const identityTransitions = sqliteTable( + n.identityTransitions, + { + graphId: text("graph_id").notNull(), + transitionId: text("transition_id").notNull(), + recordedRevision: integer("recorded_revision").notNull(), + recordedAt: text("recorded_at").notNull(), + validAt: text("valid_at").notNull(), + cause: text("cause").notNull(), + classKind: text("class_kind").notNull(), + classId: text("class_id").notNull(), + priorClassKind: text("prior_class_kind"), + priorClassId: text("prior_class_id"), + assertionIds: text("assertion_ids").notNull(), + decision: text("decision"), + txId: text("tx_id"), + // NULL for a row this graph recorded itself through the live capture + // flush; set to the restore's own wall time for a row an archival + // restore inserted verbatim. `identityReplay` uses ONLY this marker — + // never a revision comparison — to decide whether a row may be paired + // with a reconstructed before/after snapshot, because a restored row's + // `recorded_revision` is the SOURCE graph's own number and interleaves + // arbitrarily with this graph's (see `isRestoredTransitionRow`). + restoredAt: text("restored_at"), + }, + (t) => [ + primaryKey({ columns: [t.graphId, t.transitionId] }), + index(`${n.identityTransitions}_class_idx`).on( + t.graphId, + t.classKind, + t.classId, + t.recordedRevision, + ), + index(`${n.identityTransitions}_revision_idx`).on( + t.graphId, + t.recordedRevision, + ), + index(`${n.identityTransitions}_prior_class_idx`).on( + t.graphId, + t.priorClassKind, + t.priorClassId, + t.recordedRevision, + ), + ], + ); + + // One row per graph: the transition-retention high-water mark + // `pruneIdentityTransitions` advances. `0` (absent row) means nothing has + // been pruned. + const identityTransitionRetention = sqliteTable( + n.identityTransitionRetention, + { + graphId: text("graph_id").notNull(), + prunedBeforeRevision: integer("pruned_before_revision").notNull(), + prunedAt: text("pruned_at").notNull(), + }, + (t) => [primaryKey({ columns: [t.graphId] })], + ); + const uniques = sqliteTable( n.uniques, { @@ -638,6 +706,8 @@ export function createSqliteTables( recordedIdentityAssertions, identityClosure, identitySeparation, + identityTransitions, + identityTransitionRetention, uniques, edgeClaims, fences, diff --git a/packages/typegraph/src/backend/drizzle/sqlite.ts b/packages/typegraph/src/backend/drizzle/sqlite.ts index 97c21ce35..f2a7aaf4a 100644 --- a/packages/typegraph/src/backend/drizzle/sqlite.ts +++ b/packages/typegraph/src/backend/drizzle/sqlite.ts @@ -77,7 +77,7 @@ import { import { requireDefined } from "../../utils/presence"; import { isMissingTableError, - isSqliteDuplicateEdgeMatchIdentityColumnError, + isSqliteDuplicateColumnError, isSqliteNotAuthorizedError, isSqliteWriterSlotBusy, } from "../../utils/sql-errors"; @@ -190,6 +190,7 @@ import { SQLITE_CONTRIBUTION_MAT_TIMESTAMPS, } from "./contribution-materializations"; import { + EDGE_MATCH_IDENTITY_ADOPTION_COLUMNS, generateSqliteCreateIndexSQL, generateSqliteCreateTableSQL, generateSqliteDDL, @@ -1388,6 +1389,10 @@ export function buildSqliteEngineProfile( recordedIdentityAssertions: getTableName(tables.recordedIdentityAssertions), identityClosure: getTableName(tables.identityClosure), identitySeparation: getTableName(tables.identitySeparation), + identityTransitions: getTableName(tables.identityTransitions), + identityTransitionRetention: getTableName( + tables.identityTransitionRetention, + ), fulltext: tables.fulltextTableName, uniques: getTableName(tables.uniques), edgeClaims: getTableName(tables.edgeClaims), @@ -1628,7 +1633,10 @@ export function buildSqliteEngineProfile( } catch (error) { if ( attempt === 2 || - !isSqliteDuplicateEdgeMatchIdentityColumnError(error) + !isSqliteDuplicateColumnError( + error, + EDGE_MATCH_IDENTITY_ADOPTION_COLUMNS, + ) ) { throw error; } @@ -1771,6 +1779,13 @@ export function buildSqliteEngineProfile( ), }), ], + identityTransitionsTableDdl: [ + generateSqliteCreateTableSQL(tables.identityTransitions), + ...generateSqliteCreateIndexSQL(tables.identityTransitions), + ], + identityTransitionRetentionTableDdl: generateSqliteCreateTableSQL( + tables.identityTransitionRetention, + ), revisionChangesTableDdl: generateSqliteCreateTableSQL( tables.revisionChanges, ), diff --git a/packages/typegraph/src/backend/graph-backend-keys.ts b/packages/typegraph/src/backend/graph-backend-keys.ts index 3d70ccdac..3039a5e8b 100644 --- a/packages/typegraph/src/backend/graph-backend-keys.ts +++ b/packages/typegraph/src/backend/graph-backend-keys.ts @@ -51,7 +51,7 @@ export const GRAPH_BACKEND_PROJECTION_KEYS = [ "hardDeleteEdgesBatch", "getEdge", "getEdges", - "countEdgesFrom", + "countEdgesAtEndpoint", "edgeExistsBetween", "findEdgesConnectedTo", "findNodesByKind", @@ -88,6 +88,7 @@ export const GRAPH_BACKEND_PROJECTION_KEYS = [ "lockSchemaVersionAndGraphWrite", "commitSchemaVersionWithPreflight", "setActiveVersion", + "setActiveVersionWithPreflight", "schemaWriteTransaction", "upsertEmbedding", "upsertEmbeddingBatch", diff --git a/packages/typegraph/src/backend/graph-relations.ts b/packages/typegraph/src/backend/graph-relations.ts index 279ffcb8e..753662d0e 100644 --- a/packages/typegraph/src/backend/graph-relations.ts +++ b/packages/typegraph/src/backend/graph-relations.ts @@ -39,7 +39,12 @@ export const GRAPH_ID_COLUMN = "graph_id"; * * The order is the order a namespace fork copies and digests relations in. It * is part of the fork's durable content digest, so it must not be reshuffled - * for tidiness; `clear.order` carries the (different) deletion order. + * for tidiness; `GRAPH_RELATION_CLEAR_ORDER` carries the (different) deletion order. + * + * APPEND new relations at the end. A fork's digest names a relation added + * after its frozen baseline only once that relation holds rows + * (`DIGEST_BASELINE_RELATION_KEYS`, `src/graph-merge/namespace-fork.ts`), so + * an appended relation leaves every recorded fork proof verifiable. */ export const GRAPH_RELATION_KEYS = [ "nodes", @@ -61,6 +66,8 @@ export const GRAPH_RELATION_KEYS = [ "contributionMaterializations", "kindRemovals", "reconciliationMarkers", + "identityTransitions", + "identityTransitionRetention", ] as const; export type GraphRelationKey = (typeof GRAPH_RELATION_KEYS)[number]; @@ -68,7 +75,8 @@ export type GraphRelationKey = (typeof GRAPH_RELATION_KEYS)[number]; /** * How `Store.clear()` disposes of one relation's rows for the cleared graph. * - * - `delete`: a `DELETE ... WHERE graph_id = ?` issued in ascending `order`. + * - `delete`: a `DELETE ... WHERE graph_id = ?` issued in the position + * {@link GRAPH_RELATION_CLEAR_ORDER} gives the relation, exposed as `order`. * `missingTable: "tolerated"` marks a relation that is provisioned lazily * (or after first boot), so a database that never created it must not fail * the clear. `preservable` marks a relation `Store.clear()` may keep when it @@ -92,11 +100,10 @@ export type GraphRelationKey = (typeof GRAPH_RELATION_KEYS)[number]; type GraphRelationClearBehavior = | Readonly<{ kind: "delete"; - order: number; missingTable: "required" | "tolerated"; preservable?: true; }> - | Readonly<{ kind: "fulltextStrategy"; order: number }>; + | Readonly<{ kind: "fulltextStrategy" }>; /** * `content` rows are written by applications or derived from those writes. @@ -143,8 +150,11 @@ type GraphRelationBehavior = Readonly<{ workingCopyClonePolicy: GraphRowsClonePolicy | RebuildAfterClonePolicy; }>; -export type GraphRelationDeclaration = GraphRelationBehavior & - Readonly<{ key: GraphRelationKey }>; +export type GraphRelationDeclaration = Omit & + Readonly<{ + key: GraphRelationKey; + clear: GraphRelationClearBehavior & Readonly<{ order: number }>; + }>; const COPY_GRAPH_ROWS = { kind: "graphRows", @@ -165,100 +175,100 @@ const REBUILD_AFTER_CLONE = { */ const BEHAVIOR = { nodes: { - clear: { kind: "delete", order: 11, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "content", presenceAnchor: true, provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, edges: { - clear: { kind: "delete", order: 12, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "content", presenceAnchor: true, provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, recordedNodes: { - clear: { kind: "delete", order: 4, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, recordedEdges: { - clear: { kind: "delete", order: 3, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, recordedClock: { - clear: { kind: "delete", order: 5, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, revisionOrigins: { - clear: { kind: "delete", order: 19, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, revisionChanges: { - clear: { kind: "delete", order: 13, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, identityAssertions: { - clear: { kind: "delete", order: 8, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, recordedIdentityAssertions: { - clear: { kind: "delete", order: 2, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, identityClosure: { - clear: { kind: "delete", order: 7, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, identitySeparation: { - clear: { kind: "delete", order: 6, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, uniques: { - clear: { kind: "delete", order: 9, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, edgeClaims: { - clear: { kind: "delete", order: 10, missingTable: "tolerated" }, + clear: { kind: "delete", missingTable: "tolerated" }, role: "content", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, schemaVersions: { - clear: { kind: "delete", order: 18, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "bookkeeping", presenceAnchor: true, provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, fulltext: { - clear: { kind: "fulltextStrategy", order: 1 }, + clear: { kind: "fulltextStrategy" }, role: "content", provisionedBy: "fulltext", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, indexMaterializations: { - clear: { kind: "delete", order: 14, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "bookkeeping", provisionedBy: "base", workingCopyClonePolicy: REBUILD_AFTER_CLONE, @@ -266,7 +276,6 @@ const BEHAVIOR = { contributionMaterializations: { clear: { kind: "delete", - order: 17, missingTable: "tolerated", preservable: true, }, @@ -275,22 +284,84 @@ const BEHAVIOR = { workingCopyClonePolicy: REBUILD_AFTER_CLONE, }, kindRemovals: { - clear: { kind: "delete", order: 15, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, role: "bookkeeping", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, reconciliationMarkers: { - clear: { kind: "delete", order: 16, missingTable: "required" }, + clear: { kind: "delete", missingTable: "required" }, + role: "bookkeeping", + provisionedBy: "base", + workingCopyClonePolicy: COPY_GRAPH_ROWS, + }, + identityTransitions: { + clear: { kind: "delete", missingTable: "tolerated" }, + role: "content", + provisionedBy: "base", + workingCopyClonePolicy: COPY_GRAPH_ROWS, + }, + // The retention watermark travels with the log it bounds: a copy that kept + // the transitions but lost the watermark would report pruned history as + // complete. + identityTransitionRetention: { + clear: { kind: "delete", missingTable: "tolerated" }, role: "bookkeeping", provisionedBy: "base", workingCopyClonePolicy: COPY_GRAPH_ROWS, }, } as const satisfies Readonly>; +/** + * The order `Store.clear()` deletes relations in, first to last. Position is + * the whole contract; the constraints it carries are documented on + * {@link GraphRelationClearBehavior}. + */ +const GRAPH_RELATION_CLEAR_ORDER = [ + "fulltext", + "recordedIdentityAssertions", + "recordedEdges", + "recordedNodes", + "recordedClock", + "identityTransitionRetention", + "identityTransitions", + "identitySeparation", + "identityClosure", + "identityAssertions", + "uniques", + "edgeClaims", + "nodes", + "edges", + "revisionChanges", + "indexMaterializations", + "kindRemovals", + "reconciliationMarkers", + "contributionMaterializations", + "schemaVersions", + "revisionOrigins", +] as const satisfies readonly GraphRelationKey[]; + +/** Resolves to `Order` only when it names every relation exactly once. */ +type CompleteClearOrder = + [Exclude] extends [never] ? + Order["length"] extends (typeof GRAPH_RELATION_KEYS)["length"] ? + Order + : never + : never; + +const CLEAR_POSITIONS: CompleteClearOrder = + GRAPH_RELATION_CLEAR_ORDER; + /** Every graph-scoped relation, in declaration order. */ export const GRAPH_RELATIONS: readonly GraphRelationDeclaration[] = - GRAPH_RELATION_KEYS.map((key) => ({ key, ...BEHAVIOR[key] })); + GRAPH_RELATION_KEYS.map((key) => ({ + key, + ...BEHAVIOR[key], + clear: { + ...BEHAVIOR[key].clear, + order: CLEAR_POSITIONS.indexOf(key) + 1, + }, + })); const DECLARATIONS_BY_KEY: ReadonlyMap< GraphRelationKey, @@ -335,7 +406,7 @@ export const GRAPH_PRESENCE_ANCHOR_KEYS: readonly GraphRelationKey[] = /** A relation `Store.clear()` empties with a graph-scoped statement. */ export type GraphRelationClearStep = Readonly<{ key: GraphRelationKey; - clear: GraphRelationClearBehavior; + clear: GraphRelationDeclaration["clear"]; }>; /** The relations `Store.clear()` deletes, in the order it deletes them. */ diff --git a/packages/typegraph/src/backend/graph-storage.ts b/packages/typegraph/src/backend/graph-storage.ts index 5330193ad..8cca6efd8 100644 --- a/packages/typegraph/src/backend/graph-storage.ts +++ b/packages/typegraph/src/backend/graph-storage.ts @@ -17,7 +17,10 @@ import { sql, type SqlFragment } from "../query/sql-fragment"; import { asCompiledRowsSql } from "../query/sql-intent"; import { requireCatalog } from "./capabilities/catalog"; import { resolveRecursiveTraversal } from "./capabilities/recursive-traversal"; -import { normalizeGraphCommandIsolation } from "./command-contract"; +import { + FENCE_ISOLATION_COLUMN, + normalizeGraphCommandIsolation, +} from "./command-contract"; import { GRAPH_ID_COLUMN, GRAPH_PRESENCE_ANCHOR_KEYS, @@ -53,8 +56,6 @@ const STORAGE_COUNT_TRANSACTION = { accessMode: "read_only", } as const satisfies TransactionOptions; -const SESSION_ISOLATION_COLUMN = "transaction_isolation"; - /** Options for {@link listGraphIds}. */ export type ListGraphIdsOptions = Readonly<{ /** Only graph ids starting with this exact, case-sensitive prefix. */ @@ -589,7 +590,7 @@ async function countRows( const isolationColumn = isolationExpression === undefined ? sql`` - : sql`, ${isolationExpression} AS ${sql.identifier(SESSION_ISOLATION_COLUMN)}`; + : sql`, ${isolationExpression} AS ${sql.identifier(FENCE_ISOLATION_COLUMN)}`; const [row] = await target.execute( asCompiledRowsSql( sql`SELECT COUNT(*) AS cnt${isolationColumn} FROM ${sql.identifier(table)} WHERE ${sql.identifier(GRAPH_ID_COLUMN)} = ${graphId}`, @@ -599,7 +600,7 @@ async function countRows( rows: safeCount(row?.cnt, table), isolation: isolationExpression === undefined ? undefined : ( - normalizeGraphCommandIsolation(row?.[SESSION_ISOLATION_COLUMN]) + normalizeGraphCommandIsolation(row?.[FENCE_ISOLATION_COLUMN]) ), }; } diff --git a/packages/typegraph/src/backend/index.ts b/packages/typegraph/src/backend/index.ts index 1fa931e2f..ce51518e6 100644 --- a/packages/typegraph/src/backend/index.ts +++ b/packages/typegraph/src/backend/index.ts @@ -37,7 +37,6 @@ export { assertVectorSearchLimit, buildVectorCapabilities, quoteIdentifier, - shortHash, VECTOR_CONTRIBUTION_PREFIX, vectorMinScoreCondition, vectorPhysicalName, @@ -72,6 +71,7 @@ export type { SqlIntent, } from "../query/sql-intent"; export { resolveStampedValidityLowerBound } from "../utils/date"; +export { shortHash } from "../utils/hash"; export { BATCH_POINT_READ, batchPointReadMembers, @@ -206,6 +206,7 @@ export type { GraphCommandExecutionContext, GraphCommandIsolation, GraphCommandSession, + GraphWriteFenceAcquisition, } from "./command-contract"; export { assertGraphCommandConvergenceIsolation, @@ -213,6 +214,7 @@ export { executeAuthoritativeGraphCommand, graphCommandExecutionContext, normalizeGraphCommandIsolation, + observesPostFenceCommits, } from "./command-contract"; export type { AtomicMutationProgramConformanceCase, @@ -306,6 +308,7 @@ export type { CommitSchemaVersionIfKindsEmptyResult, CommitSchemaVersionParams, CompareAndSetNodeParams, + CompositionClaimScope, ConstraintFenceViolationRows, ContendedEdgeRow, ContendedUniqueRow, @@ -324,8 +327,8 @@ export type { ContributionRepairEntry, ContributionRepairResult, ContributionRepopulationStats, + CountEdgesAtEndpointParams, CountEdgesByKindParams, - CountEdgesFromParams, CountNodesByKindParams, CreateVectorIndexParams, DatabaseExtensionName, @@ -346,6 +349,7 @@ export type { EdgeConvergenceMatch, EdgeCreateCommand, EdgeCreateCommandResult, + EdgeEndpointAllowance, EdgeEntityReadBackend, EdgeEntityWriteBackend, EdgeExistsBetweenParams, @@ -404,6 +408,7 @@ export type { ManagedEdgeCreatePlan, ManagedNodeCreateMode, ManagedNodeCreatePlan, + MisassignedEdgeEndpointRow, NodeCreateCommand, NodeCreateCommandResult, NodeEntityReadBackend, @@ -556,7 +561,6 @@ export type { } from "../indexes/types"; export type { MetaEdgeName } from "../ontology/constants"; export { ALL_META_EDGE_NAMES } from "../ontology/constants"; -export type { InferenceType } from "../ontology/types"; export type { ValueType } from "../query/ast"; export type { JsonPointer } from "../query/json-pointer"; export type { diff --git a/packages/typegraph/src/backend/member-classes.ts b/packages/typegraph/src/backend/member-classes.ts index ab0a48288..1d5046592 100644 --- a/packages/typegraph/src/backend/member-classes.ts +++ b/packages/typegraph/src/backend/member-classes.ts @@ -65,7 +65,7 @@ const READ_MEMBERS = [ "countNodesByKind", "getEdge", "getEdges", - "countEdgesFrom", + "countEdgesAtEndpoint", "countEdgesByKind", "edgeExistsBetween", "findEdgesConnectedTo", @@ -201,6 +201,7 @@ const SCHEMA_MEMBERS = [ "instantiateGraphTemplate", "registerGraphTemplate", "setActiveVersion", + "setActiveVersionWithPreflight", "lockSchemaVersionForWrite", "lockSchemaVersionAndGraphWrite", "schemaWriteTransaction", diff --git a/packages/typegraph/src/backend/migrate-recorded-time.ts b/packages/typegraph/src/backend/migrate-recorded-time.ts index de5d67f0a..1aeab5640 100644 --- a/packages/typegraph/src/backend/migrate-recorded-time.ts +++ b/packages/typegraph/src/backend/migrate-recorded-time.ts @@ -19,10 +19,11 @@ import { type ResolvedSqlTableNames, type SqlTableNames, } from "../query/compiler/schema"; -import { shortHash } from "../query/dialect/vector-strategy"; import { sql, type SqlFragment } from "../query/sql-fragment"; import { asCompiledRowsSql, asCompiledStatementSql } from "../query/sql-intent"; import { canonicalizeDatabaseTimestamp } from "../utils/date"; +import { shortHash } from "../utils/hash"; +import { decodeRevisionColumn } from "../utils/revision-column"; import { requireCatalog } from "./capabilities/catalog"; import { resolveRecordedTimeOwnership } from "./capabilities/recorded-time-ownership"; import { resolvedTableNames } from "./table-names"; @@ -194,10 +195,7 @@ function canonicalLegacyInstant(value: unknown): string { } function safeRevision(value: unknown): number { - const revision = - typeof value === "bigint" ? Number(value) - : typeof value === "string" && /^\d+$/.test(value) ? Number(value) - : value; + const revision = decodeRevisionColumn(value); if ( typeof revision !== "number" || !Number.isSafeInteger(revision) || diff --git a/packages/typegraph/src/backend/missing-peer-ledger.ts b/packages/typegraph/src/backend/missing-peer-ledger.ts index ddc9a5870..9e6cb9e95 100644 --- a/packages/typegraph/src/backend/missing-peer-ledger.ts +++ b/packages/typegraph/src/backend/missing-peer-ledger.ts @@ -24,6 +24,7 @@ * `drizzle-orm` into a false "install drizzle-orm" instruction. */ import { ConfigurationError } from "../errors"; +import { isObjectRecord } from "../utils/object"; /** The optional peer dependency the typed refusal names. */ export const MISSING_PEER_PACKAGE = "drizzle-orm"; @@ -176,11 +177,6 @@ const MISSING_SPECIFIER_PATTERN = /Cannot find (?:package|module) '([^']+)'/; /** Matches `drizzle-orm` and `drizzle-orm/`, never a mere substring of a longer specifier or an importer path. */ const DRIZZLE_ORM_SPECIFIER_PATTERN = /^drizzle-orm(\/|$)/; -/** Narrows `unknown` to a plain object whose string-keyed members can be read defensively — never an assertion. */ -function isRecord(value: unknown): value is Readonly> { - return typeof value === "object" && value !== null; -} - /** * Whether `error` is a Node module-resolution failure naming a MISSING * `drizzle-orm` (or `drizzle-orm/`) specifier — never a whole-message @@ -189,7 +185,7 @@ function isRecord(value: unknown): value is Readonly> { * reach it only through {@link loadDrizzleBackedModule}. */ export function isMissingDrizzlePeerError(error: unknown): boolean { - if (!isRecord(error)) return false; + if (!isObjectRecord(error)) return false; const code = error["code"]; if (typeof code !== "string" || !MISSING_MODULE_ERROR_CODES.has(code)) { diff --git a/packages/typegraph/src/backend/schema-kind-emptiness.ts b/packages/typegraph/src/backend/schema-kind-emptiness.ts index f819cded7..2fe4b460b 100644 --- a/packages/typegraph/src/backend/schema-kind-emptiness.ts +++ b/packages/typegraph/src/backend/schema-kind-emptiness.ts @@ -5,19 +5,54 @@ type SchemaKindCountMembers = Pick< "countNodesByKind" | "countEdgesByKind" >; -/** Counts the explicitly selected row population for a schema transition. */ +/** + * Counts the explicitly selected row population for a schema transition, + * stopping at `limit` rows when one is given. + */ export async function countSchemaKindRows( backend: SchemaKindCountMembers, graphId: string, probe: SchemaKindEmptinessProbe, + limit?: number, ): Promise { const rowScope = { excludeDeleted: probe.rows === "nonDeleted", temporalMode: probe.rows === "nonDeleted" ? "includeEnded" : "includeTombstones", + ...(limit === undefined ? {} : { limit }), } as const; if (probe.entity === "node") { return backend.countNodesByKind({ graphId, kind: probe.kind, ...rowScope }); } return backend.countEdgesByKind({ graphId, kind: probe.kind, ...rowScope }); } + +/** + * Whether the selected row population holds at least one row: the existence + * form of {@link countSchemaKindRows}, which reads a single row rather than + * the whole population. + */ +export async function schemaKindHasRows( + backend: SchemaKindCountMembers, + graphId: string, + probe: SchemaKindEmptinessProbe, +): Promise { + return (await countSchemaKindRows(backend, graphId, probe, 1)) > 0; +} + +/** + * The probes whose row population holds at least one row, in input order. + * Sequential, not concurrent: a schema-commit preflight probes through its + * one transaction connection. + */ +export async function populatedSchemaKinds( + backend: SchemaKindCountMembers, + graphId: string, + probes: readonly SchemaKindEmptinessProbe[], +): Promise { + const populated: SchemaKindEmptinessProbe[] = []; + for (const probe of probes) { + if (await schemaKindHasRows(backend, graphId, probe)) populated.push(probe); + } + return populated; +} diff --git a/packages/typegraph/src/backend/sqlite/local.ts b/packages/typegraph/src/backend/sqlite/local.ts index 4b8515de6..c93305572 100644 --- a/packages/typegraph/src/backend/sqlite/local.ts +++ b/packages/typegraph/src/backend/sqlite/local.ts @@ -34,13 +34,14 @@ import { CompilerInvariantError, ConfigurationError } from "../../errors"; import { type FulltextStrategy } from "../../query/dialect/fulltext-strategy"; import { sqliteVecStrategy } from "../../query/dialect/vector/sqlite-vec-strategy"; import { - isSqliteDuplicateEdgeMatchIdentityColumnError, + isSqliteDuplicateColumnError, isSqliteMissingEdgeMatchIdentityColumnError, } from "../../utils/sql-errors"; import { markBundledRootAutocommitEligible } from "../capabilities/autocommit-single-statement"; import { wrapWithManagedClose } from "../derive-backend"; import { CURRENT_BASE_SCHEMA_VERSION } from "../drizzle/base-schema"; import { + EDGE_MATCH_IDENTITY_ADOPTION_COLUMNS, generateSqliteMigrationSQL, planSqliteEdgeMatchIdentityAdoption, quoteDdlIdentifier, @@ -88,8 +89,9 @@ function installLocalSqliteBaseSchema( ): void { // v2 (the fences relation), v3 (the recorded-relations' and recorded // identity-assertions relation's `since_idx` indexes), v4 (the revision - // changes relation) and v5 (PostgreSQL's byte-ordered `graph_id` indexes, - // which SQLite has no counterpart for) need no adoption + // changes relation), v5 (PostgreSQL's byte-ordered `graph_id` indexes, + // which SQLite has no counterpart for) and v6 (the identity transition log + // plus its retention watermark) need no adoption // logic beyond what `generateSqliteMigrationSQL` already emits: a // brand-new relation or index is fully covered by its own `CREATE TABLE // IF NOT EXISTS` / `CREATE INDEX IF NOT EXISTS`, on both a fresh database @@ -97,9 +99,9 @@ function installLocalSqliteBaseSchema( // edge-match-identity `ADD COLUMN` migration — handled by the catch block // below — needed runtime introspection this synchronous path writes by // hand instead of running `BaseSchemaLifecycle`'s async state machine. - if (CURRENT_BASE_SCHEMA_VERSION !== 5) { + if (CURRENT_BASE_SCHEMA_VERSION !== 6) { throw new CompilerInvariantError( - "The synchronous managed SQLite installation path only implements base-schema v1 through v5 adoption.", + "The synchronous managed SQLite installation path only implements base-schema v1 through v6 adoption.", { currentVersion: CURRENT_BASE_SCHEMA_VERSION }, ); } @@ -122,19 +124,19 @@ function installLocalSqliteBaseSchema( ); try { sqlite.exec([...adoptionSql, installationSql].join("\n")); - return; + break; } catch (repairError) { if ( attempt === 2 || - !isSqliteDuplicateEdgeMatchIdentityColumnError(repairError) + !isSqliteDuplicateColumnError( + repairError, + EDGE_MATCH_IDENTITY_ADOPTION_COLUMNS, + ) ) { throw repairError; } } } - throw new CompilerInvariantError( - "Local SQLite match-identity adoption exhausted its retry loop without returning or throwing.", - ); } } diff --git a/packages/typegraph/src/backend/types.ts b/packages/typegraph/src/backend/types.ts index 045dab107..f933d33af 100644 --- a/packages/typegraph/src/backend/types.ts +++ b/packages/typegraph/src/backend/types.ts @@ -5,7 +5,6 @@ * SQL implementations (SQLite, PostgreSQL) behind a common interface. */ import { - type Cardinality, type IndexEntity, type JsonScalar, type JsonValue, @@ -26,6 +25,10 @@ import { type CompiledTemporaryStatementSql, } from "../query/sql-intent"; import { type SerializedSchema } from "../schema/types"; +// Type-only: `store/claims/edge-claims.ts` value-imports several params +// types from this file, but a type-only import is erased at runtime, so this +// back-edge creates no value cycle. +import { type EdgeCardinalityAxisRef } from "../store/claims/edge-claims"; import { typeGraphGlobalSymbol } from "../utils/global-symbol"; // ============================================================ @@ -627,6 +630,17 @@ export function isLiveNodeRow(row: NodeRow): row is LiveNodeRow { return row.deleted_at === undefined; } +/** + * Whether a node read found a row and that row is live: present and not + * tombstoned. Valid time is deliberately not part of it — a row whose window + * has closed is still a row an edge may point at. + */ +export function isLiveNodeRowPresent( + row: NodeRow | undefined, +): row is LiveNodeRow { + return row !== undefined && isLiveNodeRow(row); +} + export function isTombstonedNodeRow(row: NodeRow): row is TombstonedNodeRow { return row.deleted_at !== undefined; } @@ -642,6 +656,7 @@ export type GraphReadBackend = Pick< GraphBackend, | "dialect" | "getNode" + | "getNodes" | "getEdge" | "findNodesByKind" | "findEdgesByKind" @@ -1195,7 +1210,14 @@ export type ManagedEdgeCreatePlan = Readonly<{ entity: "edge"; params: InsertEdgeParams; schemaFence?: SchemaWriteFenceParams; - cardinalityClaim?: ClaimEdgeCardinalityParams; + /** + * Every cardinality claim this create owes. A command port applies EVERY + * entry or returns `unsupported` with the `"cardinalityClaim"` dimension + * before executing any SQL — the bundled fused insert applies exactly one + * entry and refuses a two-axis plan to the portable path (see + * `executeEdgeManagedCreate`). + */ + cardinalityClaims?: readonly ClaimEdgeCardinalityParams[]; }>; /** A node create command accepted by the authoritative command port. */ @@ -1291,6 +1313,25 @@ export type GraphCommandSession = "root" | "transaction"; export type GraphCommandIsolation = "read_committed" | "repeatable_read" | "serializable" | "unknown"; +/** + * What one acquisition of the per-graph write fence observed and did on the + * session that took it. + * + * `fenceRow` is `"advanced"` when the acquiring statement also advanced the + * graph's row in the fences relation. Every holder that makes a decision only + * the fence protects advances that row, so a session whose snapshot predates + * another such holder's commit fails on the row with a serialization failure + * instead of deciding on a graph it can no longer see. `"none"` is an + * acquisition that took the keyed exclusion alone — a holder that decides + * nothing, or a fence that cannot reach a fences relation. Nothing detects a + * stale snapshot there, so only an isolation that takes a fresh snapshot per + * statement may make such a decision under it. + */ +export type GraphWriteFenceAcquisition = Readonly<{ + isolation: GraphCommandIsolation; + fenceRow: "advanced" | "none"; +}>; + declare const GRAPH_COMMAND_COORDINATION_BRAND: unique symbol; /** Compile-time evidence that graph-write coordination is already established. */ @@ -2179,19 +2220,26 @@ export type InternalTransactionOptions = TransactionOptions & // ============================================================ /** - * The physical names of the four Operational Identity relations: the current - * assertion ledger, its recorded-time twin, and the two DERIVED relations - * (closure, separation) rebuilt from the ledger. + * The physical names of the six Operational Identity relations: the current + * assertion ledger, its recorded-time twin, the two DERIVED relations + * (closure, separation) rebuilt from the ledger, and the transition log's + * append-only annotation relation plus its retention watermark. * * Named once so the two ports that speak about them — * {@link GraphBackend.ensureIdentityTables} and - * {@link GraphBackend.identityTableDdl} — cannot drift apart. + * {@link GraphBackend.identityTableDdl} — cannot drift apart. A backend + * that implements those ports directly (rather than through the bundled + * Drizzle profiles, which also self-heal these two relations through base + * schema adoption) is the sole provisioning path for the transition log on + * such a backend, so both fields are required here, not optional. */ export type IdentityTableNames = Readonly<{ identityAssertions: string; recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; }>; /** @@ -2455,7 +2503,10 @@ export type GraphBackend = Readonly<{ ) => Promise; // === Edge Cardinality Operations === - countEdgesFrom: (this: void, params: CountEdgesFromParams) => Promise; + countEdgesAtEndpoint: ( + this: void, + params: CountEdgesAtEndpointParams, + ) => Promise; edgeExistsBetween: ( this: void, params: EdgeExistsBetweenParams, @@ -2662,7 +2713,7 @@ export type GraphBackend = Readonly<{ * * Returns the contending ROWS, not the verdict: the axis a `uniques` row * belongs to is a fold over the graph's subclass component, and the key an - * edge's claim sits on is `EDGE_CARDINALITY_SPECS`' — both of which live + * edge's claim sits on is `edgeCardinalitySpec`'s — both of which live * above the backend, so a backend that decided either would be a second * spelling of a decision the fence already owns. * @@ -2743,8 +2794,12 @@ export type GraphBackend = Readonly<{ /** * PostgreSQL/PGlite transaction-scoped fast path that takes the active * schema `FOR SHARE` fence and the recorded graph advisory lock in one SQL - * statement, in that order. The graph-lock CTE is reachable only through a - * matching schema row, so a stale Store never acquires the later lock. + * statement, in that order, and — when `params.fenceRow` is `"advance"` — + * the graph's fence-row advance after them. The graph-lock CTE is reachable + * only through a matching schema row, so a stale Store never acquires the + * later lock. The result reports what the statement observed and did, + * exactly as the portable acquisition does: `fenceRow: "advanced"` only + * when the statement wrote the row. * * Bundled PostgreSQL/PGlite transaction targets expose this member; their * root backends omit it so it cannot be used outside an already-open @@ -2755,8 +2810,8 @@ export type GraphBackend = Readonly<{ */ lockSchemaVersionAndGraphWrite?: ( this: void, - params: SchemaWriteFenceParams, - ) => Promise; + params: SchemaGraphWriteFenceParams, + ) => Promise; /** * Internal schema-lifecycle seam for features whose data preflight must * commit atomically with the schema CAS. The callback runs in the same @@ -2790,8 +2845,27 @@ export type GraphBackend = Readonly<{ this: void, params: SetActiveVersionParams, ) => Promise; + /** + * {@link GraphBackend.setActiveVersion} with a data preflight, for a flip + * that reactivates a stricter schema than the active one. The callback runs + * in the same write transaction after the schema write fence is acquired + * and before the pointer flip, exactly as + * {@link GraphBackend.commitSchemaVersionWithPreflight} runs its own. + * + * @internal + */ + setActiveVersionWithPreflight?: ( + this: void, + params: SetActiveVersionParams, + preflight: (target: SchemaCommitPreflightBackend) => Promise, + ) => Promise; - /** Register an immutable materialized schema template. */ + /** + * Register an immutable materialized schema template: insert it when no + * template holds `templateId`, then return the row stored under that id, + * whichever release wrote it. The caller judges whether the stored row + * holds the same schema; a member does not refuse a differing row itself. + */ registerGraphTemplate?: ( this: void, params: Readonly<{ @@ -3545,7 +3619,8 @@ export type GraphBackend = Readonly<{ * Clears graph data while retaining contribution markers for physical * storage that remains provisioned. Optional so custom backends can keep * their existing `clearGraph` behavior; `Store.clear()` uses this member - * when available. + * when available, and refuses a stated + * `preserveContributionMaterializations: true` when it is absent. */ clearGraphPreservingContributionMaterializations?: ( this: void, @@ -3797,7 +3872,7 @@ export type EdgeEntityReadBackend = Pick< GraphBackend, | "getEdge" | "getEdges" - | "countEdgesFrom" + | "countEdgesAtEndpoint" | "edgeExistsBetween" | "findEdgesConnectedTo" | "findEdgesByKind" @@ -3948,15 +4023,22 @@ export type BackendLifecycle = Pick; /** * Read-oriented transaction projection exposed by portable transaction - * contexts. It preserves snapshot-aware graph reads and TypeGraph-compiled - * row queries while excluding graph writes, arbitrary raw SQL, DDL, - * transaction adoption, and lifecycle control. + * contexts. It preserves snapshot-aware graph reads and SQL rendering while + * excluding graph writes, statement execution, DDL, transaction adoption, and + * lifecycle control. + * + * Statement execution (`execute`) is absent on purpose. Its compiled-query + * brand is a type, not a runtime check, so on a transaction it would run any + * SQL on the pinned session — including a `ROLLBACK TO SAVEPOINT` that + * releases a fence lock a later TypeGraph write in the same transaction still + * relies on. A transaction runs TypeGraph queries through `tx.query()`; raw + * SQL goes through an adapter transaction's `tx.sql`, the one raw handle a + * transaction context carries. */ export type TransactionReadBackend = Readonly< BackendIdentity & GraphEntityReadBackend & SchemaReadBackend & - QueryExecutionBackend & SqlCompilationBackend >; @@ -4037,13 +4119,22 @@ export type SchemaWriteTransactionBackend = TransactionBackend & export type SchemaCommitPreflightBackend = TransactionBackend & Readonly<{ executeSchemaDdl?: (this: void, ddl: string) => Promise; + /** + * The read-only fence audit, when the preflight target can run it. + * Optional for the same reason `executeSchemaDdl` is: a custom + * backend's `commitSchemaVersionWithPreflight` may hand back a + * transaction that cannot. An ontology-tightening preflight that needs + * it refuses with `CONSTRAINT_FENCE_AUDIT_UNSUPPORTED` rather than + * skipping the check. + */ + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; }>; /** * Builds the actual runtime projection exposed as `TransactionContext.backend`. * This is an explicit object rather than a type-only narrowing so portable - * callers cannot discover write or arbitrary-SQL methods on the underlying - * transaction backend at runtime. + * callers cannot discover write or statement-execution methods on the + * underlying transaction backend at runtime. * * @internal */ @@ -4083,7 +4174,7 @@ export function createTransactionReadBackend( getEdges: (graphId: string, ids: readonly string[]) => getEdges(graphId, ids), }), - countEdgesFrom: (params) => backend.countEdgesFrom(params), + countEdgesAtEndpoint: (params) => backend.countEdgesAtEndpoint(params), edgeExistsBetween: (params) => backend.edgeExistsBetween(params), findEdgesConnectedTo: (params) => backend.findEdgesConnectedTo(params), findNodesByKind: (params) => backend.findNodesByKind(params), @@ -4111,7 +4202,6 @@ export function createTransactionReadBackend( getActiveSchema: (graphId) => backend.getActiveSchema(graphId), getSchemaVersion: (graphId, version) => backend.getSchemaVersion(graphId, version), - execute: (query: CompiledRowsSql) => backend.execute(query), ...(compileSql === undefined ? {} : { compileSql: (query: SqlFragment) => compileSql(query) }), @@ -4261,35 +4351,54 @@ export type HardDeleteUniquesByConcreteKindParams = Readonly<{ concreteKind: string; }>; +/** + * Present only on a composition claim: the reserved relation-wide axis + * (`COMPOSITION_RELATION_NAME`), and the ORIENTED realizing edge + * kinds whose live rows can hold it — every edge kind the graph's + * `partOf`/`hasPart` declarations resolve to, tagged with which endpoint of + * that kind carries the part. Absent means the ordinary per-edge-kind + * cardinality claim. + * + * The two arms this carries (`partSide: "from"` vs `"to"`) are what let + * {@link file://./drizzle/operations/edge-claims.ts claimHolderTerms} render + * the "does a live edge already hold this part" predicate as a cross-kind, + * oriented OR — a plain `kind IN (...)` cannot express which endpoint of each + * kind is the part. + */ +export type CompositionClaimScope = Readonly<{ + kind: "composition"; + holders: readonly Readonly<{ edgeKind: string; partSide: "from" | "to" }>[]; +}>; + /** * One edge cardinality claim, named by the components its axis, its key and its * holder-liveness predicate are all built from. * - * The components are passed RAW rather than pre-rendered: `EDGE_CARDINALITY_SPECS` - * (`store/claims/edge-claims.ts`) is the one table that decides which endpoints - * the key covers and what a holder must still be, and both the TypeScript probe - * and the SQL builder read it. A caller that rendered the axis and key itself - * would be a second spelling of that decision. + * The components are passed RAW rather than pre-rendered: `edgeCardinalitySpec` + * (`store/claims/edge-claims.ts`) is the one function that decides which + * endpoints the key covers and what a holder must still be, and both the + * TypeScript probe and the SQL builder read it. A caller that rendered the + * axis and key itself would be a second spelling of that decision. */ -export type ClaimEdgeCardinalityParams = Readonly<{ - graphId: string; - /** The declared cardinality; `many` declares nothing and never claims. */ - cardinality: Exclude; - edgeKind: string; - /** The edge that will hold the axis if this claim lands. */ - edgeId: string; - fromKind: string; - fromId: string; - toKind: string; - toId: string; -}>; +export type ClaimEdgeCardinalityParams = EdgeCardinalityAxisRef & + Readonly<{ + graphId: string; + edgeKind: string; + /** The edge that will hold the axis if this claim lands. */ + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + scope?: CompositionClaimScope; + }>; /** * What a claim statement decided. * * `refused` carries the incumbent so the caller can say which edge holds the - * axis; the typed refusal itself is the store's, built from the same - * `checkCardinality` / `checkUniqueEdge` owners the probe uses, so a caller + * axis; the typed refusal itself is the store's, built from + * `edgeCardinalityViolation`, the same owner the probe uses, so a caller * cannot tell which layer refused. */ export type EdgeClaimOutcome = @@ -4303,11 +4412,18 @@ export type PurgeEdgeClaimsParams = Readonly<{ }>; /** One edge kind's declared cardinality, as the fence audit reads it. */ -export type EdgeCardinalityDeclaration = Readonly<{ - edgeKind: string; - /** `many` declares nothing, so it is unrepresentable here. */ - cardinality: Exclude; -}>; +export type EdgeCardinalityDeclaration = EdgeCardinalityAxisRef & + Readonly<{ + edgeKind: string; + /** + * Present when this edge kind's declared axis is a composition axis — + * see {@link CompositionClaimScope}. The fence-audit reader groups and + * queries a composition declaration by its two oriented arms rather than + * by exact-kind equality, because the composition axis is relation-wide: two + * different realizing edge kinds contend for the SAME row. + */ + scope?: CompositionClaimScope; + }>; /** * What a constraint-fence audit asks the database about: the declarations the @@ -4328,6 +4444,31 @@ export type ReadConstraintFenceViolationsParams = Readonly<{ disjointKindPairs: readonly (readonly [string, string])[]; /** Every edge kind declaring a cardinality other than `many`. */ edgeCardinalities: readonly EdgeCardinalityDeclaration[]; + /** + * Edge kinds whose live rows must still sit on a declared endpoint pair. + * Optional (and, when present, possibly empty) so a params object built + * before this family existed keeps compiling: an absent value means the + * caller does not ask for the family, and `readConstraintFenceViolations` + * must then omit `misassignedEdgeEndpointRows` rather than reporting a + * clean (and therefore reassuring) empty result for it. + */ + edgeEndpointAllowances?: readonly EdgeEndpointAllowance[]; +}>; + +/** Every concrete `(fromKind, toKind)` pair an edge kind's declaration still admits. */ +export type EdgeEndpointAllowance = Readonly<{ + edgeKind: string; + allowedPairs: readonly (readonly [string, string])[]; +}>; + +/** One live edge whose endpoints no declared pair admits. */ +export type MisassignedEdgeEndpointRow = Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; }>; /** @@ -4347,16 +4488,31 @@ export type ContendedUniqueRow = Readonly<{ * One live edge that shares its declared cardinality's population with at least * one other live edge. The endpoints are returned whole so the caller can name * the claim key through the one builder that renders it. + * + * `scope` names which declaration's query produced this row — the ordinary + * per-edge-kind axis (`undefined`) or the reserved, relation-wide composition + * axis (present) — exactly as {@link EdgeCardinalityDeclaration.scope} names + * it for the declaration itself. The reader must never re-derive this from + * the row's own (possibly dirty) endpoints: which query found the row already + * says which axis it contends on. + * + * Required-but-nullable, not optional: a custom `readConstraintFenceViolationRows` + * implementation that omits the field would compile with `scope` silently + * `undefined` on every row, misclassifying every genuine composition row as + * an ordinary one — the exact drift this field exists to prevent. Spelling + * it `| undefined` forces a backend author to state the fact explicitly, + * even when the answer is "never composition" (`undefined` for every row). */ -export type ContendedEdgeRow = Readonly<{ - edgeKind: string; - cardinality: Exclude; - edgeId: string; - fromKind: string; - fromId: string; - toKind: string; - toId: string; -}>; +export type ContendedEdgeRow = EdgeCardinalityAxisRef & + Readonly<{ + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + scope: CompositionClaimScope | undefined; + }>; /** One node id live under BOTH kinds of a declared disjoint pair. */ export type DisjointOverlapRow = Readonly<{ @@ -4369,6 +4525,12 @@ export type ConstraintFenceViolationRows = Readonly<{ contendedUniqueRows: readonly ContendedUniqueRow[]; contendedEdgeRows: readonly ContendedEdgeRow[]; disjointOverlaps: readonly DisjointOverlapRow[]; + /** + * Present whenever `edgeEndpointAllowances` was asked for (defined on the + * params, even if empty). A backend that cannot answer the family omits + * it, and the caller refuses rather than reporting a clean result. + */ + misassignedEdgeEndpointRows?: readonly MisassignedEdgeEndpointRow[]; }>; /** @@ -4467,6 +4629,20 @@ export type LockSchemaVersionForWriteParams = Readonly<{ /** Parameters shared by the ordinary and fused schema-write fences. */ export type SchemaWriteFenceParams = LockSchemaVersionForWriteParams; +/** + * Parameters of the fused schema + graph write fence + * ({@link GraphBackend.lockSchemaVersionAndGraphWrite}). + * + * `fenceRow` states what the acquirer does under the graph fence. `"advance"` + * is a writer that goes on to make a decision only the fence protects (an + * edge-acyclicity probe, a composition whole's parts closure, a match-key + * convergence): the statement advances the graph's row in the fences + * relation, behind the lock. `"leave"` is every other holder: the statement + * takes the lock alone and writes no row. + */ +export type SchemaGraphWriteFenceParams = SchemaWriteFenceParams & + Readonly<{ fenceRow: "advance" | "leave" }>; + /** * Optional schema-managed write identity for a trusted import. Supplying it * acquires and validates the Store version's transaction-scoped schema fence; @@ -4491,13 +4667,28 @@ export type SetActiveVersionParams = Readonly<{ }>; /** - * Parameters for counting edges from a source node. + * Parameters for counting edges at one endpoint. + * + * One member and one statement answer both the source-side and target-side + * count rather than a mirrored `countEdgesTo` — two members with two SQL + * builders, two cache keys and two liveness spellings that would drift. + * `endpoint` selects which column pair (`fromKind`/`fromId` or + * `toKind`/`toId`) the predicate reads. */ -export type CountEdgesFromParams = Readonly<{ +export type CountEdgesAtEndpointParams = Readonly<{ graphId: string; edgeKind: string; - fromKind: string; - fromId: string; + /** Which endpoint column pair the predicate reads. */ + endpoint: "from" | "to"; + /** + * Deliberately not `kind` / `id`: those names, on a shape this narrow, + * would structurally satisfy {@link DeleteEdgeParams} / + * {@link HardDeleteEdgeParams} (`{graphId, id, kind?}`) and silently + * conscript this READ into `write-surface.ts`'s derived graph-entity WRITE + * surface. + */ + endpointKind: string; + endpointId: string; /** If true, only count edges where valid_to IS NULL */ activeOnly?: boolean; }>; @@ -4582,6 +4773,11 @@ export type CountNodesByKindParams = Readonly<{ temporalMode?: TemporalMode; /** Timestamp for "current" and "asOf" temporal modes. */ asOf?: string; + /** + * Count at most this many rows. A backend that ignores it still returns a + * correct, merely larger, count. + */ + limit?: number; }>; /** @@ -4741,6 +4937,11 @@ export type CountEdgesByKindParams = Readonly<{ temporalMode?: TemporalMode; /** Timestamp for "current" and "asOf" temporal modes. */ asOf?: string; + /** + * Count at most this many rows. A backend that ignores it still returns a + * correct, merely larger, count. + */ + limit?: number; }>; // ============================================================ diff --git a/packages/typegraph/src/constraints/index.ts b/packages/typegraph/src/constraints/index.ts index b579f20be..9e5ca67e1 100644 --- a/packages/typegraph/src/constraints/index.ts +++ b/packages/typegraph/src/constraints/index.ts @@ -19,7 +19,6 @@ const UNIQUE_KEY_SEPARATOR = "\u001E"; const UNIQUE_KEY_NULL_MARKER = "\u001F"; // ASCII Unit Separator import { getEdgeEndpointPairs, isEdgeTargetMap } from "../core/edge-endpoints"; import { - type Cardinality, type Collation, type EdgeRegistration, type NullCheckOp, @@ -27,14 +26,17 @@ import { type UniquenessScope, } from "../core/types"; import { - CardinalityError, + uniqueCollationFoldsCase, + uniquenessScopeSpansSubclasses, +} from "../core/unique-constraint-options"; +import { ConfigurationError, DisjointError, EndpointError, EndpointPairError, - UniquenessError, } from "../errors"; import { type KindRegistry } from "../registry/kind-registry"; +import { compareCodePoints } from "../utils/compare"; import { hasOwnKey, readOwnProperty } from "../utils/object"; import { isPresent } from "../utils/presence"; @@ -73,7 +75,7 @@ export function computeUniqueKey( : typeof value === "number" || typeof value === "boolean" ? value.toString() : JSON.stringify(value); - return collation === "caseInsensitive" ? + return uniqueCollationFoldsCase(collation) ? stringValue.toLowerCase() : stringValue; }); @@ -366,12 +368,12 @@ export function getKindsForUniquenessCheck( scope: UniquenessScope, registry: KindRegistry, ): readonly string[] { - if (scope === "kind") { + if (!uniquenessScopeSpansSubclasses(scope)) { return [baseKind]; } // Get the entire connected subclass hierarchy by finding the root ancestor - const root = findRootAncestor(baseKind, registry); + const root = rootAncestor(baseKind, registry); // Return the root and all its descendants (which includes baseKind and siblings) return registry.expandSubClasses(root); @@ -403,128 +405,33 @@ export function subClassComponent( } /** - * Finds the topmost ancestor of a kind, or the kind itself if it has no ancestors. - */ -function findRootAncestor(kind: string, registry: KindRegistry): string { - const ancestors = registry.getAncestors(kind); - - if (ancestors.size === 0) { - return kind; - } - - // Find an ancestor with no ancestors (the root) - for (const ancestor of ancestors) { - if (registry.getAncestors(ancestor).size === 0) { - return ancestor; - } - } - - // If all ancestors have ancestors, recurse up - const firstAncestor = [...ancestors][0]; - return firstAncestor ? findRootAncestor(firstAncestor, registry) : kind; -} - -/** - * Creates a uniqueness error. - */ -export function createUniquenessError( - constraintName: string, - kind: string, - existingId: string, - newId: string, - fields: readonly string[], -): UniquenessError { - return new UniquenessError({ - constraintName, - kind, - existingId, - newId, - fields: [...fields], - }); -} - -// ============================================================ -// Cardinality Validation -// ============================================================ - -/** - * Checks if adding an edge would violate cardinality constraints. + * The kind whose descendants a `kindWithSubClasses` probe walks: the + * code-point-minimum MAXIMAL element of `kind` plus its ancestors, where + * maximal means "every ancestor of this kind is also one of its descendants", + * i.e. nothing sits strictly above it. * - * @param edgeKind - The edge kind being added - * @param fromKind - The source node kind - * @param fromId - The source node ID - * @param cardinality - The cardinality constraint - * @param existingEdgeCount - Number of existing edges of this kind from this source - * @param hasActiveEdge - Whether there's an active (valid_to IS NULL) edge - * @returns Error if violation, undefined if valid - */ -export function checkCardinality( - edgeKind: string, - fromKind: string, - fromId: string, - cardinality: Cardinality, - existingEdgeCount: number, - hasActiveEdge: boolean, -): CardinalityError | undefined { - switch (cardinality) { - case "many": { - // No constraint - return undefined; - } - case "one": { - // At most one edge of this kind from any source node - if (existingEdgeCount > 0) { - return new CardinalityError({ - edgeKind, - fromKind, - fromId, - cardinality: "one", - existingCount: existingEdgeCount, - }); - } - return undefined; - } - case "unique": { - // unique is checked separately per (source, target) pair - return undefined; - } - case "oneActive": { - // At most one edge with valid_to IS NULL from any source - if (hasActiveEdge) { - return new CardinalityError({ - edgeKind, - fromKind, - fromId, - cardinality: "oneActive", - existingCount: 1, - }); - } - return undefined; - } - } -} - -/** - * Checks unique edge constraint (at most one edge between any source-target pair). + * "The ancestor with no ancestors" stopped being a definition once + * `equivalentTo` became mutual subsumption: an equivalence class is a + * cycle in the ancestor relation, so with `Company ≡ Corporation` and no other + * parent, neither is ancestor-free and the old recursion never terminated. + * Picking the code-point minimum of the maximal set restores termination AND + * removes the old iteration-order dependence — the previous code returned + * whichever root `Set` insertion order happened to visit first. + * + * The probe stays deliberately narrower than the claim axis's component + * (`subClassComponent`): see that function's note on multi-root hierarchies. */ -export function checkUniqueEdge( - edgeKind: string, - fromKind: string, - fromId: string, - _toKind: string, - _toId: string, - existingCount: number, -): CardinalityError | undefined { - if (existingCount > 0) { - return new CardinalityError({ - edgeKind, - fromKind, - fromId, - cardinality: "unique", - existingCount, - }); - } - return undefined; +function rootAncestor(kind: string, registry: KindRegistry): string { + const candidates = [kind, ...registry.getAncestors(kind)]; + const maximal = candidates.filter((candidate) => + [...registry.getAncestors(candidate)].every((ancestor) => + registry.isSubClassOf(ancestor, candidate), + ), + ); + const ordered = (maximal.length > 0 ? maximal : candidates).toSorted( + (left, right) => compareCodePoints(left, right), + ); + return ordered[0] ?? kind; } // ============================================================ diff --git a/packages/typegraph/src/core/define-graph.ts b/packages/typegraph/src/core/define-graph.ts index cbb0e8441..3394eb12d 100644 --- a/packages/typegraph/src/core/define-graph.ts +++ b/packages/typegraph/src/core/define-graph.ts @@ -17,6 +17,7 @@ import { normalizeTargetMap, validateTargetMapEntries, } from "./edge-endpoints"; +import { assertEdgeIntegrityOptions } from "./edge-integrity-options"; import { isPortableEdgeMatchIdentityValue } from "./edge-match-identity-value"; import { assertGraphAnnotations, @@ -39,6 +40,12 @@ import { type NodeType, type TemporalMode, } from "./types"; +import { + assertUniqueConstraintOptions, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + type StatedUniqueConstraintOptions, + withUniqueConstraintDefaults, +} from "./unique-constraint-options"; // ============================================================ // Graph Definition Brand Symbol @@ -186,6 +193,8 @@ function normalizeEdgeEntry( return { type: entry, from: allNodeTypes, to: allNodeTypes }; } + assertEdgeIntegrityOptions(name, entry); + let normalizedRegistration: AnyEdgeRegistration = entry; if (!Array.isArray(entry.to)) { validateTargetMapEntries(name, entry.from, entry.to); @@ -560,7 +569,7 @@ export type AllEdgeTypes = { export function defineGraph< const TNodes extends Record>, const TEdges extends Record, - const TOntology extends readonly OntologyRelation[], + const TOntology extends readonly OntologyRelation[] = readonly [], const TIdentity extends GraphIdentityConfig | undefined = undefined, >( config: GraphDefConfig, @@ -577,7 +586,7 @@ export function defineGraph< function defineGraphUnchecked< const TNodes extends Record>, const TEdges extends Record, - const TOntology extends readonly OntologyRelation[], + const TOntology extends readonly OntologyRelation[] = readonly [], const TIdentity extends GraphIdentityConfig | undefined = undefined, >( config: GraphDefConfig, @@ -597,8 +606,9 @@ function defineGraphUnchecked< const allNodeTypes = Object.values(config.nodes).map((reg) => reg.type); const normalizedEdges = normalizeEdges(config.edges, allNodeTypes); - assertClaimNamesAreSafe(config.nodes); - assertUniqueConstraintsAreDeclared(config.nodes); + assertClaimNamesAreSafe(config.nodes, normalizedEdges); + const nodes = withUniqueConstraintDefaultsWritten(config.nodes); + assertUniqueConstraintsAreDeclared(nodes); // Vector indexes are auto-derived from `embedding()` brands on node // schemas (see `autoDeriveVectorIndexes`). Explicit declarations // passed via `defineGraph({ indexes })` win on (kind, fieldPath) @@ -628,7 +638,7 @@ function defineGraphUnchecked< [GRAPH_DEF_BRAND]: true as const, id: config.id, annotations, - nodes: config.nodes, + nodes, edges: normalizedEdges, ontology: config.ontology ?? ([] as unknown as TOntology), identity: config.identity as TIdentity, @@ -655,7 +665,7 @@ function defineGraphUnchecked< export function defineInternalGraph< const TNodes extends Record>, const TEdges extends Record, - const TOntology extends readonly OntologyRelation[], + const TOntology extends readonly OntologyRelation[] = readonly [], const TIdentity extends GraphIdentityConfig | undefined = undefined, >( config: GraphDefConfig, @@ -687,6 +697,7 @@ const EMPTY_DEPRECATED_KINDS: ReadonlySet = Object.freeze( */ function assertClaimNamesAreSafe( nodes: Record, + edges: Record, ): void { for (const registration of Object.values(nodes)) { assertNodeKindNameSafe(registration.type.kind); @@ -694,6 +705,62 @@ function assertClaimNamesAreSafe( assertClaimAxisSafe(constraint.name, "Unique constraint"); } } + // Edge kinds are checked here too: no collision is reachable today or + // after `targetCardinality` (a source axis always begins with a + // cardinality literal, a target axis always with U+001E), but the + // reserved-vocabulary rule should hold for every name that reaches an + // axis, not only the ones that currently could collide. + for (const registration of Object.values(edges)) { + assertClaimAxisSafe(registration.type.kind, "Edge kind"); + } +} + +/** + * `nodes` with every unique constraint's `scope` and `collation` written out, + * refusing a stated value outside its domain. + * + * Every reader of a registration — the claim axis, the serializer, and + * through it the stored-schema parser — then sees one declaration, read as + * {@link NODE_UNIQUE_CONSTRAINT_DEFAULTS} says. Only an untyped caller can + * omit either member, so a graph whose declarations state both is returned + * as given. + * + * @throws ConfigurationError (`UNIQUE_CONSTRAINT_OPTION_INVALID`) + */ +function withUniqueConstraintDefaultsWritten< + TNodes extends Record, +>(nodes: TNodes): TNodes { + const registrations = Object.entries(nodes); + for (const [, registration] of registrations) { + for (const constraint of registration.unique ?? []) { + assertUniqueConstraintOptions(registration.type.kind, constraint); + } + } + const statesEveryOption = registrations.every(([, registration]) => + (registration.unique ?? []).every( + (constraint: StatedUniqueConstraintOptions) => + constraint.scope !== undefined && constraint.collation !== undefined, + ), + ); + if (statesEveryOption) return nodes; + const written = createDataKeyedBag(); + for (const [kind, registration] of registrations) { + written[kind] = + registration.unique === undefined ? + registration + : { + ...registration, + unique: registration.unique.map((constraint) => + withUniqueConstraintDefaults( + constraint, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + ), + ), + }; + } + // Spread at the boundary: this becomes `GraphDef.nodes`. See + // `createDataKeyedBag` in ../utils/object.ts. + return { ...written } as TNodes; } /** diff --git a/packages/typegraph/src/core/edge-integrity-options.ts b/packages/typegraph/src/core/edge-integrity-options.ts new file mode 100644 index 000000000..0828d73a2 --- /dev/null +++ b/packages/typegraph/src/core/edge-integrity-options.ts @@ -0,0 +1,140 @@ +/** + * The values an edge registration's integrity options may take, and the one + * check that refuses anything else. + * + * `cardinality`, `targetCardinality`, `endpointExistence` and `acyclic` are read by string and + * strict-boolean comparison all over the store (the claim axes, the + * acyclicity relation, the serializer, introspection), and every one of those + * readers treats a value it does not recognize as "undeclared". So an + * untyped caller's `acyclic: "yes"` or `cardinality: "bogus"` must be refused + * where the registration is accepted, never carried into a graph whose + * declared constraint then silently does not hold — or into a stored schema + * the loader's own enum rejects. + * + * The value lists live here because the layers validate against them: this + * module (`defineGraph`), the stored-schema parser (`src/schema/types.ts`) + * and the runtime graph-extension validator + * (`src/graph-extension/validation.ts`). + */ +import { ConfigurationError } from "../errors/index"; +import { + type Cardinality, + type EndpointExistence, + type TargetCardinality, +} from "./types"; + +export const CARDINALITY_VALUES = [ + "many", + "one", + "unique", + "oneActive", +] as const satisfies readonly Cardinality[]; + +/** Every {@link CARDINALITY_VALUES} entry a target side may declare. */ +export const TARGET_CARDINALITY_VALUES: readonly TargetCardinality[] = + CARDINALITY_VALUES.filter( + (cardinality): cardinality is TargetCardinality => cardinality !== "unique", + ); + +/** + * The target-side cardinality a registration or stored edge declares. An + * absent `targetCardinality` means `"many"`: the serializer omits the + * default (see {@link serializedTargetCardinalityField}), so documents stored + * before the option existed keep their hash. + */ +export function effectiveTargetCardinality( + declared: TargetCardinality | undefined, +): TargetCardinality { + return declared ?? "many"; +} + +/** + * The `targetCardinality` field a serialized edge carries: omitted when + * undeclared or declared as the default, so a graph that never uses the + * option serializes and hashes byte-identically to a document produced + * before it existed. + */ +export function serializedTargetCardinalityField( + declared: TargetCardinality | undefined, +): Readonly<{ targetCardinality?: TargetCardinality }> { + return declared === undefined || declared === "many" ? + {} + : { targetCardinality: declared }; +} + +export const ENDPOINT_EXISTENCE_VALUES = [ + "notDeleted", + "currentlyValid", + "ever", +] as const satisfies readonly EndpointExistence[]; + +/** The integrity options of one edge registration, as an untyped caller may state them. */ +type StatedEdgeIntegrityOptions = Readonly<{ + cardinality?: unknown; + targetCardinality?: unknown; + endpointExistence?: unknown; + acyclic?: unknown; +}>; + +function assertStatedLiteral( + edgeName: string, + option: "cardinality" | "targetCardinality" | "endpointExistence", + value: unknown, + allowed: readonly string[], +): void { + if (value === undefined) return; + if (typeof value === "string" && allowed.includes(value)) return; + const list = allowed.map((entry) => `"${entry}"`).join(", "); + throw new ConfigurationError( + `Edge "${edgeName}" declares \`${option}\` with a value that is not one of: ${list}.`, + { + code: "EDGE_INTEGRITY_OPTION_INVALID", + edgeKind: edgeName, + option, + value, + }, + { suggestion: `Declare \`${option}\` as one of ${list}, or omit it.` }, + ); +} + +/** + * Refuses an edge registration whose `cardinality`, `targetCardinality`, + * `endpointExistence` or `acyclic` is stated with a value outside its domain. + * + * @throws ConfigurationError (`EDGE_INTEGRITY_OPTION_INVALID`) + */ +export function assertEdgeIntegrityOptions( + edgeName: string, + stated: StatedEdgeIntegrityOptions, +): void { + assertStatedLiteral( + edgeName, + "cardinality", + stated.cardinality, + CARDINALITY_VALUES, + ); + assertStatedLiteral( + edgeName, + "targetCardinality", + stated.targetCardinality, + TARGET_CARDINALITY_VALUES, + ); + assertStatedLiteral( + edgeName, + "endpointExistence", + stated.endpointExistence, + ENDPOINT_EXISTENCE_VALUES, + ); + if (stated.acyclic !== undefined && typeof stated.acyclic !== "boolean") { + throw new ConfigurationError( + `Edge "${edgeName}" declares \`acyclic\` with a value that is not a boolean.`, + { + code: "EDGE_INTEGRITY_OPTION_INVALID", + edgeKind: edgeName, + option: "acyclic", + value: stated.acyclic, + }, + { suggestion: "Declare `acyclic` as `true` or `false`, or omit it." }, + ); + } +} diff --git a/packages/typegraph/src/core/index.ts b/packages/typegraph/src/core/index.ts index ee0498c26..ab77970e8 100644 --- a/packages/typegraph/src/core/index.ts +++ b/packages/typegraph/src/core/index.ts @@ -4,9 +4,6 @@ export { defineNode, type DefineNodeOptions } from "./node"; // Edge factory export { defineEdge, type DefineEdgeOptions } from "./edge"; -// Meta-edge factory -export { metaEdge, type MetaEdgeOptions } from "./meta-edge"; - // Embedding type for vector search export { embedding, @@ -45,6 +42,7 @@ export { type RecordedInstant, recordedInstantRevision, recordedInstantWallTime, + type RestoredRecordedInstant, } from "./temporal"; // Graph definition @@ -108,6 +106,7 @@ export { type NodeRegistration, type NodeType, type NullCheckOp, + type TargetCardinality, type TemporalMode, type UniqueConstraint, type UniquenessScope, diff --git a/packages/typegraph/src/core/meta-edge.ts b/packages/typegraph/src/core/meta-edge.ts deleted file mode 100644 index 9ba87fd58..000000000 --- a/packages/typegraph/src/core/meta-edge.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - type InferenceType, - META_EDGE_BRAND, - type MetaEdge, - type MetaEdgeProperties, -} from "../ontology/types"; - -// ============================================================ -// Meta-Edge Factory Options -// ============================================================ - -/** - * Options for creating a meta-edge. - */ -export type MetaEdgeOptions = Readonly<{ - /** Whether the relationship is transitive (A→B, B→C implies A→C) */ - transitive?: boolean; - /** Whether the relationship is symmetric (A→B implies B→A) */ - symmetric?: boolean; - /** Whether the relationship is reflexive (A→A is always true) */ - reflexive?: boolean; - /** Name of the inverse meta-edge */ - inverse?: string; - /** How this meta-edge affects queries and validation */ - inference?: InferenceType; - /** Optional description */ - description?: string; -}>; - -// ============================================================ -// Meta-Edge Factory -// ============================================================ - -/** - * Creates a custom meta-edge definition. - * - * @example - * ```typescript - * // Custom meta-edge for regulatory relationships - * const regulatedBy = metaEdge("regulatedBy", { - * description: "Type X is regulated by authority type Y", - * transitive: false, - * symmetric: false, - * }); - * ``` - */ -export function metaEdge( - name: K, - options: MetaEdgeOptions = {}, -): MetaEdge { - const properties: MetaEdgeProperties = { - transitive: options.transitive ?? false, - symmetric: options.symmetric ?? false, - reflexive: options.reflexive ?? false, - inverse: options.inverse, - inference: options.inference ?? "none", - description: options.description, - }; - - return Object.freeze({ - [META_EDGE_BRAND]: true as const, - name, - properties, - }); -} diff --git a/packages/typegraph/src/core/temporal.ts b/packages/typegraph/src/core/temporal.ts index a466fc69c..65eac03ea 100644 --- a/packages/typegraph/src/core/temporal.ts +++ b/packages/typegraph/src/core/temporal.ts @@ -10,6 +10,24 @@ const ENGINE_INSTANT_VERSION = "e1"; const RECORDED_REVISION_WIDTH = 16; /** Open interval ceiling for numeric recorded-revision columns. */ export const RECORDED_MAX_REVISION = Number.MAX_SAFE_INTEGER; +/** The first revision a graph records; nothing is recorded before it. */ +export const RECORDED_FIRST_REVISION = 1; + +/** + * Whether `revision` is one a TypeGraph recorded instant can carry: a safe + * integer from {@link RECORDED_FIRST_REVISION} up to, not including, + * {@link RECORDED_MAX_REVISION}. The one range check behind minting an + * instant, parsing one, and accepting a revision from an interchange + * document — a value any of them admitted and another refused would be stored + * and then unreadable. + */ +export function isRecordedRevision(revision: number): boolean { + return ( + Number.isSafeInteger(revision) && + revision >= RECORDED_FIRST_REVISION && + revision < RECORDED_MAX_REVISION + ); +} const RECORDED_INSTANT_PATTERN = /^r1:(\d{16}):(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z)$/; /** @@ -47,6 +65,19 @@ export type RecordedInstant = string & { readonly [RECORDED_INSTANT_BRAND]: "RecordedInstant"; }; +/** + * A recorded instant ANOTHER graph minted, carried into this one by an + * archival restore of its identity transitions: the source graph's revision + * and wall time, kept verbatim. It has a {@link RecordedInstant}'s string form + * but names a position on the source's recorded axis, so it carries a + * different brand value and `store.asOfRecorded` does not accept it — a read + * anchored on it would answer about this graph at a revision this graph never + * allocated for that event. + */ +export type RestoredRecordedInstant = string & { + readonly [RECORDED_INSTANT_BRAND]: "RestoredRecordedInstant"; +}; + /** * The parsed halves of a TypeGraph-owned (`r1:`) recorded instant. Not * exported on its own — callers narrow {@link RecordedInstantParts} by @@ -108,11 +139,7 @@ export function parseRecordedInstant( throw invalidRecordedInstant(value, path); } const revision = Number(revisionText); - if ( - !Number.isSafeInteger(revision) || - revision < 1 || - revision >= RECORDED_MAX_REVISION - ) { + if (!isRecordedRevision(revision)) { throw invalidRecordedInstant(value, path); } validateCanonicalIsoDate(recordedAt, path); @@ -154,18 +181,14 @@ export function createRecordedInstant( revision: number, recordedAt: string, ): RecordedInstant { - if ( - !Number.isSafeInteger(revision) || - revision < 1 || - revision >= RECORDED_MAX_REVISION - ) { + if (!isRecordedRevision(revision)) { throw new ValidationError( "createRecordedInstant revision must be a valid recorded revision.", { issues: [ { path: "createRecordedInstant.revision", - message: `Expected a safe integer from 1 through ${String(RECORDED_MAX_REVISION - 1)}, got ${String(revision)}`, + message: `Expected a safe integer from ${String(RECORDED_FIRST_REVISION)} through ${String(RECORDED_MAX_REVISION - 1)}, got ${String(revision)}`, }, ], }, @@ -399,6 +422,27 @@ export function withRecordedCoordinate( }; } +/** + * THE diagonal recorded coordinate: the recorded-time relation at the + * anchor's logical revision, read from the valid instant the anchor itself + * was recorded at. One constructor for `store.asOfRecorded(T)` and for + * identity replay, so a replayed step and a recorded view pinned to that + * step's own anchor are the same read by construction. + * + * @param suggestion - Caller-specific remediation hint for an invalid anchor. + */ +export function recordedDiagonalCoordinate( + recordedAsOf: RecordedInstant, + suggestion?: string, +): ReadCoordinate { + const validCoordinate = resolveReadCoordinate( + "asOf", + recordedInstantWallTime(recordedAsOf), + suggestion, + ); + return withRecordedCoordinate(validCoordinate, recordedAsOf); +} + /** * Validates the `asOf` coordinate of a temporal read: `"asOf"` mode * requires a timestamp, and any supplied timestamp must be a canonical diff --git a/packages/typegraph/src/core/types.ts b/packages/typegraph/src/core/types.ts index 5eff620ce..fb8fa8a31 100644 --- a/packages/typegraph/src/core/types.ts +++ b/packages/typegraph/src/core/types.ts @@ -291,6 +291,14 @@ export type Cardinality = | "unique" // At most one edge of this kind between any (source, target) pair | "oneActive"; // At most one edge with valid_to IS NULL from any source +/** + * The target-side cardinalities. Derived from {@link Cardinality} rather than + * spelled again, so the vocabulary has one owner; `"unique"` is excluded + * because it is a property of the endpoint PAIR, which the source-side option + * already declares — a second pair declaration would be the same axis twice. + */ +export type TargetCardinality = Exclude; + /** * Endpoint existence modes for edge validation. */ @@ -396,8 +404,29 @@ export type EdgeRegistration< from: readonly FromTypes[]; to: ToDef; cardinality?: Cardinality; + /** + * The maximum number of edges of this kind that may point AT one target + * node. + * + * Independent of {@link cardinality}, which bounds the edges leaving one + * source. Target `"one"` with source `"many"` is many-to-one ownership: a + * source may point at many targets, a target may be pointed at once. + */ + targetCardinality?: TargetCardinality; endpointExistence?: EndpointExistence; matchIdentity?: EdgeMatchIdentity; + /** + * When true, the live relation formed by this edge kind is a DAG: no write + * may create a path from an edge's `to` endpoint back to its `from` + * endpoint, and a self-loop is a cycle of length one. Enforced under the + * per-graph write fence; refused on a backend that cannot hold one. + * + * Orthogonal to `cardinality`, `matchIdentity`, `endpointExistence` and + * `onDelete` — `cardinality: "many", acyclic: true` (a dependency graph) + * is the common case and still takes the fence even though its + * cardinality does not. + */ + acyclic?: boolean; }>; /** diff --git a/packages/typegraph/src/core/unique-constraint-options.ts b/packages/typegraph/src/core/unique-constraint-options.ts new file mode 100644 index 000000000..78da21b77 --- /dev/null +++ b/packages/typegraph/src/core/unique-constraint-options.ts @@ -0,0 +1,168 @@ +/** + * The values a unique constraint's `scope` and `collation` may take, what an + * omitted one means, and the one place a compile-time declaration is checked + * against them and given them. + * + * Every reader of a declaration — the claim axis (`scope === "kind"`), the + * stored-schema parser, the graph-extension compiler, the schema-commit + * comparisons — must agree on what an omitted member means. So `defineGraph` + * writes the defaults into the registration and refuses a stated value + * outside the domain: the store would otherwise read a typo as "not `kind`" + * and the loader's own enum would reject the stored schema. The typed API + * requires both members; this is the runtime half, for untyped callers. + * + * The two document shapes have different defaults, each fixed by what the + * store has always enforced for it (see {@link NODE_UNIQUE_CONSTRAINT_DEFAULTS} + * and {@link EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS}). + * + * The value lists live here because several layers validate against them: + * `defineGraph`, the stored-schema parser (`src/schema/types.ts`) and the + * runtime graph-extension validator (`src/graph-extension/validation.ts`). + */ +import { ConfigurationError } from "../errors/index"; +import { type Collation, type UniquenessScope } from "./types"; + +export const UNIQUENESS_SCOPE_VALUES = [ + "kind", + "kindWithSubClasses", +] as const satisfies readonly UniquenessScope[]; + +export const COLLATION_VALUES = [ + "binary", + "caseInsensitive", +] as const satisfies readonly Collation[]; + +/** What a unique constraint's omitted `scope` and `collation` mean. */ +export type UniqueConstraintDefaults = Readonly<{ + scope: UniquenessScope; + collation: Collation; +}>; + +/** + * What an omitted member of a node registration's unique constraint — and of + * the stored node document it serializes to — means. + * + * The claim axis reads every scope but `"kind"` as subclass-wide, so the + * store has always enforced a declaration without a `scope` across the + * kind's subclass component, and its claims sit at that component's axis. + * Reading the omission any other way would move the axis under rows that + * already hold claims. + */ +export const NODE_UNIQUE_CONSTRAINT_DEFAULTS = { + scope: "kindWithSubClasses", + collation: "binary", +} as const satisfies UniqueConstraintDefaults; + +/** + * What an omitted member of a graph-extension document's unique constraint + * means: the extension compiler has always written `scope: "kind"`. + */ +export const EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS = { + scope: "kind", + collation: "binary", +} as const satisfies UniqueConstraintDefaults; + +/** + * A unique constraint's enforcement options as a graph-extension document, a + * stored document or an untyped caller may state them. + */ +export type StatedUniqueConstraintOptions = Readonly<{ + scope?: UniquenessScope | undefined; + collation?: Collation | undefined; +}>; + +/** + * `constraint` with the `scope` and `collation` it is enforced under written + * out, reading an omitted member as `defaults` says. + */ +export function withUniqueConstraintDefaults< + C extends StatedUniqueConstraintOptions, +>( + constraint: C, + defaults: UniqueConstraintDefaults, +): C & UniqueConstraintDefaults { + return { + ...constraint, + scope: constraint.scope ?? defaults.scope, + collation: constraint.collation ?? defaults.collation, + }; +} + +/** + * THE reading of a unique constraint's `scope`: whether it spans the kind's + * subclass component. The claim axis, the fence audit and the schema-commit + * gates all ask through here, with an omitted scope read as + * {@link NODE_UNIQUE_CONSTRAINT_DEFAULTS} says, so no consumer can hold a + * registration that skipped `defineGraph` to a different meaning than the + * store enforces. + */ +export function uniquenessScopeSpansSubclasses( + scope: UniquenessScope | undefined, +): boolean { + return ( + (scope ?? NODE_UNIQUE_CONSTRAINT_DEFAULTS.scope) === "kindWithSubClasses" + ); +} + +/** + * THE reading of a unique constraint's `collation`: whether its keys fold + * case, with an omitted collation read as + * {@link NODE_UNIQUE_CONSTRAINT_DEFAULTS} says. + */ +export function uniqueCollationFoldsCase( + collation: Collation | undefined, +): boolean { + return ( + (collation ?? NODE_UNIQUE_CONSTRAINT_DEFAULTS.collation) === + "caseInsensitive" + ); +} + +function assertStatedLiteral( + kind: string, + constraintName: string, + option: "scope" | "collation", + value: unknown, + allowed: readonly string[], +): void { + if (value === undefined) return; + if (typeof value === "string" && allowed.includes(value)) return; + const list = allowed.map((entry) => `"${entry}"`).join(", "); + throw new ConfigurationError( + `Unique constraint "${constraintName}" on node kind "${kind}" declares \`${option}\` with a value that is not one of: ${list}.`, + { + code: "UNIQUE_CONSTRAINT_OPTION_INVALID", + kind, + constraintName, + option, + value, + }, + { suggestion: `Declare \`${option}\` as one of ${list}, or omit it.` }, + ); +} + +/** + * Refuses a compile-time unique constraint whose `scope` or `collation` is + * stated outside its domain. + * + * @throws ConfigurationError (`UNIQUE_CONSTRAINT_OPTION_INVALID`) + */ +export function assertUniqueConstraintOptions( + kind: string, + constraint: Readonly<{ name: string; scope?: unknown; collation?: unknown }>, +): void { + assertStatedLiteral( + kind, + constraint.name, + "scope", + constraint.scope, + UNIQUENESS_SCOPE_VALUES, + ); + assertStatedLiteral( + kind, + constraint.name, + "collation", + constraint.collation, + COLLATION_VALUES, + ); +} diff --git a/packages/typegraph/src/errors/index.ts b/packages/typegraph/src/errors/index.ts index 23527001e..5166dd1bc 100644 --- a/packages/typegraph/src/errors/index.ts +++ b/packages/typegraph/src/errors/index.ts @@ -22,12 +22,19 @@ * ``` */ +import type { EdgeCardinalityDeclaration } from "../backend/types"; import type { KindEntity } from "../core/types"; +import type { OntologyChange } from "../schema/migration"; import type { SchemaDiff } from "../schema/migration"; -// Type-only import: `materialize-indexes.ts` value-imports -// `ConfigurationError` from this file, but type-only imports are erased -// at runtime so this back-edge does not create a value cycle. +import type { UniquenessClaimGate } from "../schema/uniqueness-claim-gate"; +import type { EdgeCardinalityDirection } from "../store/claims/edge-claims"; +import type { ConstraintFenceViolation } from "../store/claims/verify"; +// Type-only imports: `materialize-indexes.ts` and `claims/verify.ts` +// value-import `ConfigurationError` / `MigrationError` from this file, but +// type-only imports are erased at runtime so these back-edges do not create +// a value cycle. import type { MaterializeIndexesResult } from "../store/materialize-indexes"; +import type { CompositionCascadedPart } from "../store/types"; // ============================================================ // Types @@ -719,12 +726,30 @@ export class EdgeMatchIdentityConflictError extends TypeGraphError { */ export type CardinalityErrorDetails = Readonly<{ edgeKind: string; + /** Which endpoint's population the write overran. */ + direction: EdgeCardinalityDirection; fromKind: string; fromId: string; + toKind: string; + toId: string; cardinality: string; existingCount: number; }>; +/** + * The remediation suggestion for a {@link CardinalityError}, naming the + * option the caller actually set rather than the source-side one by default: + * a `direction: "target"` violation is never fixed by setting `cardinality`, + * so the suggestion must name `targetCardinality` for it. + */ +function cardinalityErrorSuggestion(details: CardinalityErrorDetails): string { + const option = + details.direction === "target" ? "targetCardinality" : "cardinality"; + return details.cardinality === "one" || details.cardinality === "unique" ? + `Delete the existing edge before creating a new one, or use ${option} "many".` + : `Check if the ${option} constraint "${details.cardinality}" is correct for your use case.`; +} + /** * Thrown when cardinality constraint is violated. */ @@ -733,15 +758,14 @@ export class CardinalityError extends TypeGraphError { constructor(details: CardinalityErrorDetails, options?: { cause?: unknown }) { super( - `Cardinality violation: "${details.edgeKind}" from ${details.fromKind}/${details.fromId} allows "${details.cardinality}" but ${details.existingCount} edge(s) already exist`, + details.direction === "target" ? + `Cardinality violation: "${details.edgeKind}" targeting ${details.toKind}/${details.toId} allows "${details.cardinality}" but ${details.existingCount} edge(s) already exist` + : `Cardinality violation: "${details.edgeKind}" from ${details.fromKind}/${details.fromId} allows "${details.cardinality}" but ${details.existingCount} edge(s) already exist`, "CARDINALITY_ERROR", { details, category: "constraint", - suggestion: - details.cardinality === "one" || details.cardinality === "unique" ? - `Delete the existing edge before creating a new one, or use cardinality "many".` - : `Check if the cardinality constraint "${details.cardinality}" is correct for your use case.`, + suggestion: cardinalityErrorSuggestion(details), cause: options?.cause, }, ); @@ -749,6 +773,88 @@ export class CardinalityError extends TypeGraphError { } } +/** + * Details for EdgeAcyclicityError. + */ +export type EdgeAcyclicityErrorDetails = Readonly<{ + relation: string; + edgeKind: string; + edgeId: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + /** True when `from` and `to` are the same node: a cycle of length one. */ + selfLoop: boolean; +}>; + +/** + * Thrown when a write would give a declared `acyclic: true` edge relation a + * cycle. Carries no witness path — reconstructing one requires path + * tracking, which the set-semantics reachability check gives up in exchange + * for terminating with no depth bound (see `src/store/acyclicity.ts`). + */ +export class EdgeAcyclicityError extends TypeGraphError { + declare readonly details: EdgeAcyclicityErrorDetails; + + constructor( + details: EdgeAcyclicityErrorDetails, + options?: { cause?: unknown }, + ) { + super( + details.selfLoop ? + `Acyclicity violation: "${details.edgeKind}" edge ${details.edgeId} is a self-loop (${details.fromKind}/${details.fromId} -> ${details.toKind}/${details.toId}), a cycle of length one.` + : `Acyclicity violation: "${details.edgeKind}" edge ${details.edgeId} would close a cycle in the "${details.relation}" relation (${details.toKind}/${details.toId} already reaches ${details.fromKind}/${details.fromId}).`, + "EDGE_ACYCLICITY_ERROR", + { + details, + category: "constraint", + suggestion: + "Inspect store.verifyConstraintFences() for every offending edge in this relation, or run a .recursive() traversal from the endpoints to see the path a human should resolve.", + cause: options?.cause, + }, + ); + this.name = "EdgeAcyclicityError"; + } +} + +/** + * Details for EdgeAcyclicityIndeterminateError. + */ +export type EdgeAcyclicityIndeterminateErrorDetails = Readonly<{ + relation: string; + operation: string; + graphId: string; +}>; + +/** + * Thrown when the engine cut an acyclicity reachability search short + * (statement timeout, resource exhaustion) before it could prove or refute a + * cycle. An incomplete search is never reported as "no cycle" — see + * `isStatementCutShortError` in `src/utils/sql-errors.ts`. + */ +export class EdgeAcyclicityIndeterminateError extends TypeGraphError { + declare readonly details: EdgeAcyclicityIndeterminateErrorDetails; + + constructor( + details: EdgeAcyclicityIndeterminateErrorDetails, + options?: { cause?: unknown }, + ) { + super( + `Acyclicity check for "${details.relation}" could not complete: the engine cut the reachability search short during "${details.operation}".`, + "EDGE_ACYCLICITY_INDETERMINATE", + { + details, + category: "system", + suggestion: + "Raise the statement budget for this operation, or drop `acyclic: true` from the edge and enforce acyclicity in application code.", + cause: options?.cause, + }, + ); + this.name = "EdgeAcyclicityIndeterminateError"; + } +} + /** * Details for DisjointError. */ @@ -937,6 +1043,68 @@ export class IdentitySeparationViolationError extends TypeGraphError { } } +export type IdentityReplayErrorDetails = + | Readonly<{ code: "IDENTITY_REPLAY_REQUIRES_HISTORY"; graphId: string }> + | Readonly<{ + code: "IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED"; + graphId: string; + }> + | Readonly<{ + code: "IDENTITY_REPLAY_HISTORY_TRUNCATED"; + /** + * The caller's OWN `fromRecorded`, present only when the caller + * actually supplied one. `requestedTo` is `toRecorded`, always present + * (this refusal only fires when a bounded range — `toRecorded` given — + * lies entirely below the watermark). Never fabricated from the other: + * an open-ended request (no `fromRecorded`) must not read back a + * `requestedFrom` it never named. + */ + requestedFrom?: string; + requestedTo: string; + prunedBefore: string; + }> + | Readonly<{ + code: "IDENTITY_REPLAY_WALK_INCOMPLETE"; + ceiling: number; + }> + | Readonly<{ + code: "IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK"; + /** The caller's own `beforeRecorded`. */ + requestedBefore: string; + requestedRevision: number; + /** The highest revision a prune can name: the next commit's revision, or one past the highest retained (possibly restored) transition. */ + highestPrunableRevision: number; + }>; + +/** + * Thrown by `store.identity.replay` / `transitionsOf` (and + * `pruneIdentityTransitions`'s own history precondition) when the transition + * log cannot answer a request: history capture is off, the requested range + * lies entirely below the retention watermark, or the lineage walk's own + * internal read ceiling was reached before the seed set converged. A range + * with more boundaries than the caller's `limit` is NOT a refusal — it pages, + * through the `nextCursor` on the result. `pruneIdentityTransitions` also + * raises it for a `beforeRecorded` beyond the graph's recorded clock. + */ +export class IdentityReplayError extends TypeGraphError { + declare readonly details: IdentityReplayErrorDetails; + + constructor( + message: string, + details: IdentityReplayErrorDetails, + options?: Readonly<{ suggestion?: string }>, + ) { + super(message, details.code, { + details, + category: "constraint", + ...(options?.suggestion === undefined ? + {} + : { suggestion: options.suggestion }), + }); + this.name = "IdentityReplayError"; + } +} + /** * Details for RestrictedDeleteError. */ @@ -972,6 +1140,439 @@ export class RestrictedDeleteError extends TypeGraphError { } } +/** + * Details for CompositionCycleError. + */ +export type CompositionCycleErrorDetails = Readonly<{ + wholeKind: string; + wholeId: string; + revisitedKind: string; + revisitedId: string; +}>; + +/** + * Thrown when a composition parts closure revisits a node already in the + * walk — an INSTANCE-level cycle, not a library invariant violation. + * + * Reflexive composition (a kind declaring `partOf`/`hasPart` against itself) + * is permitted at the kind level, and the store refuses the corresponding + * INSTANCE cycle at write time: every realizing edge kind belongs to the + * oriented composition union, which is probed for acyclicity on each + * composition edge write. A ring therefore only reaches this walk through + * rows that bypassed that fence — written before the `partOf`/`hasPart` + * pair was declared, by trusted import, or by direct SQL. The cascade's + * visited set catches it deterministically rather than looping or silently + * truncating, but the affected nodes stay undeletable through the ordinary + * delete path until the cycle is broken by hand. + */ +export class CompositionCycleError extends TypeGraphError { + declare readonly details: CompositionCycleErrorDetails; + + constructor( + details: CompositionCycleErrorDetails, + options?: { cause?: unknown }, + ) { + super( + `Composition parts closure of "${details.wholeKind}/${details.wholeId}" revisited "${details.revisitedKind}/${details.revisitedId}": an instance-level cycle exists among reflexive composition edges.`, + "COMPOSITION_CYCLE_DETECTED", + { + details, + category: "constraint", + suggestion: `Delete or reassign one of the composition edges that closes this cycle (the "${details.revisitedKind}/${details.revisitedId}" ↔ ancestor link), then retry the delete.`, + cause: options?.cause, + }, + ); + this.name = "CompositionCycleError"; + } +} + +/** + * Details for NodeDeletePlanMismatchError, one shape per `situation`. + * + * - `"notLive"`: the node is absent or already deleted, so the delete has no + * plan to compare. + * - `"changed"`: the delete's own plan differs from `expect`. `addedParts` and + * `addedEdgeIds` are in the delete's plan but not in `expect`; + * `droppedParts` and `droppedEdgeIds` are in `expect` but no longer in the + * delete's plan. A part is one entry per `(kind, id, whole, viaEdgeKind)`, so + * a part whose whole or edge kind changed appears in both lists. + */ +export type NodeDeletePlanMismatchErrorDetails = Readonly<{ + kind: string; + id: string; +}> & + ( + | Readonly<{ situation: "notLive" }> + | Readonly<{ + situation: "changed"; + addedParts: readonly CompositionCascadedPart[]; + droppedParts: readonly CompositionCascadedPart[]; + addedEdgeIds: readonly string[]; + droppedEdgeIds: readonly string[]; + }> + ); + +function describeNodeDeletePlanMismatch( + details: NodeDeletePlanMismatchErrorDetails, +): string { + const target = `${details.kind}/${details.id}`; + if (details.situation === "notLive") { + return `Delete of ${target} was given an expected plan, but the node is absent or already deleted.`; + } + return `Delete of ${target} no longer matches the expected plan: ${details.addedParts.length} part(s) added, ${details.droppedParts.length} dropped; ${details.addedEdgeIds.length} edge id(s) added, ${details.droppedEdgeIds.length} dropped.`; +} + +/** + * Thrown by `nodes..delete(id, { expect })` when the plan the delete + * computes for itself is not the `expect` plan a caller took from + * `planDelete(id)` — parts or edges changed since the preview, or the node is + * no longer live. Reached before any write. Category `"constraint"`, like + * {@link RestrictedDeleteError}: the graph's current state refuses the delete + * the caller confirmed, and the remedy is a fresh preview the user confirms + * again. + */ +export class NodeDeletePlanMismatchError extends TypeGraphError { + declare readonly details: NodeDeletePlanMismatchErrorDetails; + + constructor( + details: NodeDeletePlanMismatchErrorDetails, + options?: { cause?: unknown }, + ) { + super( + describeNodeDeletePlanMismatch(details), + "NODE_DELETE_PLAN_MISMATCH", + { + details, + category: "constraint", + suggestion: `Preview again with planDelete(id), confirm the new plan, and retry delete(id, { expect: newPlan }).`, + cause: options?.cause, + }, + ); + this.name = "NodeDeletePlanMismatchError"; + } +} + +/** + * Details for CompositionError, one shape per `situation`. + * + * - `"occupied"` (`COMPOSITION_WHOLE_OCCUPIED`): the write would give the part + * a second whole. `incumbentEdgeId` is omitted only when a caller builds + * this error with no claim outcome in hand at all; every claim-issuing call + * site has one and supplies it. It names the EDGE that already holds the + * axis — the one fact the claim statement's `holder_edge_id` actually + * reports — never the incumbent whole's own kind/id, which would need a + * second read this refusal path does not make. + * - `"futureStart"` (`COMPOSITION_VALIDITY_FUTURE_START`) and `"futureEnd"` + * (`COMPOSITION_VALIDITY_FUTURE_END`): a composition edge write states a + * `validFrom` or `validTo` later than the operation clock. `bound` is the + * stated value. `partId` is absent when the part is being created with a + * generated id (`partOf` on a create), `edgeId` when the edge does not + * exist yet. + * - `"futureMove"` (`COMPOSITION_MOVE_FUTURE`): `reparent`/`bulkReparent` + * names an `at` later than the operation clock. + * - `"moveBeforeIncumbent"` (`COMPOSITION_MOVE_BEFORE_INCUMBENT`): + * `reparent`/`bulkReparent` names an `at` earlier than the `validFrom` of + * the `population: "oneActive"` attachment the move would end, so the + * ended window would close before it opened. `incumbentEdgeKind` / + * `incumbentEdgeId` name that attachment's realizing edge. + * + * Composition membership is a fact of a row, not of an instant: a row with + * no `validTo` is the part's current attachment and an ended row is history, + * which is what lets one claim row, the cascade and the acyclicity relation + * agree on it. A window scheduled to start or end later would make a row + * current at one read instant and history at another, so it is refused on + * every write path rather than half-honored. + */ +export type CompositionErrorDetails = + | Readonly<{ + situation: "occupied"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + edgeKind: string; + incumbentEdgeId?: string; + }> + | Readonly<{ + situation: "futureStart" | "futureEnd"; + partKind: string; + partId?: string; + wholeKind: string; + wholeId: string; + edgeKind: string; + edgeId?: string; + bound: string; + operationInstant: string; + }> + | Readonly<{ + situation: "futureMove"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + at: string; + operationInstant: string; + }> + | Readonly<{ + situation: "moveBeforeIncumbent"; + partKind: string; + partId: string; + wholeKind: string; + wholeId: string; + incumbentEdgeKind: string; + incumbentEdgeId: string; + incumbentValidFrom: string; + at: string; + }>; + +function compositionPartLabel( + details: Readonly<{ partKind: string; partId?: string | undefined }>, +): string { + return `${details.partKind}${details.partId === undefined ? "" : `/${details.partId}`}`; +} + +function compositionWholeLabel( + whole: Readonly<{ kind: string; id: string }>, + via: string | undefined, +): string { + return `${whole.kind}/${whole.id}${via === undefined ? "" : ` (via "${via}")`}`; +} + +/** + * What one {@link CompositionErrorDetails} situation reads as: its code, its + * message, and the way out — one switch, so a code never drifts from the + * message it is raised with. + */ +function describeCompositionRefusal( + details: CompositionErrorDetails, +): Readonly<{ code: string; message: string; suggestion: string }> { + const partLabel = compositionPartLabel(details); + const wholeLabel = `${details.wholeKind}/${details.wholeId}`; + switch (details.situation) { + case "occupied": { + return { + code: "COMPOSITION_WHOLE_OCCUPIED", + message: + `Cannot attach ${partLabel} to ${wholeLabel} via "${details.edgeKind}": ` + + `it already has a whole${ + details.incumbentEdgeId === undefined ? + "" + : ` (held by edge ${details.incumbentEdgeId})` + }, and composition allows exactly one.`, + suggestion: + "Call `store.nodes..reparent(partId, { whole, via? })` to move the part — it retires the incumbent attachment and creates the new one in one transaction. A second composition edge create can never succeed while the first one holds the part.", + }; + } + case "futureStart": + case "futureEnd": { + const field = + details.situation === "futureStart" ? "validFrom" : "validTo"; + return { + code: + details.situation === "futureStart" ? + "COMPOSITION_VALIDITY_FUTURE_START" + : "COMPOSITION_VALIDITY_FUTURE_END", + message: `Cannot write composition edge "${details.edgeKind}"${ + details.edgeId === undefined ? "" : ` ${details.edgeId}` + } between ${partLabel} and ${wholeLabel}: ${field} "${details.bound}" is later than the operation clock "${details.operationInstant}".`, + suggestion: `Use a ${field} at or before the current time. A composition attachment is current while its window is open and history once it ends; scheduling either transition for later is not supported — make the change when it happens, with \`reparent\` to move a part.`, + }; + } + case "futureMove": { + return { + code: "COMPOSITION_MOVE_FUTURE", + message: `Cannot move ${partLabel} to ${wholeLabel} at "${details.at}": the move instant is later than the operation clock "${details.operationInstant}".`, + suggestion: + "Omit `at` to move the part now, or pass an instant at or before the current time. A move cannot be scheduled for later.", + }; + } + case "moveBeforeIncumbent": { + return { + code: "COMPOSITION_MOVE_BEFORE_INCUMBENT", + message: `Cannot move ${partLabel} to ${wholeLabel} at "${details.at}": its current attachment (edge "${details.incumbentEdgeKind}" ${details.incumbentEdgeId}) starts at "${details.incumbentValidFrom}", so ending it at the move instant would close it before it opened.`, + suggestion: + "Pass an `at` at or after the current attachment's validFrom, or omit `at` to move the part at the later of the current time and that validFrom.", + }; + } + } +} + +/** + * Thrown when a composition edge (`partOf`/`hasPart`) write breaks one of the + * rules that keep a part's attachment a fact of the row: a part holds exactly + * one whole across every declared composition relation (enforced by one + * claim row per part — `typegraph_edge_claims`, the reserved composition + * axis), and an attachment's window never starts or ends later than the + * write that states it. + * + * One class, one code per situation ({@link CompositionErrorDetails}), so a + * caller that routes on `code` can tell a second whole from a scheduled + * window. + */ +export class CompositionError extends TypeGraphError { + declare readonly details: CompositionErrorDetails; + + constructor(details: CompositionErrorDetails, options?: { cause?: unknown }) { + const refusal = describeCompositionRefusal(details); + super(refusal.message, refusal.code, { + details, + category: "constraint", + suggestion: refusal.suggestion, + cause: options?.cause, + }); + this.name = "CompositionError"; + } +} + +/** + * Details for CompositionExistenceError. + * + * `partId` is absent on a bare create refusal (`situation: "create"`): the + * node has not been assigned an id yet at the point the refusal is decided, + * since deciding it is what keeps the row from ever being written. + * `edgeKind`/`edgeId` are present only on `situation: "detach"`, where an + * existing composition edge row is what the caller is trying to end, + * soft-delete, or hard-delete. + * + * `currentWhole`/`currentVia` and `requestedWhole`/`requestedVia` are + * present only on `situation: "existing"` — a `getOrCreateByConstraint` call + * whose `partOf` postcondition the already-existing node CONTRADICTS. Both + * sides are named so a caller can see the move it would have to make: the + * whole (and realizing edge) the node holds now, and the one the call asked + * for. `requestedVia` is the RESOLVED realizing edge of the pair the stated + * `partOf` names (`resolveCompositionAttachment`), not an echo of a stated + * `via` — a call that omitted `via` because the part declares one pair + * toward that whole kind still names that pair's edge here. `currentVia` is + * absent when the contradiction is a differing whole rather than a differing + * realizing edge only. + * + * `currentProps`/`requestedProps` are present only on `situation: "props"` — + * a `getOrCreateByConstraint` or `reparent` call whose attachment is already + * satisfied (same whole, same realizing edge — named by `edgeKind`/`edgeId`) + * but whose stated `partOf.props` are schema-valid and canonically DIFFERENT + * from the edge's live stored props. Neither call writes on an + * already-satisfied attachment, so a valid-but-different `props` is an + * accepted option this API cannot honor — it is refused rather than silently + * dropped, exactly like a differing whole or realizing edge. + * + * `situation` and the error's `code` say the same thing in two vocabularies — + * `"create"`/`COMPOSITION_WHOLE_REQUIRED`, + * `"detach"`/`COMPOSITION_DETACH_REFUSED`, + * `"existing"`/`COMPOSITION_WHOLE_CONFLICT`, + * `"props"`/`COMPOSITION_PROPS_CONFLICT` — from one owner + * ({@link describeCompositionExistenceRefusal}), so a caller may branch on + * either. `situation` stays because it is also the discriminant for which of + * the optional fields above are populated. + */ +export type CompositionExistenceErrorDetails = Readonly<{ + partKind: string; + partId?: string; + situation: "create" | "detach" | "existing" | "props"; + edgeKind?: string; + edgeId?: string; + currentWhole?: Readonly<{ kind: string; id: string }>; + currentVia?: string; + requestedWhole?: Readonly<{ kind: string; id: string }>; + requestedVia?: string; + currentProps?: Record; + requestedProps?: Record; +}>; + +/** + * What one {@link CompositionExistenceErrorDetails} situation reads as: its + * machine-readable code, its message, and the way out. + * + * One switch rather than three parallel ones (a code map, a message builder, + * a suggestion ladder): a situation added to the union teaches exactly one + * function, and a code can never drift from the message it is raised with. + */ +function describeCompositionExistenceRefusal( + details: CompositionExistenceErrorDetails, +): Readonly<{ code: string; message: string; suggestion: string }> { + const partLabel = compositionPartLabel(details); + switch (details.situation) { + case "create": { + return { + code: "COMPOSITION_WHOLE_REQUIRED", + message: `Cannot create ${partLabel}: this kind requires a whole (\`existence: "required"\`), and no \`partOf\` was given.`, + suggestion: `Pass \`partOf: { whole }\` naming a live, declared whole, or soft-delete/hard-delete the part instead of creating it bare.`, + }; + } + case "detach": { + return { + code: "COMPOSITION_DETACH_REFUSED", + message: `Cannot detach ${partLabel} from its whole via "${details.edgeKind}"${ + details.edgeId === undefined ? "" : ` (edge ${details.edgeId})` + }: this kind requires a whole (\`existence: "required"\`) and the part is still live.`, + suggestion: `Soft-delete or hard-delete the part itself first (which frees its composition edge), or call \`store.nodes.${details.partKind}.reparent(partId, { whole, via? })\` — reparent retires the old attachment and creates the new one in one transaction, so the part is never left detached.`, + }; + } + case "existing": { + const held = + details.currentWhole === undefined ? + "no whole" + : `whole ${compositionWholeLabel(details.currentWhole, details.currentVia)}`; + const asked = + details.requestedWhole === undefined ? + "the requested whole" + : compositionWholeLabel(details.requestedWhole, details.requestedVia); + return { + code: "COMPOSITION_WHOLE_CONFLICT", + message: `Cannot apply \`partOf\` to ${partLabel}: the node already exists with ${held}, not ${asked}.`, + suggestion: `Call \`store.nodes..reparent(id, { whole, via? })\` to MOVE the part to the requested whole; getOrCreateByConstraint's \`partOf\` asserts an attachment, it never re-homes one.`, + }; + } + case "props": { + return { + code: "COMPOSITION_PROPS_CONFLICT", + message: `Cannot apply \`partOf.props\` to ${partLabel}: it already holds this whole via "${details.edgeKind}"${ + details.edgeId === undefined ? "" : ` (edge ${details.edgeId})` + } with different properties.`, + suggestion: `Call \`store.edges.${details.edgeKind}.update(${details.edgeId === undefined ? "edgeId" : JSON.stringify(details.edgeId)}, props)\` to change the realizing edge's own properties directly — \`partOf\` on an already-satisfied attachment only asserts placement, it never rewrites the edge.`, + }; + } + } +} + +/** + * Thrown when a write would leave a required-existence composition part + * (`existence: "required"`) with no live whole (a bare create with no + * `partOf`, or a detach that would orphan a currently-live part), or when a + * `getOrCreateByConstraint`/`reparent` call stating `partOf` resolves to an + * attachment that already holds — a different whole, the same whole through + * a different realizing edge, or the same whole and edge with different + * `props` (`"found"`/`"updated"`/reparent's no-op) — an accepted option this + * API cannot honor without silently dropping it. + * + * Its own class rather than a `CompositionError` code: `CompositionError` is + * the "at most one whole" refusal; this is the "at least one whole while + * live, and a whole is never silently re-assigned" refusal — a different + * invariant with a different shape (no incumbent edge to name on the create + * leg). Shares `CompositionError`'s `"constraint"` category. + * + * One class, four codes — one per situation, since a bare create, a refused + * detach, a contradicted whole and a contradicted `props` are four different + * things to handle and a caller that routes on `code` alone must be able to + * tell them apart. + */ +export class CompositionExistenceError extends TypeGraphError { + declare readonly details: CompositionExistenceErrorDetails; + + constructor( + details: CompositionExistenceErrorDetails, + options?: { cause?: unknown }, + ) { + const refusal = describeCompositionExistenceRefusal(details); + super(refusal.message, refusal.code, { + details, + category: "constraint", + suggestion: refusal.suggestion, + cause: options?.cause, + }); + this.name = "CompositionExistenceError"; + } +} + // ============================================================ // Concurrency Errors (category: "system") // ============================================================ @@ -1122,6 +1723,39 @@ export const MIGRATION_FAILURE_REASONS = [ "kind-removal", /** A declared edge match identity changed while its edge kind held rows. */ "edge-match-identity-rekey", + /** + * An ontology tightening (`disjointWith` / `subClassOf` / `equivalentTo` / + * `sameAs` addition, or a `subClassOf` / `equivalentTo` / `sameAs` + * removal) is false against existing rows. Inspect `details.violations` + * — in exactly the shape `store.verifyConstraintFences()` returns — for + * the rows that must be resolved before retrying. + */ + "ontology-tightening-violated", + /** + * An edge cardinality tightening (`cardinality` or `targetCardinality` + * newly constrained, or moved to a stricter constrained value) is false + * against existing rows. Inspect `details.violations` — in exactly the + * shape `store.verifyConstraintFences()` returns — for the rows that must + * be resolved before retrying. + */ + "edge-cardinality-tightening-violated", + /** + * The commit would leave a uniqueness constraint unenforced over existing + * rows: it adds or reshapes a unique constraint on a kind that holds rows, + * or moves a `kindWithSubClasses` claim axis away from where that kind's + * existing claims sit. A schema commit neither validates existing rows + * against a unique constraint nor rebuilds its claims, so the kind must be + * empty. Inspect `details.constraints`. + */ + "unique-constraint-requires-empty-kind", + /** + * `store.evolve()`, `store.planEvolution()` or `store.removeKinds()` would + * drop a stored relation whose meta-edge is not built in, the last copy of + * a relation the graph can no longer declare. Inspect `details.relations`, + * move them into `defineGraph({ annotations })`, then commit with + * `migrateSchema()`. + */ + "custom-relation-removal", ] as const; export type MigrationFailureReason = (typeof MIGRATION_FAILURE_REASONS)[number]; @@ -1179,6 +1813,73 @@ export type MigrationErrorDetails = toVersion: number; reason: "edge-match-identity-rekey"; edgeKinds: readonly string[]; + }> + | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "unique-constraint-requires-empty-kind"; + /** + * The uniqueness constraints the commit would leave unenforced over + * existing rows — added, reshaped, or left reading a subclass component + * that lost a member their claims may sit at — on a kind that still + * holds rows. A + * gated constraint whose kind is empty is permitted and does NOT appear + * here. Never empty. + */ + constraints: readonly UniquenessClaimGate[]; + }> + | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "ontology-tightening-violated"; + /** + * The ontology changes that required a data check — every classified + * change in the diff whose `probes` is non-empty. A change classified + * `safe` or `breaking` (which carries no `probes`) never appears here, + * even when the same diff also contains one. + */ + changes: readonly OntologyChange[]; + /** + * The rows that make the proposed ontology false, in exactly the + * shape `store.verifyConstraintFences()` returns — so the same rows + * that block the migration can be listed, resolved, and the + * migration retried. + */ + violations: readonly ConstraintFenceViolation[]; + }> + | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + reason: "edge-cardinality-tightening-violated"; + /** The axes this commit newly constrains — those that owed the data check. */ + axes: readonly EdgeCardinalityDeclaration[]; + /** + * The rows that make the proposed cardinality false, in exactly the + * shape `store.verifyConstraintFences()` returns — so the same rows + * that block the migration can be listed, resolved, and the + * migration retried. + */ + violations: readonly ConstraintFenceViolation[]; + }> + | Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; + /** + * `store.evolve()`, `store.planEvolution()` or `store.removeKinds()` + * would drop a stored relation whose meta-edge is not built in. The + * stored schema holds its last copy, so only an explicit + * `migrateSchema()` may drop it. + */ + reason: "custom-relation-removal"; + /** + * Each such relation as written, such as `governs(Team, Project)`: the + * `removedCustomRelation` of every change that drops one. + */ + relations: readonly string[]; }>; /** @@ -1728,7 +2429,10 @@ export class UnsupportedBackendCapabilityError extends TypeGraphError { /** Stable reasons an intentionally trusted initial import can be rejected. */ export type TrustedImportErrorReason = + | "acyclicity_unsupported" | "backend_unsupported" + | "cardinality_unsupported" + | "composition_unsupported" | "database_not_empty" | "fulltext_unsupported" | "history_unsupported" diff --git a/packages/typegraph/src/errors/validation.ts b/packages/typegraph/src/errors/validation.ts index 6f925ded5..396804eed 100644 --- a/packages/typegraph/src/errors/validation.ts +++ b/packages/typegraph/src/errors/validation.ts @@ -43,7 +43,9 @@ export type ValidationContext = Readonly<{ /** * Converts Zod issues to ValidationIssue format. */ -function zodIssuesToValidationIssues(error: ZodError): ValidationIssue[] { +export function zodIssuesToValidationIssues( + error: ZodError, +): ValidationIssue[] { return error.issues.map((issue) => ({ path: issue.path.join("."), message: issue.message, diff --git a/packages/typegraph/src/graph-extension/classify.ts b/packages/typegraph/src/graph-extension/classify.ts index 4e67a159d..9d6a51849 100644 --- a/packages/typegraph/src/graph-extension/classify.ts +++ b/packages/typegraph/src/graph-extension/classify.ts @@ -10,7 +10,8 @@ * committing. */ import type { KindEntity } from "../core/types"; -import { canonicalEqual } from "../schema/canonical"; +import { EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS } from "../core/unique-constraint-options"; +import { uniqueConstraintEnforcementChange } from "../schema/unique-constraint-change"; import { hasOwnKey } from "../utils/object"; import { requireDefined } from "../utils/presence"; import { type IncompatibleChange, IncompatibleChangeError } from "./errors"; @@ -598,41 +599,24 @@ function classifyUnique( next: readonly ExtensionUniqueConstraint[], recorders: Recorders, ): void { - const existingByName = new Map( - existing.map((constraint) => [constraint.name, constraint]), - ); - const nextByName = new Map( - next.map((constraint) => [constraint.name, constraint]), - ); - for (const [name, constraint] of nextByName) { - if (!existingByName.has(name)) { - // ADD_UNIQUE — allowed-on-empty (existing rule, kept). - recorders.recordRequireEmpty({ - kind, - type: "ADD_UNIQUE_ON_POPULATED", - detail: `unique constraint "${name}" on [${constraint.fields.join(", ")}]`, - }); - } - } - // Dropped unique constraints — DROP_UNIQUE, allowed. - // Modified — DROP + ADD; the ADD is recorded above for any - // structurally-different constraint with a same name. - for (const [name, constraint] of existingByName) { - const newOne = nextByName.get(name); - if (newOne === undefined) continue; - // Different shape with same name → treat as modify; ADD half is - // allowed-on-empty. Use `canonicalEqual` so the comparison is - // key-order-stable; plain `JSON.stringify` would flag two - // structurally identical constraints with differently-ordered - // keys as reshaped. - const { name: _existingName, ...existingShape } = constraint; - const { name: _nextName, ...nextShape } = newOne; - if (!canonicalEqual(existingShape, nextShape)) { - recorders.recordRequireEmpty({ - kind, - type: "ADD_UNIQUE_ON_POPULATED", - detail: `unique constraint "${name}" reshaped`, - }); - } + // A dropped constraint is allowed. An added or reshaped one is + // allowed-on-empty: no existing row holds a claim for it. The decision is + // `uniqueConstraintEnforcementChange`'s, shared with the schema-commit + // preflight that gates the same change on compile-time kinds. + for (const constraint of next) { + const change = uniqueConstraintEnforcementChange( + existing, + constraint, + EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS, + ); + if (change === undefined) continue; + recorders.recordRequireEmpty({ + kind, + type: "ADD_UNIQUE_ON_POPULATED", + detail: + change === "added" ? + `unique constraint "${constraint.name}" on [${constraint.fields.join(", ")}]` + : `unique constraint "${constraint.name}" reshaped`, + }); } } diff --git a/packages/typegraph/src/graph-extension/compiler.ts b/packages/typegraph/src/graph-extension/compiler.ts index 3e8b6e187..fb5bbd0fe 100644 --- a/packages/typegraph/src/graph-extension/compiler.ts +++ b/packages/typegraph/src/graph-extension/compiler.ts @@ -15,14 +15,20 @@ import { embedding } from "../core/embedding"; import { defineNode } from "../core/node"; import { searchable } from "../core/searchable"; import { + type Cardinality, type KindAnnotations, type NodeType, type NullCheckOp, + type TargetCardinality, type UniqueConstraint, } from "../core/types"; -import { ALL_META_EDGE_NAMES, type MetaEdgeName } from "../ontology/constants"; -import { core as coreOntology } from "../ontology/core-meta-edges"; -import { type MetaEdge, type OntologyRelation } from "../ontology/types"; +import { + EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS, + withUniqueConstraintDefaults, +} from "../core/unique-constraint-options"; +import { metaEdgesByName } from "../ontology/core-meta-edges"; +import { type OntologyRelation } from "../ontology/types"; +import { compositionRelationFields } from "../registry/composition-relation"; import { compactUndefined, createDataKeyedBag } from "../utils/object"; import { type ExtensionArrayItemType, @@ -93,6 +99,9 @@ type CompiledEdge = Readonly<{ to: | readonly (NodeType | string)[] | Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + acyclic?: boolean; }>; // ============================================================ @@ -258,6 +267,9 @@ function compileEdge( annotations: document.annotations, from: Object.freeze(from), to, + cardinality: document.cardinality, + targetCardinality: document.targetCardinality, + acyclic: document.acyclic, }), ); } @@ -398,11 +410,15 @@ function applyOptional( function compileUniqueConstraint( document: ExtensionUniqueConstraint, ): UniqueConstraint { + const { scope, collation } = withUniqueConstraintDefaults( + document, + EXTENSION_UNIQUE_CONSTRAINT_DEFAULTS, + ); const base = { name: document.name, fields: document.fields, - scope: document.scope ?? "kind", - collation: document.collation ?? "binary", + scope, + collation, }; if (document.where === undefined) { return base; @@ -462,19 +478,16 @@ function makeWherePredicate( // Ontology compilation // ============================================================ -const META_EDGE_BY_NAME: Readonly> = - Object.fromEntries( - ALL_META_EDGE_NAMES.map((name) => [name, coreOntology[`${name}MetaEdge`]]), - ) as Readonly>; - function compileOntologyRelation( relation: ExtensionOntologyRelation, nodeTypeByName: ReadonlyMap, ): OntologyRelation { // `relation.metaEdge` is typed as `MetaEdgeName` so the lookup is // total. Validation rejects out-of-range names before they ever - // reach the compiler. - const metaEdge = META_EDGE_BY_NAME[relation.metaEdge]; + // reach the compiler. `metaEdgesByName` (`../ontology/core-meta-edges`, + // internal — not the public `core` export) is the one record that still + // carries `sameAs`/`differentFrom` by name for this by-name reader. + const metaEdge = metaEdgesByName[relation.metaEdge]; const fromNode = nodeTypeByName.get(relation.from); const toNode = nodeTypeByName.get(relation.to); @@ -486,5 +499,6 @@ function compileOntologyRelation( metaEdge, from: fromNode ?? relation.from, to: toNode ?? relation.to, + ...compositionRelationFields(relation), }; } diff --git a/packages/typegraph/src/graph-extension/errors.ts b/packages/typegraph/src/graph-extension/errors.ts index 7ef42431b..8effeb945 100644 --- a/packages/typegraph/src/graph-extension/errors.ts +++ b/packages/typegraph/src/graph-extension/errors.ts @@ -85,7 +85,20 @@ export const GRAPH_EXTENSION_ISSUE_CODES = [ "ONTOLOGY_SELF_LOOP", "ONTOLOGY_DISJOINT_CONFLICT", "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS", + // NOTE: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS" is deliberately absent here. + // `validateGraphExtension`'s document-scoped `validateOntology` call has no + // kind classifier (an extension's ontology may reference base-graph kinds + // it cannot see on its own), so it can never emit that code — it only + // surfaces as a `ConfigurationError` when the MERGED graph's `KindRegistry` + // is built (see `detectInvalidEquivalenceClasses` in `ontology/validation.ts`). + // Listing an unreachable code here would force an exhaustive switch over + // `GraphExtensionIssueCode` to add a branch that validation can never + // produce. "DUPLICATE_ONTOLOGY_RELATION", + "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN", + "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN", + "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN", "INVALID_DOCUMENT_SHAPE", "UNKNOWN_DOCUMENT_KEY", "UNSUPPORTED_STRING_FORMAT", diff --git a/packages/typegraph/src/graph-extension/extension-types.ts b/packages/typegraph/src/graph-extension/extension-types.ts index 63fbea59a..6f75ee97f 100644 --- a/packages/typegraph/src/graph-extension/extension-types.ts +++ b/packages/typegraph/src/graph-extension/extension-types.ts @@ -15,11 +15,14 @@ import { type z } from "zod"; import { + type Cardinality, type GraphAnnotations, type KindAnnotations, type NullCheckOp, + type TargetCardinality, } from "../core/types"; import { type MetaEdgeName } from "../ontology/constants"; +import { type CompositionRealization } from "../registry/composition-relation"; // ============================================================ // Property Types @@ -287,6 +290,10 @@ export type ExtensionEdgeDef = Readonly<{ from: readonly string[]; to: readonly string[] | Readonly>; properties?: Readonly>; + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; + /** See `EdgeRegistration.acyclic`. A runtime-authored edge may declare it. */ + acyclic?: boolean; }>; // ============================================================ @@ -360,7 +367,8 @@ export type ExtensionOntologyRelation = Readonly<{ metaEdge: MetaEdgeName; from: string; to: string; -}>; +}> & + CompositionRealization; // ============================================================ // Document diff --git a/packages/typegraph/src/graph-extension/merge.ts b/packages/typegraph/src/graph-extension/merge.ts index 86bac80ae..157cf87ea 100644 --- a/packages/typegraph/src/graph-extension/merge.ts +++ b/packages/typegraph/src/graph-extension/merge.ts @@ -223,7 +223,14 @@ export function mergeGraphExtension( to: to as Record, }), ); - mergedEdges[edge.kindName] = { type, from, to }; + mergedEdges[edge.kindName] = compactUndefined({ + type, + from, + to, + cardinality: edge.cardinality, + targetCardinality: edge.targetCardinality, + ...(edge.acyclic === true ? { acyclic: true } : {}), + }); } // Drop ontology relations that came from the previous graph-extension diff --git a/packages/typegraph/src/graph-extension/ontology-keys.ts b/packages/typegraph/src/graph-extension/ontology-keys.ts index 7e2aad68a..31307a527 100644 --- a/packages/typegraph/src/graph-extension/ontology-keys.ts +++ b/packages/typegraph/src/graph-extension/ontology-keys.ts @@ -12,6 +12,8 @@ * encoding here keeps the four sites from drifting apart. */ import { getTypeName, type OntologyRelation } from "../ontology/types"; +import { type CompositionRealization } from "../registry/composition-relation"; +import { encodeTupleKey } from "../utils/tuple-key"; import { type ExtensionOntologyRelation, type GraphExtension, @@ -20,11 +22,32 @@ import { export function graphExtensionOntologyKey( entry: ExtensionOntologyRelation, ): string { - return `${entry.metaEdge}|${entry.from}|${entry.to}`; + return ontologyRelationKey(entry.metaEdge, entry.from, entry.to, entry); } export function compileTimeOntologyKey(relation: OntologyRelation): string { - return `${relation.metaEdge.name}|${getTypeName(relation.from)}|${getTypeName(relation.to)}`; + return ontologyRelationKey( + relation.metaEdge.name, + getTypeName(relation.from), + getTypeName(relation.to), + relation, + ); +} + +function ontologyRelationKey( + metaEdge: string, + from: string, + to: string, + composition: CompositionRealization, +): string { + return encodeTupleKey([ + metaEdge, + from, + to, + composition.via ?? "", + composition.partSide ?? "", + composition.existence ?? "", + ]); } /** diff --git a/packages/typegraph/src/graph-extension/remove.ts b/packages/typegraph/src/graph-extension/remove.ts index 81137437e..9451d5e3b 100644 --- a/packages/typegraph/src/graph-extension/remove.ts +++ b/packages/typegraph/src/graph-extension/remove.ts @@ -106,17 +106,18 @@ export function planRemovals( runtimeEdgeNames.has(name), ); - // Compile-time-edge referent check: a graph-extension kind being - // removed cannot remain a target of any compile-time edge or ontology - // relation, because the compile-time declaration would resurrect the - // reference on the next deploy. Pre-build an inverted index of - // `kindName → referents` once so the per-kind check is O(1) instead - // of walking all edges + ontology per removed kind. + // Compile-time referent check: a graph-extension kind being removed + // cannot remain a target of any compile-time edge or ontology relation — + // as an edge endpoint, or as the `via` a compile-time + // composition relation realizes through — because the compile-time + // declaration would resurrect the reference on the next deploy. Pre-build + // an inverted index of `kindName → referents` once so the per-kind check + // is O(1) instead of walking all edges + ontology per removed kind. const referentsByKind = buildCompileTimeReferentIndex( graph, runtimeEdgeNames, ); - for (const kindName of removedNodeKinds) { + for (const kindName of [...removedNodeKinds, ...explicitlyRemovedEdgeKinds]) { const referents = referentsByKind.get(kindName); if (referents !== undefined && referents.length > 0) { throw new KindHasReferentsError(kindName, referents, graph.id); @@ -193,16 +194,24 @@ export function planRemovals( updatedNodes[nodeName] = nodeDocument; } - // Drop ontology relations referencing any removed kind. The compile- - // time-referent check above already rejects compile-time-side - // ontology referencing the removed kind, so any remaining ontology - // is safe to filter. + // Drop ontology relations referencing any removed kind — a `from`/`to` + // node endpoint, or the edge kind a composition relation names + // as its `via`: an edge cascade-dropped for losing its last endpoint + // orphans that relation's `via` exactly as surely as removing an endpoint + // node does. The compile-time-referent check above already rejects + // compile-time-side ontology referencing a removed kind (as an endpoint + // or as `via`), so any remaining ontology is safe to filter. + const removedEdgeKindsSet = new Set([ + ...cascadeEdges, + ...explicitlyRemovedEdgeKindsSet, + ]); const survivingOntology: ExtensionOntologyRelation[] = ( document.ontology ?? [] ).filter( (relation) => !removedNodeKindsSet.has(relation.from) && - !removedNodeKindsSet.has(relation.to), + !removedNodeKindsSet.has(relation.to) && + (relation.via === undefined || !removedEdgeKindsSet.has(relation.via)), ); // Drop graph-extension indexes referencing removed kinds (relational + edge @@ -212,8 +221,7 @@ export function planRemovals( // against the smaller node set). const allRemovedKinds = new Set([ ...removedNodeKindsSet, - ...cascadeEdges, - ...explicitlyRemovedEdgeKindsSet, + ...removedEdgeKindsSet, ]); const survivingIndexes: ExtensionIndex[] = (document.indexes ?? []).filter( (index) => !allRemovedKinds.has(index.kind), @@ -345,6 +353,11 @@ function buildCompileTimeReferentIndex( }; append(fromName, referent); if (toName !== fromName) append(toName, referent); + // A composition relation's `via` names the edge kind it is realized + // through — a referent exactly like an endpoint, since + // removing that edge would orphan the compile-time relation just the + // same as removing one of its node endpoints would. + if (relation.via !== undefined) append(relation.via, referent); } return index; diff --git a/packages/typegraph/src/graph-extension/validation.ts b/packages/typegraph/src/graph-extension/validation.ts index 1c7ef240d..83f160ce0 100644 --- a/packages/typegraph/src/graph-extension/validation.ts +++ b/packages/typegraph/src/graph-extension/validation.ts @@ -8,6 +8,10 @@ * against an existing compile-time graph happens later, when the * document is merged into a host `GraphDef`. */ +import { + CARDINALITY_VALUES, + TARGET_CARDINALITY_VALUES, +} from "../core/edge-integrity-options"; import { assertJsonValue, cloneAndFreezeGraphAnnotations, @@ -18,10 +22,21 @@ import { type KindEntity, type NullCheckOp, } from "../core/types"; +import { + COLLATION_VALUES, + UNIQUENESS_SCOPE_VALUES, +} from "../core/unique-constraint-options"; import { ConfigurationError } from "../errors"; import { ALL_META_EDGE_NAMES, type MetaEdgeName } from "../ontology/constants"; import { validateOntologyRelations } from "../ontology/validation"; import { encodeJsonPointerSegment } from "../query/json-pointer"; +import { + COMPOSITION_EXISTENCE_VALUES, + COMPOSITION_PART_SIDE_VALUES, + compositionRelationFields, + isCompositionExistence, + isCompositionPartSide, +} from "../registry/composition-relation"; import { isUnstorablePropertyName, RESERVED_EDGE_KEYS, @@ -190,11 +205,17 @@ const EDGE_BODY_KEYS: ReadonlySet = new Set([ "from", "to", "properties", + "cardinality", + "targetCardinality", + "acyclic", ]); const ONTOLOGY_ENTRY_KEYS: ReadonlySet = new Set([ "metaEdge", "from", "to", + "via", + "partSide", + "existence", ]); const UNIQUE_CONSTRAINT_KEYS: ReadonlySet = new Set([ "name", @@ -629,12 +650,43 @@ function validateEdgeDocument( ); if (properties === undefined) return undefined; + const cardinalityResult = validateOptionalLiteral( + raw["cardinality"], + CARDINALITY_VALUES, + `${path}/cardinality`, + "Edge `cardinality`", + "INVALID_DOCUMENT_SHAPE", + issues, + ); + if (!cardinalityResult.ok) return undefined; + + const targetCardinalityResult = validateOptionalLiteral( + raw["targetCardinality"], + TARGET_CARDINALITY_VALUES, + `${path}/targetCardinality`, + "Edge `targetCardinality`", + "INVALID_DOCUMENT_SHAPE", + issues, + ); + if (!targetCardinalityResult.ok) return undefined; + + const acyclic = validateOptionalBoolean( + raw["acyclic"], + `${path}/acyclic`, + "`acyclic`", + "INVALID_DOCUMENT_SHAPE", + issues, + ); + return compactUndefined({ description, annotations, from, to, properties, + cardinality: cardinalityResult.value, + targetCardinality: targetCardinalityResult.value, + acyclic, }); } @@ -799,7 +851,43 @@ function validateOntologySection( continue; } - result.push({ metaEdge: metaEdge as MetaEdgeName, from, to }); + const via = entry["via"]; + if (via !== undefined && (typeof via !== "string" || via.length === 0)) { + issues.push({ + path: `${path}/via`, + message: + "Ontology relation `via` must be a non-empty string when present.", + code: "INVALID_DOCUMENT_SHAPE", + }); + continue; + } + + const partSide = entry["partSide"]; + if (partSide !== undefined && !isCompositionPartSide(partSide)) { + issues.push({ + path: `${path}/partSide`, + message: `Ontology relation \`partSide\` must be one of ${COMPOSITION_PART_SIDE_VALUES.join(", ")} when present.`, + code: "INVALID_DOCUMENT_SHAPE", + }); + continue; + } + + const existence = entry["existence"]; + if (existence !== undefined && !isCompositionExistence(existence)) { + issues.push({ + path: `${path}/existence`, + message: `Ontology relation \`existence\` must be one of ${COMPOSITION_EXISTENCE_VALUES.join(", ")} when present.`, + code: "INVALID_DOCUMENT_SHAPE", + }); + continue; + } + + result.push({ + metaEdge: metaEdge as MetaEdgeName, + from, + to, + ...compositionRelationFields({ via, partSide, existence }), + }); } return result; } @@ -1945,7 +2033,7 @@ function validateUniqueConstraints( const scopeResult = validateOptionalLiteral( constraint["scope"], - ["kind", "kindWithSubClasses"] as const, + UNIQUENESS_SCOPE_VALUES, `${constraintPath}/scope`, "Unique constraint `scope`", "INVALID_DOCUMENT_SHAPE", @@ -1956,7 +2044,7 @@ function validateUniqueConstraints( const collationResult = validateOptionalLiteral( constraint["collation"], - ["binary", "caseInsensitive"] as const, + COLLATION_VALUES, `${constraintPath}/collation`, "Unique constraint `collation`", "INVALID_DOCUMENT_SHAPE", diff --git a/packages/typegraph/src/graph-merge/apply-callbacks.ts b/packages/typegraph/src/graph-merge/apply-callbacks.ts index f844567f3..5e9dc6e0a 100644 --- a/packages/typegraph/src/graph-merge/apply-callbacks.ts +++ b/packages/typegraph/src/graph-merge/apply-callbacks.ts @@ -9,6 +9,9 @@ import { import type { TransactionContext } from "../store/types"; import { requireDefined } from "../utils/presence"; import { InvalidMergeOptionsError } from "./errors"; +import { assertOnlyDeclaredKeys } from "./options"; +import type { MergePlanArtifactV2 } from "./plan-schema"; +import type { MergeReviewRevalidation } from "./review-schema"; import type { MergedCounts } from "./types"; const NODE_READ_NAMES = [ @@ -40,6 +43,40 @@ export type MergePlanReadContext = Readonly<{ /** Plan effects inside an uncommitted transaction; excludes callback writes. */ export type MergePlanApplied = Readonly<{ merged: MergedCounts }>; +/** + * A review revalidation that found the reviewed plan still applicable: the + * `compatible` arm of {@link MergeReviewRevalidation}, carrying the review's + * digest together with the fresh execution plan it was revalidated into. + */ +export type CompatibleMergeReview = Extract< + MergeReviewRevalidation, + Readonly<{ status: "compatible" }> +>; + +/** + * The provenance an apply records on every identity transition it causes. Both + * apply paths accept it — `applyMergePlan` among its options, and + * `applyMergePlanInTransaction` as its own — so a plan records the same + * decision whichever path applied it. + */ +export type MergePlanDecisionOptions = Readonly<{ + /** + * The compatible revalidation this plan was approved under + * (`revalidateCandidateWriteSetReview`). Its `reviewDigest` is recorded on + * every identity transition the apply causes, and it must name the plan + * being applied: the apply refuses a revalidation whose fresh `plan` is a + * different plan than the one it is handed. Omitted for a plan applied + * outside a review workflow. + */ + review?: CompatibleMergeReview; + /** + * The candidate write set's `sourceId`, for a plan applied on the ingestion + * path. Recorded on the identity transitions so an incoming feed's identity + * effects stay attributable to the feed. + */ + sourceId?: string; +}>; + /** * Work composed with merge application in its own protected transaction. Both * callbacks may be replayed up to three times on a transaction conflict: @@ -48,15 +85,96 @@ export type MergePlanApplied = Readonly<{ merged: MergedCounts }>; * for the full replay contract this binds to. Throw/reject to abort; * returning a value (including a Result) is refused. */ -export type MergePlanApplyOptions = Readonly<{ - /** Runs after the target fence is checked, before plan writes. Reads only. */ - beforeApply?: (reads: MergePlanReadContext) => Promise; - /** Runs after plan writes, before capture flush and commit. */ - afterApply?: ( - tx: TransactionContext, - applied: MergePlanApplied, - ) => Promise; -}>; +export type MergePlanApplyOptions = + MergePlanDecisionOptions & + Readonly<{ + /** Runs after the target fence is checked, before plan writes. Reads only. */ + beforeApply?: (reads: MergePlanReadContext) => Promise; + /** Runs after plan writes, before capture flush and commit. */ + afterApply?: ( + tx: TransactionContext, + applied: MergePlanApplied, + ) => Promise; + }>; + +/** Every key {@link MergePlanDecisionOptions} declares. */ +export const MERGE_PLAN_DECISION_OPTION_KEYS: ReadonlySet = new Set( + Object.keys({ + review: true, + sourceId: true, + } satisfies Readonly>), +); + +/** Every key {@link MergePlanApplyOptions} declares. */ +export const MERGE_PLAN_APPLY_OPTION_KEYS: ReadonlySet = new Set([ + ...MERGE_PLAN_DECISION_OPTION_KEYS, + ...Object.keys({ + beforeApply: true, + afterApply: true, + } satisfies Readonly< + Record< + Exclude< + keyof MergePlanApplyOptions, + keyof MergePlanDecisionOptions + >, + true + > + >), +]); + +/** + * Refuses an apply option the entry point does not declare. The apply reads + * each option by name, so a misspelled `review` would otherwise be dropped and + * the apply would record no review at all. + */ +export function assertOnlyDeclaredApplyOptions( + options: object, + declared: ReadonlySet, +): void { + assertOnlyDeclaredKeys(options, declared, "", "merge plan apply option"); +} + +/** + * The review digest an apply may record for `artifact`: the digest of a + * compatible revalidation whose fresh plan IS `artifact`, or `undefined` when + * no review was supplied. A revalidation that is not `compatible`, or that + * revalidated a different plan, is refused rather than recorded — the digest + * is evidence about one plan, never a free-standing label. + */ +export function reviewDigestForPlan( + artifact: MergePlanArtifactV2, + review: CompatibleMergeReview | undefined, +): string | undefined { + if (review === undefined) return undefined; + if ((review as Readonly<{ status: unknown }>).status !== "compatible") { + throw new InvalidMergeOptionsError( + "options.review must be a compatible review revalidation; a changed or incompatible review requires a new review and approval.", + { + details: { + option: "review", + reason: "not-compatible", + status: (review as Readonly<{ status: unknown }>).status, + }, + }, + ); + } + if (review.plan.digest.value !== artifact.digest.value) { + throw new InvalidMergeOptionsError( + "options.review revalidated a different merge plan than the one being applied.", + { + details: { + option: "review", + reason: "plan-mismatch", + reviewedPlanDigest: review.plan.digest, + appliedPlanDigest: artifact.digest, + }, + suggestion: + "Apply the fresh plan the revalidation returned (`review.plan`).", + }, + ); + } + return review.reviewDigest.value; +} function pickReadMethods( source: T, @@ -103,7 +221,7 @@ export function mergePlanReadContext( /** JavaScript callers must not accidentally commit by returning an Err. */ export function assertMergeCallbackResult( result: unknown, - callback: keyof MergePlanApplyOptions, + callback: "beforeApply" | "afterApply", ): void { if (result !== undefined) { throw new InvalidMergeOptionsError( diff --git a/packages/typegraph/src/graph-merge/base-version.ts b/packages/typegraph/src/graph-merge/base-version.ts index c8953c7c1..3a7439126 100644 --- a/packages/typegraph/src/graph-merge/base-version.ts +++ b/packages/typegraph/src/graph-merge/base-version.ts @@ -57,6 +57,7 @@ import type { GraphDef, IdentityTransferAssertion, LineageDelta, + SchemaVersionRow, Store, TransactionBackend, } from "./typegraph-internal"; @@ -74,7 +75,11 @@ import { storeCaptureEnabled, storeRuntime, } from "./typegraph-internal"; -import { computeSchemaHash, serializeSchema } from "./typegraph-internal"; +import { + activeSchemaHash, + computeSchemaHash, + serializeSchema, +} from "./typegraph-internal"; import type { BaseVersion } from "./types"; import { asBaseVersion } from "./types"; @@ -134,6 +139,18 @@ export async function readActiveSchemaVersion( return active?.version ?? FALLBACK_SCHEMA_VERSION; } +/** + * The schema hash merge compares for a schema row, or `undefined` when the + * store has no managed schema. Read through `activeSchemaHash`, never the + * row's `schema_hash` column, so a row written by an earlier release compares + * equal to the graph it describes. + */ +export async function optionalActiveSchemaHash( + row: SchemaVersionRow | undefined, +): Promise { + return row === undefined ? undefined : activeSchemaHash(row); +} + /** * Computes the schema-hash component of the base version token — the SCHEMA half of * `base@V`, independent of live content. Exported so `mergeIncremental()` can assert diff --git a/packages/typegraph/src/graph-merge/branch.ts b/packages/typegraph/src/graph-merge/branch.ts index abe2541c4..e3ecdd256 100644 --- a/packages/typegraph/src/graph-merge/branch.ts +++ b/packages/typegraph/src/graph-merge/branch.ts @@ -21,7 +21,12 @@ import { BranchError } from "./errors"; import type { Result } from "./result"; import { err, ok } from "./result"; import type { EngineRevision, GraphDef } from "./typegraph-internal"; -import { generateId, resolveLineage, storeBackend } from "./typegraph-internal"; +import { + activeSchemaHash, + generateId, + resolveLineage, + storeBackend, +} from "./typegraph-internal"; import type { BranchOptions, GraphBranch } from "./types"; import { asBranchId } from "./types"; import type { MakeBackend, WorkingCopyStrategy } from "./working-copy"; @@ -155,7 +160,7 @@ export async function readBranchForkState( const schemaRow = await storeBackend(store).getActiveSchema(store.graphId); const schemaAnchor = schemaRow === undefined ? undefined : ( - { version: schemaRow.version, hash: schemaRow.schema_hash } + { version: schemaRow.version, hash: await activeSchemaHash(schemaRow) } ); const lineage = captureRevision ? resolveLineage(store) : undefined; // The session is the working copy's own root backend — the same object diff --git a/packages/typegraph/src/graph-merge/candidate-review.ts b/packages/typegraph/src/graph-merge/candidate-review.ts index 9407cdc87..1cdfc6c5d 100644 --- a/packages/typegraph/src/graph-merge/candidate-review.ts +++ b/packages/typegraph/src/graph-merge/candidate-review.ts @@ -14,7 +14,7 @@ import { sameMergePlanTargetFence, } from "./merge"; import type { MergePlanArtifact, MergePlanTargetFence } from "./plan-schema"; -import { validateMergePlanArtifact } from "./plan-wire"; +import { readFormatVersion, validateMergePlanArtifact } from "./plan-wire"; import { err, isErr, ok, type Result } from "./result"; import { candidateReviewReferenceRows, @@ -30,23 +30,25 @@ import { reviewOptionEvidence, } from "./review-evidence"; import { - MERGE_REVIEW_FORMAT_VERSION_V1, - MERGE_REVIEW_FORMAT_VERSION_V2, + isSupportedMergeReviewFormatVersion, + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, type MergeReviewArtifact, mergeReviewArtifactSchema, type MergeReviewDifference, type MergeReviewPolicy, mergeReviewPolicySchema, type MergeReviewRevalidation, + SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS, } from "./review-schema"; -import { canUseSparseCandidatePlanning } from "./sparse-candidate-branch"; -import type { GraphDef } from "./typegraph-internal"; +import { candidatePlanningScope } from "./sparse-candidate-branch"; +import type { GraphDef, Store } from "./typegraph-internal"; export type PlanCandidateWriteSetReviewArgs = PlanCandidateWriteSetArgs & Readonly<{ policy: MergeReviewPolicy; - /** V2 is explicit opt-in and uses candidate-scoped identity evidence. */ + /** Explicit opt-in to the format that uses candidate-scoped identity evidence. */ reviewScope?: "candidate"; }>; @@ -74,19 +76,19 @@ export async function planCandidateWriteSetReview( const options = reviewOptionEvidence(args.options); const startingFence = await captureMergePlanTargetFence(args.target); const candidateScope = args.reviewScope === "candidate"; - if (candidateScope && !canUseSparseCandidatePlanning(args.target)) { - throw new MergeReviewError( + if (candidateScope) + assertCandidateScopeEligible( + args.target, "Candidate-scoped review requires a revision-tracked target without unsupported cross-row constraints.", - { details: { reason: "candidate-scope-ineligible" } }, ); - } const baselineBeforePlan = candidateScope ? undefined : await captureReviewBaseline(args.target); const planned = await planCandidateWriteSet({ ...args, writeSet }); if (isErr(planned)) return planned; + const { plan } = planned.data; const referencedRows = candidateReviewReferenceRows( writeSet, - planned.data, + plan, args.target.graph, ); const baseline = @@ -101,36 +103,27 @@ export async function planCandidateWriteSetReview( ...(writeSet.identity?.assertions.map( (assertion) => assertion.id, ) ?? []), - ...planned.data.writes.identityAssertions.map( - (assertion) => assertion.id, - ), - ...planned.data.writes.identityRetractions.map( - (assertion) => assertion.id, - ), + ...plan.writes.identityAssertions.map((assertion) => assertion.id), + ...plan.writes.identityRetractions.map((assertion) => assertion.id), ], ) : baselineBeforePlan; if (baseline === undefined) - throw new MergeReviewError("Missing V1 review baseline."); - assertReviewPlanFence(startingFence, planned.data); + throw new MergeReviewError("Missing whole-target review baseline."); + assertReviewPlanFence(startingFence, plan); await assertPlanningFenceUnchanged(args.target, startingFence); assertOptionsUnchanged(options, reviewOptionEvidence(args.options)); const input = { formatVersion: candidateScope ? - MERGE_REVIEW_FORMAT_VERSION_V2 - : MERGE_REVIEW_FORMAT_VERSION_V1, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED + : MERGE_REVIEW_FORMAT_VERSION, kind: "candidate-write-set" as const, writeSet, policy, options, - plan: planned.data, - baseline: withReviewAbsences( - baseline, - writeSet, - planned.data, - args.target.graph, - ), + plan, + baseline: withReviewAbsences(baseline, writeSet, plan, args.target.graph), }; const review: MergeReviewArtifact = { ...input, @@ -155,25 +148,22 @@ export async function revalidateCandidateWriteSetReview( const review = await validateReview(args.review); const policy = mergeReviewPolicySchema.parse(args.policy); const options = reviewOptionEvidence(args.options); - const reviewedEvidenceMode = - review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_V1 ? "v1" : "v2"; + const candidateScoped = + review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED; if ( - reviewedEvidenceMode === "v2" && + candidateScoped && args.target.graph.identity !== undefined && review.baseline.identityReferences === undefined ) { throw new MergeReviewError( - "Identity-enabled V2 review evidence is missing its retained identity scope.", + "Identity-enabled candidate-scoped review evidence is missing its retained identity scope.", { details: { reason: "missing-identity-scope" } }, ); } const startingFence = await captureMergePlanTargetFence(args.target); - if ( - review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_V2 && - startingFence.revision.revision === null - ) { + if (candidateScoped && startingFence.revision.revision === null) { throw new MergeReviewError( - "Candidate-scoped V2 review evidence requires revision tracking for exact revalidation.", + "Candidate-scoped review evidence requires revision tracking for exact revalidation.", { details: { reason: "revision-tracking-required" } }, ); } @@ -212,24 +202,20 @@ export async function revalidateCandidateWriteSetReview( differences: policyDifferences, }); - if ( - review.baseline.scope === "referenced" && - !canUseSparseCandidatePlanning(args.target) - ) { - throw new MergeReviewError( + if (review.baseline.scope === "referenced") + assertCandidateScopeEligible( + args.target, "Scoped review evidence requires a revision-tracked graph without unsupported cross-row constraints.", - { details: { reason: "candidate-scope-ineligible" } }, ); - } const baseline = - reviewedEvidenceMode === "v1" ? - await captureReviewBaseline(args.target) - : await captureReferencedReviewBaseline( + candidateScoped ? + await captureReferencedReviewBaseline( args.target, review.baseline.rows, review.baseline.identityReferences, review.baseline.identityAssertionIds ?? [], - ); + ) + : await captureReviewBaseline(args.target); const completeBaseline = withReviewAbsences( baseline, review.writeSet, @@ -253,26 +239,27 @@ export async function revalidateCandidateWriteSetReview( writeSet: review.writeSet, }); if (isErr(planned)) return planned; - assertReviewPlanFence(startingFence, planned.data); + const { plan } = planned.data; + assertReviewPlanFence(startingFence, plan); await assertPlanningFenceUnchanged(args.target, startingFence); assertOptionsUnchanged(options, reviewOptionEvidence(args.options)); const differences = compareFields( "plan", reviewPlanContent(review.plan), - reviewPlanContent(planned.data), + reviewPlanContent(plan), ); return ok( differences.length === 0 ? { status: "compatible", reviewDigest: review.digest, - plan: planned.data, + plan, } : { status: "changed", reviewDigest: review.digest, differences, - plan: planned.data, + plan, }, ); } catch (error) { @@ -281,10 +268,26 @@ export async function revalidateCandidateWriteSetReview( } async function validateReview(input: unknown): Promise { + const formatVersion = readFormatVersion(input); + if (!isSupportedMergeReviewFormatVersion(formatVersion)) { + throw new MergeReviewError( + "The stored merge review uses a format this library version cannot validate.", + { + details: { + reason: "unsupported-version", + received: formatVersion, + supported: SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS, + }, + suggestion: + "Plan and review the candidate write set again with this library version.", + }, + ); + } const review = mergeReviewArtifactSchema.parse(input); if ( - (review.formatVersion === 1 && review.baseline.scope !== undefined) || - (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_V2 && + (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION && + review.baseline.scope !== undefined) || + (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED && review.baseline.scope !== "referenced") ) { throw new MergeReviewError( @@ -345,11 +348,11 @@ async function validateReview(input: unknown): Promise { ); } if ( - (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_V2 && + (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED && (review.baseline.scope !== "referenced" || (review.baseline.identityReferences === undefined) !== (review.baseline.identityAssertionIds === undefined))) || - (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION_V1 && + (review.formatVersion === MERGE_REVIEW_FORMAT_VERSION && (review.baseline.scope !== undefined || review.baseline.identityReferences !== undefined || review.baseline.identityAssertionIds !== undefined)) @@ -361,6 +364,17 @@ async function validateReview(input: unknown): Promise { return { ...review, plan: plan.artifact }; } +function assertCandidateScopeEligible( + target: Store, + message: string, +): void { + const planningScope = candidatePlanningScope(target); + if (planningScope.scope === "whole-target") + throw new MergeReviewError(message, { + details: { reason: "candidate-scope-ineligible", planningScope }, + }); +} + function assertReviewPlanFence( fence: MergePlanTargetFence, plan: MergePlanArtifact, diff --git a/packages/typegraph/src/graph-merge/candidate-write-set.ts b/packages/typegraph/src/graph-merge/candidate-write-set.ts index 233d42d2e..5d4ace10d 100644 --- a/packages/typegraph/src/graph-merge/candidate-write-set.ts +++ b/packages/typegraph/src/graph-merge/candidate-write-set.ts @@ -1,15 +1,21 @@ import { z } from "zod"; -import type { EdgeRow } from "../backend/types"; import { + FORMAT_VERSION, type GraphData, importGraph, InterchangeIdentitySchema, } from "../interchange"; import type { EvolutionPlan } from "../schema/evolution-plan"; import { isCanonicalIsoDate } from "../utils/date"; +import { readOwnProperty } from "../utils/object"; import { computeSchemaComponent } from "./base-version"; -import { CandidateWriteSetError, MergeError } from "./errors"; +import { + CandidatePlanningScopeError, + CandidateWriteSetError, + InvalidMergeOptionsError, + MergeError, +} from "./errors"; import { evolutionPlanningTarget } from "./evolution-target"; import { ingestionBranch, @@ -21,17 +27,20 @@ import { planMergeIncremental, planMergeIncrementalForEvolution, planMergeIncrementalWithCandidateKeys, + publicPlanCapabilityRefusal, sameMergePlanTargetFence, } from "./merge"; import type { MergePlanArtifact } from "./plan-schema"; import type { Result } from "./result"; -import { err, isErr } from "./result"; +import { err, isErr, ok } from "./result"; import { boundedCandidateKeys, - canUseSparseCandidatePlanning, - matchIdentityOwnersAreCloneVisible, - readCandidateMatchIdentityOwners, + type CandidatePlanningScope, + candidatePlanningScope, + type CandidatePlanningScopeResolution, + resolveCandidatePlanningScope, sparseCandidateWorkingCopyStrategy, + wholeTargetPlanningScope, } from "./sparse-candidate-branch"; import type { GraphDef, Store } from "./typegraph-internal"; import { storeBackend } from "./typegraph-internal"; @@ -107,12 +116,32 @@ export type PlanCandidateWriteSetArgs = Readonly<{ makeBackend: MakeBackend; writeSet: unknown; options?: Omit, "target">; + /** + * Require planning from the candidate's own rows. Without it, a target or + * candidate outside that scope is planned against the whole target; with + * it, that case is refused with `CandidatePlanningScopeError` before any + * staging store is created. + */ + planningScope?: "candidate"; +}>; + +/** + * A candidate plan and the scope it was staged in. The scope is a + * planning-cost fact, not plan content: both scopes produce the same correct + * plan, so it stays outside the digested artifact. + */ +export type CandidateWriteSetPlan = Readonly<{ + plan: MergePlanArtifact; + planningScope: CandidatePlanningScope; }>; -/** Object-form arguments for candidate planning against a planned evolution. */ +/** + * Object-form arguments for candidate planning against a planned evolution. + * It always stages the whole resulting graph, so it takes no `planningScope`. + */ export type PlanCandidateWriteSetForEvolutionArgs = Omit< PlanCandidateWriteSetArgs, - "target" + "target" | "planningScope" > & Readonly<{ target: Store; @@ -169,9 +198,22 @@ function sameCandidateTarget( ); } +function requestedPlanningScope( + args: Readonly>, +): Result<"candidate" | undefined, InvalidMergeOptionsError> { + const requested = readOwnProperty(args, "planningScope"); + if (requested === undefined || requested === "candidate") + return ok(requested); + return err( + new InvalidMergeOptionsError("Unsupported candidate planning scope.", { + details: { planningScope: requested }, + }), + ); +} + function interchangeDocument(writeSet: CandidateWriteSet): GraphData { return { - formatVersion: "2.0", + formatVersion: FORMAT_VERSION, // Import does not use transport time. A constant keeps this adapter a pure // function of the candidate JSON and target snapshot. exportedAt: "1970-01-01T00:00:00.000Z", @@ -187,13 +229,24 @@ function interchangeDocument(writeSet: CandidateWriteSet): GraphData { * * The adapter creates no durable branch and never mutates `target`. It stages in * a disposable ingestion working copy, delegates to `planMergeIncremental`, and - * closes that working copy on success, refusal, or throw. The returned artifact + * closes that working copy on success, refusal, or throw. The returned `plan` * is the ordinary versioned/digested {@link MergePlanArtifact}; no parallel - * conflict format or scoring implementation exists. + * conflict format or scoring implementation exists. `planningScope` reports + * whether the copy was seeded from the candidate's own rows or the whole + * target, and why. */ export async function planCandidateWriteSet( args: PlanCandidateWriteSetArgs, -): Promise> { +): Promise> { + const requested = requestedPlanningScope(args); + if (isErr(requested)) return requested; + const capabilityRefusal = publicPlanCapabilityRefusal(args.target); + if (capabilityRefusal !== undefined) return err(capabilityRefusal); + if (requested.data === "candidate") { + const targetScope = candidatePlanningScope(args.target); + if (targetScope.scope === "whole-target") + return err(new CandidatePlanningScopeError(targetScope)); + } const parsed = CandidateWriteSetSchema.safeParse(args.writeSet); if (!parsed.success) { return err( @@ -228,41 +281,29 @@ export async function planCandidateWriteSet( } let created: Awaited>>; - let bounded = canUseSparseCandidatePlanning(args.target); - let boundedFence: - Awaited> | undefined; - let matchIdentityOwners: readonly EdgeRow[] = []; - if (bounded) { - try { - // The owner query controls whether sparse import sees an existing edge. - // Bind that read and the sparse seed to one target revision. - boundedFence = await captureMergePlanTargetFence(args.target); - const owners = await readCandidateMatchIdentityOwners( - args.target, - writeSet, - ); - if (owners === undefined || !matchIdentityOwnersAreCloneVisible(owners)) { - bounded = false; - } else { - matchIdentityOwners = owners; - } - } catch (error) { - return err( - new CandidateWriteSetError( - "Unable to read exact durable edge identity owners for candidate planning.", - { cause: error }, - ), - ); - } + let resolution: CandidatePlanningScopeResolution; + try { + resolution = await resolveCandidatePlanningScope(args.target, writeSet); + } catch (error) { + return err( + new CandidateWriteSetError( + "Unable to read exact durable edge identity owners for candidate planning.", + { cause: error }, + ), + ); } + const { planningScope } = resolution; + if (requested.data === "candidate" && planningScope.scope === "whole-target") + return err(new CandidatePlanningScopeError(planningScope)); + const bounded = "fence" in resolution ? resolution : undefined; const sparse = - bounded ? + bounded === undefined ? undefined : ( sparseCandidateWorkingCopyStrategy( writeSet, args.makeBackend, - matchIdentityOwners, + bounded.ownerRead.owners, ) - : undefined; + ); try { created = await ingestionBranchWithStrategy( args.target, @@ -308,6 +349,14 @@ export async function planCandidateWriteSet( ), ); } + if (bounded?.ownerRead.status === "malformed") { + // The sparse seed omits these edges' owners; a plan may rely on it only + // because import refuses every such edge. + throw new CandidateWriteSetError( + "Import staged a candidate edge whose durable match-identity owner could not be read.", + { details: { edges: bounded.ownerRead.malformed } }, + ); + } const mergeArgs = { forkPoint: args.target, target: args.target, @@ -316,7 +365,7 @@ export async function planCandidateWriteSet( }; const identityScope = sparse?.identityScope(); if ( - bounded && + bounded !== undefined && args.target.graph.identity !== undefined && identityScope === undefined ) { @@ -327,22 +376,23 @@ export async function planCandidateWriteSet( ); } const planned = - bounded ? - await planMergeIncrementalWithCandidateKeys( + bounded === undefined ? + await planMergeIncremental(mergeArgs) + : await planMergeIncrementalWithCandidateKeys( mergeArgs, boundedCandidateKeys(writeSet), identityScope, - ) - : await planMergeIncremental(mergeArgs); - if (boundedFence !== undefined && planned.success) { - await assertPlanningFenceUnchanged(args.target, boundedFence); - if (!sameMergePlanTargetFence(boundedFence, planned.data.target)) { + ); + if (isErr(planned)) return planned; + if (bounded !== undefined) { + await assertPlanningFenceUnchanged(args.target, bounded.fence); + if (!sameMergePlanTargetFence(bounded.fence, planned.data.target)) { throw new CandidateWriteSetError( "The bounded candidate baseline does not match the merge plan target fence.", ); } } - return planned; + return ok({ plan: planned.data, planningScope }); } catch (error) { return err( error instanceof CandidateWriteSetError || error instanceof MergeError ? @@ -372,10 +422,18 @@ export async function planCandidateWriteSet( * resulting schema fence, so * `withEvolvedTransaction()` and `applyMergePlanInTransaction()` can commit * schema and accepted candidate writes in one caller transaction and revision. + * The staging copy is always the whole resulting graph, so the result reports + * `planned-evolution`, and an untyped `planningScope: "candidate"` is refused + * with `CandidatePlanningScopeError`. */ export async function planCandidateWriteSetForEvolution( args: PlanCandidateWriteSetForEvolutionArgs, -): Promise> { +): Promise> { + const requested = requestedPlanningScope(args); + if (isErr(requested)) return requested; + const planningScope = wholeTargetPlanningScope("planned-evolution"); + if (requested.data === "candidate") + return err(new CandidatePlanningScopeError(planningScope)); const parsed = CandidateWriteSetSchema.safeParse(args.writeSet); if (!parsed.success) { return err( @@ -459,13 +517,15 @@ export async function planCandidateWriteSetForEvolution( ), ); } - return await planMergeIncrementalForEvolution( + const planned = await planMergeIncrementalForEvolution( args.target, args.evolutionPlan, [candidate], args.options, planningFence, ); + if (isErr(planned)) return planned; + return ok({ plan: planned.data, planningScope }); } catch (error) { return err( error instanceof CandidateWriteSetError || error instanceof MergeError ? diff --git a/packages/typegraph/src/graph-merge/closures.ts b/packages/typegraph/src/graph-merge/closures.ts deleted file mode 100644 index 3aee6d232..000000000 --- a/packages/typegraph/src/graph-merge/closures.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Ontology subclass-closure glue over TypeGraph's PUBLIC closure utilities. - * - * TypeGraph 0.29.0 exports `computeTransitiveClosure` / `invertClosure` / - * `isReachable` from the package root, and `store.introspect().ontology` exposes - * the declared meta-edges as `{ metaEdge, from, to, origin }` records. This - * module is therefore THIN GLUE: it projects the introspected ontology onto the - * `[child, parent]` relation pairs the public closure builder expects, folds in - * type-level equivalence classes (`equivalentTo` and its deprecated `sameAs` - * alias), and exposes the {@link isReachable} - * accessor reconcileTypes (T10) consumes. There is deliberately NO local Warshall - * reimplementation — the transitive work is done entirely by the public - * `computeTransitiveClosure`. - * - * Direction convention (verified against `store.introspect()` at runtime): - * `subClassOf(Child, Parent)` introspects as `{ metaEdge: "subClassOf", - * from: "Child", to: "Parent" }`. So `from` is the more-specific (child) type - * and `to` is the more-general (parent) type, matching the `[child, parent]` - * relation pairs `computeTransitiveClosure` consumes, where `isReachable(closure, - * child, ancestor)` answers "is `child` a (transitive) subclass of `ancestor`?". - */ -import { compareStrings } from "./node-key"; -import type { OntologyIntrospection } from "./typegraph-internal"; -import { - computeTransitiveClosure, - isReachable as isReachablePublic, - META_EDGE_EQUIVALENT_TO, - META_EDGE_SAME_AS, -} from "./typegraph-internal"; -import { UnionFind } from "./union-find"; - -/** - * The meta-edge name identifying a subclass relation in `introspect().ontology`. - */ -export const SUB_CLASS_OF_META_EDGE = "subClassOf"; - -/** - * The meta-edge names identifying a type-level equivalence relation in - * `introspect().ontology`. These edges are folded into symmetric equivalence - * classes so that equivalent types share each other's ancestors and descendants. - * - * `sameAs` is the deprecated alias of `equivalentTo` and MUST be treated - * identically here: `introspect()` reports the declared meta-edge name verbatim, - * and both the kind registry (`kind-registry.ts`) and ontology validation collapse - * the two into one case. Recognizing only `equivalentTo` would silently give a - * `sameAs` ontology a different type-reconciliation outcome from an otherwise - * identical `equivalentTo` one. - */ -const EQUIVALENCE_META_EDGES: ReadonlySet = new Set([ - META_EDGE_EQUIVALENT_TO, - META_EDGE_SAME_AS, -]); - -/** - * An immutable subclass closure plus the equivalence-class canonicalization used - * to fold `equivalentTo` relations. - * - * - `closure` maps each (canonicalized) child type to the set of all its - * (canonicalized) transitive ancestors, as produced by the public - * `computeTransitiveClosure`. - * - `canonicalOf` maps every type seen in the ontology to its equivalence-class - * representative (the lexicographically-smallest member). Types not involved - * in any `equivalentTo` relation map to themselves. - */ -export type SubClassClosure = Readonly<{ - closure: ReadonlyMap>; - canonicalOf: ReadonlyMap; -}>; - -/** - * Builds the subclass closure from an introspected ontology. - * - * Steps: - * 1. Fold every equivalence relation ({@link EQUIVALENCE_META_EDGES}) into a - * union-find so equivalent types collapse to a single deterministic - * representative. - * 2. Project every `subClassOf` relation onto a `[childRep, parentRep]` pair - * (each endpoint canonicalized to its equivalence representative). Self - * loops introduced by equivalence are dropped — a type is never its own - * strict subclass. - * 3. Hand the relation pairs to the PUBLIC `computeTransitiveClosure`. No local - * transitive-closure logic exists here. - * - * @param ontology The `store.introspect().ontology` array. - * @returns A `SubClassClosure` queryable via `isReachable` (which canonicalizes - * its inputs through the equivalence map). - */ -export function buildSubClassClosure( - ontology: readonly OntologyIntrospection[], -): SubClassClosure { - // Union by lexicographically-smaller representative, so each equivalence class's - // canonical is its lex-min member, independent of relation insertion order. - const unionFind = new UnionFind(compareStrings); - - for (const relation of ontology) { - unionFind.add(relation.from); - unionFind.add(relation.to); - if (EQUIVALENCE_META_EDGES.has(relation.metaEdge)) { - unionFind.union(relation.from, relation.to); - } - } - - const canonicalOf = new Map(); - for (const value of unionFind.members()) { - canonicalOf.set(value, unionFind.find(value)); - } - - const relationKeys = new Set(); - const relations: (readonly [string, string])[] = []; - for (const relation of ontology) { - if (relation.metaEdge !== SUB_CLASS_OF_META_EDGE) { - continue; - } - const child = canonicalOf.get(relation.from) ?? relation.from; - const parent = canonicalOf.get(relation.to) ?? relation.to; - if (child === parent) { - continue; - } - const key = `${child}\0${parent}`; - if (relationKeys.has(key)) { - continue; - } - relationKeys.add(key); - relations.push([child, parent]); - } - - return { - closure: computeTransitiveClosure(relations), - canonicalOf, - }; -} - -/** - * Resolves a type name to its equivalence-class representative, falling back to - * the name itself when the type was never seen in the ontology. - */ -function canonicalType(closure: SubClassClosure, type: string): string { - return closure.canonicalOf.get(type) ?? type; -} - -/** - * Reports whether `from` is a (transitive) subclass of `to`. - * - * Both arguments are canonicalized through the equivalence map first, then the - * PUBLIC `isReachable` is consulted. Equivalent-but-distinct types (e.g. - * `equivalentTo(Physician, Doctor)`) are treated as mutually reachable. A type - * is NOT considered a subclass of itself (the relation is strict / irreflexive), - * matching the public closure's non-reflexive semantics. - * - * @param closure The closure produced by `buildSubClassClosure`. - * @param from The candidate descendant (more-specific) type. - * @param to The candidate ancestor (more-general) type. - */ -export function isReachable( - closure: SubClassClosure, - from: string, - to: string, -): boolean { - const fromRep = canonicalType(closure, from); - const toRep = canonicalType(closure, to); - if (fromRep === toRep) { - // Distinct names that fold to the same equivalence class are mutually - // reachable; identical names are NOT (strict subclass relation). - return from !== to; - } - return isReachablePublic(closure.closure, fromRep, toRep); -} diff --git a/packages/typegraph/src/graph-merge/durable-merge.ts b/packages/typegraph/src/graph-merge/durable-merge.ts index bc06cf634..e1574a5a5 100644 --- a/packages/typegraph/src/graph-merge/durable-merge.ts +++ b/packages/typegraph/src/graph-merge/durable-merge.ts @@ -17,7 +17,7 @@ import { } from "./durable-branch"; import { describeCause, MergeError } from "./errors"; import { applyMergePlan, validateMergePlanForTarget } from "./merge"; -import type { MergePlanArtifact, MergePlanArtifactV1 } from "./plan-schema"; +import type { MergePlanArtifact, MergePlanArtifactV2 } from "./plan-schema"; import type { Result } from "./result"; import { err } from "./result"; import type { GraphDef, Store } from "./typegraph-internal"; @@ -40,7 +40,9 @@ export type ApplyDurableMergePlanArgs< /** * Applies an approved durable-branch plan after validating that its live * handle matches the sealed descriptor. Native branch allocation remains - * available, while writes use the portable applier's target transaction fence. + * available, while writes use the portable applier's target transaction fence, + * so the plan's identity and composition work always runs through TypeGraph's + * own write path. */ export async function applyDurableMergePlan< G extends GraphDef, @@ -66,7 +68,7 @@ export async function applyDurableMergePlan< ); } - let artifact: MergePlanArtifactV1; + let artifact: MergePlanArtifactV2; const descriptorOrigin = durableOriginOfDescriptor(descriptor); try { artifact = await validateMergePlanForTarget(target, plan); diff --git a/packages/typegraph/src/graph-merge/edge-repoint.ts b/packages/typegraph/src/graph-merge/edge-repoint.ts index 1b29e0db7..ef8c4b47d 100644 --- a/packages/typegraph/src/graph-merge/edge-repoint.ts +++ b/packages/typegraph/src/graph-merge/edge-repoint.ts @@ -172,6 +172,13 @@ export type StagedEdge = Readonly<{ * rank-based policy — see {@link authoredProperty}. */ baseProps?: Readonly>; + /** + * The end of validity the MERGE BASE holds for this row — present only on an + * INHERITED edge whose base window was already ended. The repoint never reads + * it; the orchestrator's cross-branch composition check does, to tell a branch + * that re-attaches a part through an ended row from one that merely edits it. + */ + baseValidTo?: string; branchId: BranchId; /** * The valid-time window the commit must write. A NEW edge carries the branch's @@ -211,14 +218,27 @@ export type MergedEdge = Readonly<{ }> & ValidityEndMutation; +/** + * A staged edge with the post-repoint endpoint identities a deleted-endpoint + * drop is decided on — in {@link EdgeRepointResult}, one the repoint dropped. The report sees + * only its {@link DroppedEdge}; the orchestrator reads this to tell an ordinary + * drop from a branch's composition attachment the drop would silently lose. + */ +export type EndpointDeletedEdge = Readonly<{ + staged: StagedEdge; + fromKey: MergeKey; + toKey: MergeKey; +}>; + /** * The outcome of the repoint + dedupe cascade: the surviving merged edges, every - * edge dropped for a deleted endpoint, and every edge-level property conflict the - * dedupe surfaced. + * edge dropped for a deleted endpoint (as a report item and as the staged edge it + * came from), and every edge-level property conflict the dedupe surfaced. */ export type EdgeRepointResult = Readonly<{ edges: readonly MergedEdge[]; dropped: readonly DroppedEdge[]; + endpointDeletedEdges: readonly EndpointDeletedEdge[]; conflicts: readonly PropertyConflict[]; }>; @@ -260,6 +280,22 @@ function repoint( return canonicalOf.get(key) ?? key; } +/** + * A staged edge with the post-repoint identities of its endpoints: the one + * owner of that mapping, shared by {@link repointEdges} and by callers judging a + * staged edge the repoint never sees. + */ +export function withRepointedEndpoints( + staged: StagedEdge, + canonicalOf: ReadonlyMap, +): EndpointDeletedEdge { + return { + staged, + fromKey: repoint(mergeKey(staged.fromKind, staged.fromId), canonicalOf), + toKey: repoint(mergeKey(staged.toKind, staged.toId), canonicalOf), + }; +} + /** * The dedupe key for a repointed edge: a JSON-encoded `[from', type, to', * propsKey]` tuple. The `propsKey` is the T2 canonical serializer over the PARSED @@ -814,6 +850,7 @@ export function repointEdges( preferredBranchId?: BranchId, ): EdgeRepointResult { const dropped: DroppedEdge[] = []; + const endpointDeletedEdges: EndpointDeletedEdge[] = []; // Per `(from', type, to')` group → its members. Insertion order is irrelevant: // Phase 2 partitions the group, re-derives each survivor, and sorts the output // explicitly, so the bucket carries membership only. @@ -825,8 +862,8 @@ export function repointEdges( for (const staged of stagedEdges) { const sourceFromKey = mergeKey(staged.fromKind, staged.fromId); const sourceToKey = mergeKey(staged.toKind, staged.toId); - const fromKey = repoint(sourceFromKey, canonicalOf); - const toKey = repoint(sourceToKey, canonicalOf); + const endpoints = withRepointedEndpoints(staged, canonicalOf); + const { fromKey, toKey } = endpoints; if (deletedNodeIds.has(fromKey) || deletedNodeIds.has(toKey)) { dropped.push({ @@ -834,6 +871,7 @@ export function repointEdges( id: staged.id, reason: ENDPOINT_DELETED_DROP_REASON, }); + endpointDeletedEdges.push(endpoints); continue; } @@ -911,6 +949,12 @@ export function repointEdges( return { edges: sortedEdges, dropped: dropped.sort((left, right) => compareStrings(left.id, right.id)), + endpointDeletedEdges: endpointDeletedEdges.sort((left, right) => { + const byId = compareStrings(left.staged.id, right.staged.id); + return byId === 0 ? + compareStrings(left.staged.branchId, right.staged.branchId) + : byId; + }), conflicts: conflicts.sort((left, right) => compareStrings( `${left.entityId}|${left.property}`, diff --git a/packages/typegraph/src/graph-merge/errors.ts b/packages/typegraph/src/graph-merge/errors.ts index 0daa68db8..a1be5f1f9 100644 --- a/packages/typegraph/src/graph-merge/errors.ts +++ b/packages/typegraph/src/graph-merge/errors.ts @@ -1,3 +1,6 @@ +import { requireDefined } from "../utils/presence"; +import type { MergePlanCompositionOrphan } from "./plan-schema"; +import type { WholeTargetPlanningScope } from "./sparse-candidate-branch"; import type { TypeGraphErrorOptions } from "./typegraph-internal"; import { TransactionConflictError, TypeGraphError } from "./typegraph-internal"; @@ -22,6 +25,8 @@ export const MERGE_ERROR_CODES = { conflict: "GRAPH_MERGE_CONFLICT", constraintConflict: "GRAPH_MERGE_CONSTRAINT_CONFLICT", identityConflict: "GRAPH_MERGE_IDENTITY_CONFLICT", + identitySeparationConflict: "GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT", + acyclicityConflict: "GRAPH_MERGE_ACYCLICITY_CONFLICT", baseVersionMismatch: "GRAPH_MERGE_BASE_VERSION_MISMATCH", planCapability: "GRAPH_MERGE_PLAN_CAPABILITY", planInvalid: "GRAPH_MERGE_PLAN_INVALID", @@ -35,6 +40,7 @@ export const MERGE_ERROR_CODES = { candidateSource: "GRAPH_MERGE_CANDIDATE_SOURCE", evidence: "GRAPH_MERGE_EVIDENCE", candidateWriteSet: "GRAPH_MERGE_CANDIDATE_WRITE_SET", + candidatePlanningScope: "GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE", review: "GRAPH_MERGE_REVIEW", operation: "GRAPH_MERGE_OPERATION", operationRequest: "GRAPH_MERGE_OPERATION_REQUEST", @@ -42,6 +48,7 @@ export const MERGE_ERROR_CODES = { operationUnsupported: "GRAPH_MERGE_OPERATION_UNSUPPORTED", operationEvidence: "GRAPH_MERGE_OPERATION_EVIDENCE", operationUndelivered: "GRAPH_MERGE_OPERATION_UNDELIVERED", + compositionOrphan: "GRAPH_MERGE_COMPOSITION_ORPHAN", } as const; /** @@ -230,16 +237,185 @@ export function translateMergeCommitError(error: unknown): unknown { return new MergeConstraintConflictError(error); } -/** Raised when identity branches contain opposing or retract/reassert truth. */ +/** + * Raised at PLAN time when two branches conflict over a composition + * attachment — one branch attaches a part to a whole while another deletes + * one end of it (`details.branchId` names the attaching branch) — and at + * APPLY when the plan would separate a composition part from its + * whole behind the target's back: a live part of a whole the plan deletes is + * not itself among the plan's node deletions (the target gained that part + * AFTER the branch point, or independently of it), or a part the plan + * deletes is held by the target under a live whole the plan leaves standing + * (the target moved it there after the branch point). Applying the plan as + * trusted would leave the part pointing at a whole that no longer exists, or + * take it from a whole that does. + * + * `planMerge`/`planMergeIncremental` surface the SAME finding, computed by the + * same `planCompositionCascade` / `unattachedRequiredPartOrphansAmong` owners + * against the target's state at plan time, as + * `MergePlanReview.compositionOrphans` — a best-effort, racy dry-run report. + * This error is the authoritative one: it is thrown from inside the apply + * transaction, under the per-graph write lock, so it cannot miss an orphan + * the plan-time report's unlocked read raced past. + * + * `details` is typed as {@link MergePlanCompositionOrphan} — the SAME shape + * `assertNoCompositionOrphans`/`assertNoUnattachedRequiredParts` (`merge.ts`) + * pass straight into this constructor — rather than a second, + * structurally-identical type. Two names for one finding is exactly the kind + * of drift a future field (say, the realizing edge id) could silently + * introduce between the dry-run report and the apply-time refusal. + * + * `details.cause` picks the message: `"deleted"` names the whole this plan + * would delete; `"moved"` names the live whole the target holds a part under + * that this plan would delete; `"unattached"` has no whole to name at all — + * the part's composition edge was dropped or collapsed by canonicalization + * while the part itself survives. With `details.branchId`, the attachment at + * stake is that branch's, not the target's. + */ +export class MergeCompositionOrphanError extends MergeError { + protected static override readonly errorCategory = "constraint"; + override readonly code = MERGE_ERROR_CODES.compositionOrphan; + declare readonly details: MergePlanCompositionOrphan; + + constructor(details: MergePlanCompositionOrphan) { + super(compositionOrphanMessage(details), { details }); + this.name = "MergeCompositionOrphanError"; + } +} + +/** The `MergeCompositionOrphanError` messages, one per `cause` and attaching side. */ +function compositionOrphanMessage(details: MergePlanCompositionOrphan): string { + if (details.branchId !== undefined) { + return branchAttachmentLossMessage(details, details.branchId); + } + if (details.cause === "moved") { + // `cause: "moved"` always carries `whole` — the only producer, + // `compositionMovedPartsAmong` (`merge.ts`), sets both together. + const holder = requireDefined( + details.whole, + 'MergePlanCompositionOrphan.whole for cause "moved"', + ); + return ( + `Applying this merge plan would delete part "${details.part.kind}:${details.part.id}", ` + + `which the target holds under live whole "${holder.kind}:${holder.id}" (via "${details.viaEdgeKind}") ` + + "that the plan neither deletes nor detaches it from. Stop the branch from deleting the part, " + + "or detach the part from that whole on the target, before merging." + ); + } + if (details.cause === "unattached") { + return ( + `Applying this merge plan would leave part "${details.part.kind}:${details.part.id}" ` + + `(via "${details.viaEdgeKind}") with no live whole — this composition pair requires one ` + + '(`existence: "required"`). Recompute the merge plan against the target\'s current ' + + "state, or attach the part to a whole in the branch before merging." + ); + } + // `cause: "deleted"` always carries `whole` — the only producer, + // `compositionOrphansAmong` (`merge.ts`), sets both together. + const whole = requireDefined( + details.whole, + 'MergePlanCompositionOrphan.whole for cause "deleted"', + ); + return ( + `Applying this merge plan would delete whole "${whole.kind}:${whole.id}" ` + + `while its part "${details.part.kind}:${details.part.id}" (via "${details.viaEdgeKind}") ` + + "is not itself among the plan's node deletions. Recompute the merge plan against the " + + "target's current state, or delete the orphaned part in the branch before merging." + ); +} + +/** + * The message for a cross-branch finding: `whole` is always set, because the + * only producer, `lostBranchAttachment` (`merge.ts`), reads it off the + * attaching edge. + */ +function branchAttachmentLossMessage( + details: MergePlanCompositionOrphan, + branchId: string, +): string { + const whole = requireDefined( + details.whole, + "MergePlanCompositionOrphan.whole for a branch attachment", + ); + const attachment = + `branch "${branchId}" attaches part "${details.part.kind}:${details.part.id}" ` + + `to whole "${whole.kind}:${whole.id}" (via "${details.viaEdgeKind}")`; + const deletedEnd = details.cause === "moved" ? "part" : "whole"; + return ( + `Merging these branches would lose a composition attachment: ${attachment}, ` + + `but the merge also deletes that ${deletedEnd}. ` + + "Drop either the deletion or the attachment from its branch before merging." + ); +} + +/** + * The identity dimension of a merge refusal: opposing or retract/reassert + * truth, or a class-lifted `different` that vetoes a match. + * + * ONE class, two codes. Each of those is a distinct machine-readable + * `MERGE_ERROR_CODES` entry so a caller can branch precisely, but they share + * this class — and therefore `category: "conflict"` — so every consumer that + * already handles an identity merge conflict keeps handling all of them, and + * `GRAPH_MERGE_IDENTITY_CONFLICT` keeps every case it raises today. + */ export class IdentityMergeConflictError extends MergeError { - override readonly code = MERGE_ERROR_CODES.identityConflict; + override readonly code: IdentityMergeConflictCode; - constructor(message: string, options: MergeErrorOptions = {}) { + constructor( + message: string, + options: MergeErrorOptions & + Readonly<{ code?: IdentityMergeConflictCode }> = {}, + ) { super(message, options); + this.code = options.code ?? MERGE_ERROR_CODES.identityConflict; this.name = "IdentityMergeConflictError"; } } +/** The two codes {@link IdentityMergeConflictError} can carry. */ +export type IdentityMergeConflictCode = + | typeof MERGE_ERROR_CODES.identityConflict + | typeof MERGE_ERROR_CODES.identitySeparationConflict; + +/** One offending edge named in an {@link AcyclicityMergeConflictError}. */ +export type AcyclicityMergeConflictEdge = Readonly<{ + edgeId: string; + edgeKind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + +/** `details` carried by {@link AcyclicityMergeConflictError}. */ +export type AcyclicityMergeConflictDetails = Readonly<{ + /** The declared acyclic relation the resolved plan would close a cycle in. */ + relation: string; + /** Every edge of `relation` the plan-time probe found on the cycle. */ + edges: readonly AcyclicityMergeConflictEdge[]; + [key: string]: unknown; +}>; + +/** + * Raised at plan time when the resolved merge plan's projected + * edge writes — after canonicalization and repointing, layered onto the + * target's current live edges — would close a cycle in a declared-acyclic + * relation. Reviewable, like {@link IdentityMergeConflictError}: the plan is + * not applied, and the target is untouched. Apply-time re-verification (a + * cycle that only appears due to a write that lands between planning and + * commit) remains a refusal, not a conflict — see `EdgeAcyclicityError` via + * {@link MergeConstraintConflictError}. + */ +export class AcyclicityMergeConflictError extends MergeError { + override readonly code = MERGE_ERROR_CODES.acyclicityConflict; + declare readonly details: AcyclicityMergeConflictDetails; + + constructor(message: string, options: MergeErrorOptions = {}) { + super(message, options); + this.name = "AcyclicityMergeConflictError"; + } +} + /** * Raised by the `merge()` precondition check when a branch's `base@V` token * does not match the merge target's current base version (the branch forked @@ -377,6 +553,31 @@ export class CandidateWriteSetError extends MergeError { } } +/** + * Raised instead of planning against the whole target when a caller requests + * `planningScope: "candidate"`. `details.planningScope.reason` names why the + * target or candidate cannot be planned from the candidate's own rows. + */ +export class CandidatePlanningScopeError extends MergeError { + protected static override readonly errorCategory = "user"; + override readonly code = MERGE_ERROR_CODES.candidatePlanningScope; + declare readonly details: Readonly<{ + planningScope: WholeTargetPlanningScope; + }>; + + constructor(planningScope: WholeTargetPlanningScope) { + super( + `Candidate-scoped planning was requested, but this candidate must be planned against the whole target (${planningScope.reason}).`, + { + details: { planningScope }, + suggestion: + "Omit planningScope to accept whole-target planning, or remove the cause named by details.planningScope.reason.", + }, + ); + this.name = "CandidatePlanningScopeError"; + } +} + /** Raised when match evidence is invalid or contains a non-finite score. */ export class MatchEvidenceError extends MergeError { override readonly code = MERGE_ERROR_CODES.evidence; diff --git a/packages/typegraph/src/graph-merge/evidence.ts b/packages/typegraph/src/graph-merge/evidence.ts index dcce2fa27..43be2c67c 100644 --- a/packages/typegraph/src/graph-merge/evidence.ts +++ b/packages/typegraph/src/graph-merge/evidence.ts @@ -1,3 +1,4 @@ +import { type Assert, type Equal } from "../utils/type-assert"; import { canonicalValueKey } from "./canonical-props"; import type { MergeKey } from "./node-key"; import { @@ -71,6 +72,22 @@ export type MatchEvidence = threshold: number; }>; +/** Why a retained candidate edge's endpoints still ended up in separate clusters. */ +export type ClusterExclusionReason = + "diameter" | "baseAmbiguity" | "separation"; + +/** Every {@link ClusterExclusionReason}, for the plan schema's runtime check. */ +export const CLUSTER_EXCLUSION_REASONS = [ + "diameter", + "baseAmbiguity", + "separation", +] as const; + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- compile-time assertion +type _clusterExclusionReasonsAreComplete = Assert< + Equal<(typeof CLUSTER_EXCLUSION_REASONS)[number], ClusterExclusionReason> +>; + /** One opt-in scorer observation, retained independently of cluster membership. */ export type CandidateDiagnostic = | Readonly<{ @@ -81,7 +98,7 @@ export type CandidateDiagnostic = | "retained" | Readonly<{ kind: "excluded"; - reason: "diameter" | "baseAmbiguity"; + reason: ClusterExclusionReason; }>; }> | Readonly<{ @@ -89,7 +106,7 @@ export type CandidateDiagnostic = scoreDecision: "accepted"; clusterDisposition: Readonly<{ kind: "excluded"; - reason: "diameter" | "baseAmbiguity"; + reason: ClusterExclusionReason; }>; }>; diff --git a/packages/typegraph/src/graph-merge/identity-decision.ts b/packages/typegraph/src/graph-merge/identity-decision.ts new file mode 100644 index 000000000..68e5a4b6f --- /dev/null +++ b/packages/typegraph/src/graph-merge/identity-decision.ts @@ -0,0 +1,105 @@ +/** + * The governing DECISION a merge attaches to the identity transitions it + * causes. + * + * When a merge folds or splits an identity class, the transition log records + * WHY. Without this, a fold triggered by a merged node create is filed as an + * anonymous `fold` and a reviewer replaying the class can see that it happened + * but not which plan, review or branch produced it. + * + * Everything here is evidence ALREADY IN HAND at the apply site — the plan + * artifact's own digest, the anchors it was built from, and the review + * revalidation that names that same plan. Nothing is read, recomputed, or + * invented; a field the caller cannot evidence stays absent rather than being + * guessed. + */ +import { + type MergePlanDecisionOptions, + reviewDigestForPlan, +} from "./apply-callbacks"; +import type { MergePlanAnchors, MergePlanArtifactV2 } from "./plan-schema"; +import type { IdentityDecisionProvenance } from "./typegraph-internal"; + +/** + * Root-first branch ancestry: the base (or fork-point) graph, then each branch + * in the anchors' own wire order. + * + * Ancestry here is the PLAN's `MergePlanAnchors` — what this merge combined — + * not a per-graph lineage the store would have to reconstruct. That is the only + * ancestry the apply site can evidence without reading anything. + */ +export function branchAncestryOf( + rootGraphId: string, + branchIds: readonly string[], +): readonly string[] { + return [rootGraphId, ...branchIds]; +} + +/** The same ancestry, read off a plan artifact's anchors. */ +function branchAncestryFromAnchors( + anchors: MergePlanAnchors, +): readonly string[] { + const root = + anchors.kind === "snapshot" ? + anchors.base.graphId + : anchors.forkPoint.graphId; + return branchAncestryOf( + root, + anchors.branches.map((branch) => branch.branchId), + ); +} + +/** The evidence an apply site has for the decision it is about to record. */ +export type MergeIdentityDecisionInput = Readonly<{ + branchAncestry: readonly string[]; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + +/** + * Builds the decision, omitting every field the caller could not evidence. + * `branchId` is present only in the single-branch case, where "which branch" + * has an unambiguous answer. + */ +export function mergeIdentityDecision( + input: MergeIdentityDecisionInput, +): IdentityDecisionProvenance { + // [root, branch] — exactly one branch merged, so naming it is unambiguous. + const soleBranch = + input.branchAncestry.length === 2 ? input.branchAncestry[1] : undefined; + return { + ...(soleBranch === undefined ? {} : { branchId: soleBranch }), + ...(input.branchAncestry.length === 0 ? + {} + : { branchAncestry: input.branchAncestry }), + ...(input.mergePlanDigest === undefined ? + {} + : { mergePlanDigest: input.mergePlanDigest }), + ...(input.reviewDigest === undefined ? + {} + : { reviewDigest: input.reviewDigest }), + ...(input.sourceId === undefined ? {} : { sourceId: input.sourceId }), + }; +} + +/** + * The decision an approved plan artifact evidences — its digest and anchors — + * plus the review and source context only the caller holds. The one builder + * every artifact apply site calls, so the transition log records the same + * decision whichever entry point applied the plan. + * + * @throws {InvalidMergeOptionsError} when `context.review` is not a compatible + * revalidation of this very plan ({@link reviewDigestForPlan}). + */ +export function mergeIdentityDecisionFromArtifact( + artifact: MergePlanArtifactV2, + context: MergePlanDecisionOptions = {}, +): IdentityDecisionProvenance { + return mergeIdentityDecision({ + branchAncestry: branchAncestryFromAnchors(artifact.anchors), + mergePlanDigest: artifact.digest.value, + reviewDigest: reviewDigestForPlan(artifact, context.review), + sourceId: context.sourceId, + }); +} diff --git a/packages/typegraph/src/graph-merge/identity-separation.ts b/packages/typegraph/src/graph-merge/identity-separation.ts new file mode 100644 index 000000000..5ba8f10b2 --- /dev/null +++ b/packages/typegraph/src/graph-merge/identity-separation.ts @@ -0,0 +1,348 @@ +/** + * The identity SEPARATION VETO. + * + * A `store.identity.assertDifferent(a, b)` is an integrity fact, not a recall + * heuristic, so the veto is ON for every identity-enabled merge. Without it a + * candidate match between two entities the ledger holds apart survives planning + * and dies in the commit on the separation relation's ordered-pair CHECK — a + * constraint violation at the wrong phase, naming table columns rather than the + * two entities and the assertions that separated them. + * + * The facts are captured ONCE, before planning, from the merge target's own + * identity context together with the identity assertions the merged branches + * carry — a staged `same` joins classes, a staged `different` separates them — + * and consumed by three application points that all read this single fact + * set: + * + * 1. the candidate-edge veto, which DROPS a scored edge (recall the ledger + * forbids) and reports it as a typed `separation` conflict; + * 2. the surviving-edge refusal, which fails the plan on a DEFINITIONAL edge + * that outlived the base and diameter guards; and + * 3. the post-cluster assertion, which catches a TRANSITIVE fusion — `a`–`b` + * and `b`–`c` each clearing the threshold while `a` and `c` are held + * apart, so no single candidate edge is separated yet the cluster fuses + * all three. + * + * Points 2 and 3 run AFTER the guards on purpose. A forced base match whose + * component the base guard severs never fuses anything, so refusing it up + * front would fail a merge that is harmless; only an edge (or a cluster) that + * survives the guards can actually collapse two separated classes. + * + * Capturing once is what lets the later points stay synchronous inside plan + * construction, and what makes it structurally impossible for them to + * disagree. + * + * ONE OWNER. The class-lifted `different` decision is `bulkIsSeparated` + * (`src/identity/separation.ts`) — this module resolves each participant to its + * current identity class and asks that one predicate, never its own SQL and + * never a re-derivation of what "class-lifted difference" means. + */ +import { identityReferenceKey } from "../identity/reference"; +import { isCurrentIdentityAssertion } from "../identity/validity-window"; +import { groupBy } from "../utils/array"; +import { requireDefined } from "../utils/presence"; +import { entityRef } from "./evidence"; +import type { MergeKey } from "./node-key"; +import { mergeKeyOf } from "./node-key"; +import type { + GraphDef, + IdentityTransferAssertion, + PlainNodeRef, + Store, +} from "./typegraph-internal"; +import { + bulkIsSeparated, + compareCodePoints, + currentClassKey, + loadCurrentStructuralClasses, + loadSpanningDifferentAssertion, + separationClassPairKey, + separationFactsEmpty, + storeRuntime, +} from "./typegraph-internal"; +import { UnionFind } from "./union-find"; + +/** + * The separation facts one merge plan is judged against: each candidate + * participant's identity CLASS key once the merge lands, and the ordered + * class-key pairs held as `different`. + * + * Evidence bound to the resource that earned it: the classes come from the + * merge target's own identity context, the separations from that context's + * ledger and from the assertions this plan itself is about to land, all read + * before planning and consumed only by that plan. + */ +export type IdentitySeparationFacts = Readonly<{ + /** + * Class key per candidate participant: its class in the target's current + * closure, joined with every class a staged `same` assertion connects it to. + */ + classKeyOf: ReadonlyMap; + /** + * NUL-joined, code-point-ordered class-key pairs held apart, by the target's + * relation or by a current `different` assertion a merged branch carries. + */ + separatedClassPairs: ReadonlySet; + /** + * For each separated pair, the `different` assertion held across it — the + * target ledger's where it has one, a merged branch's otherwise — the + * WITNESS a refusal names, so a caller reads WHICH assertion forbade + * the match rather than only that something did. Resolved eagerly, but only + * for the pairs the probe actually reported separated: a separation among + * fusion candidates is an exceptional state, never the steady one. + */ + separatingAssertionIdOf: ReadonlyMap; +}>; + +/** The empty facts an identity-disabled graph is judged against: nothing is separated. */ +export const NO_IDENTITY_SEPARATION_FACTS: IdentitySeparationFacts = { + classKeyOf: new Map(), + separatedClassPairs: new Set(), + separatingAssertionIdOf: new Map(), +}; + +/** + * Every distinct unordered pair of participants that could FUSE, grouped so the + * probe stays bounded by what the plan can actually merge. + * + * A merge only ever fuses within a connected component of the candidate-edge + * graph, and the base/diameter guards only SPLIT components further — so the + * within-group pairs are a superset of every pair the plan can put in one + * cluster, and a strict subset of the quadratic all-participants enumeration. + */ +function fusionPairs( + groups: readonly (readonly MergeKey[])[], + classKeyOf: ReadonlyMap, +): readonly Readonly<{ first: string; second: string }>[] { + const seen = new Set(); + const pairs: Readonly<{ first: string; second: string }>[] = []; + for (const group of groups) { + for (const [index, left] of group.entries()) { + for (const right of group.slice(index + 1)) { + const first = classKeyOf.get(left); + const second = classKeyOf.get(right); + if (first === undefined || second === undefined) continue; + if (first === second) continue; + const key = separationClassPairKey(first, second); + if (seen.has(key)) continue; + seen.add(key); + pairs.push({ first, second }); + } + } + } + return pairs; +} + +/** + * Resolves every candidate participant to the identity class it holds once the + * merge lands, and decides which of the pairs the plan could fuse are held + * apart: by the target's ledger ({@link bulkIsSeparated}), or by a `different` + * assertion among `stagedAssertions`. + * + * `groups` are the participant sets a fusion could occur WITHIN — the connected + * components of the candidate-edge graph. Passing the flat participant list as + * one group is correct but quadratic; passing the components keeps the probe + * proportional to what the plan can actually merge. + * + * `stagedAssertions` are the assertions the merged branches add. Only the ones + * that hold NOW ({@link isCurrentIdentityAssertion}) bind the merge, exactly as + * only the target ledger's current rows do — an assertion a branch retracted, + * or ended by deleting an endpoint, is history the merge carries, not a fact + * that forbids a match. A current assertion binds as firmly as the target's + * own ledger: + * + * - a `same` joins its endpoints' target classes, so a participant a branch + * asserts into a class answers for every separation that class carries — a + * node the target does not hold being its own class; + * - a `different` separates the resulting classes of its two endpoints. + * + * @throws {ConfigurationError} `IDENTITY_STORAGE_MISSING` when the separation + * relation this graph's veto reads has never been provisioned or filled — + * `bulkIsSeparated`'s refusal, surfaced here rather than answered "not + * separated". + */ +export async function captureIdentitySeparationFacts( + target: Store, + groups: readonly (readonly MergeKey[])[], + stagedAssertions: readonly IdentityTransferAssertion[], +): Promise { + const participants = [...new Set(groups.flat())]; + if (participants.length === 0) return NO_IDENTITY_SEPARATION_FACTS; + const currentStaged = stagedAssertions.filter((assertion) => + isCurrentIdentityAssertion(assertion), + ); + const stagedDifferent = currentStaged.filter( + (assertion) => assertion.relation === "different", + ); + const stagedSame = currentStaged.filter( + (assertion) => assertion.relation === "same", + ); + const ctx = storeRuntime(target).identityContext(); + // A graph holding no `different` assertion can separate nothing, so the whole + // capture — the class resolution AND the within-component pair enumeration, + // which is quadratic in component size — is skipped for one existence probe. + // That is the STEADY state of every graph that uses only `assertSame`, and + // the state where the veto's cost would otherwise be pure waste. + // + // The probe is the ledger's answer NOW (`separationFactsEmpty`), paid on + // every plan. A fact an earlier call on this Store handle settled cannot + // stand in for it: a `different` asserted since — by this handle or any + // other — is exactly what the veto exists to honor. + // + // Consequence, deliberately: a legacy store whose separation relation was + // never provisioned is not refused when it holds no `different` assertion + // either. Refusing there would be a false alarm — there is nothing for the + // veto to read, and the identity module already treats "no live `different` + // assertion" as proof that an empty relation is correct. A store that does + // hold one still reaches the refusal below. + const ledgerSeparatesNothing = await separationFactsEmpty( + ctx.backend, + ctx.schema, + ctx.graphId, + ctx.registry, + ); + if (ledgerSeparatesNothing && stagedDifferent.length === 0) { + return NO_IDENTITY_SEPARATION_FACTS; + } + const stagedEndpoints = [...stagedDifferent, ...stagedSame].flatMap( + (assertion) => [mergeKeyOf(assertion.a), mergeKeyOf(assertion.b)], + ); + const resolved = [...new Set([...participants, ...stagedEndpoints])]; + const resolvedEntries = resolved.map((key) => ({ + key, + ref: entityRef(key), + })); + const targetClasses = await loadCurrentStructuralClasses( + ctx.backend, + ctx.schema, + ctx.graphId, + resolvedEntries.map(({ ref }) => ref), + ); + const targetClassKeyOf = new Map(); + const membersByTargetClassKey = new Map(); + for (const { key, ref } of resolvedEntries) { + // `loadCurrentStructuralClasses` keys on the identity module's OWN + // reference key — reached here rather than re-spelled — and coalesces an + // unclosed node onto itself, so a node belonging to no class is still + // answered, as its own singleton class. + const members = targetClasses.get(identityReferenceKey(ref)) ?? [ref]; + const classKey = currentClassKey(members); + targetClassKeyOf.set(key, classKey); + membersByTargetClassKey.set(classKey, members); + } + const mergedClasses = new UnionFind(compareCodePoints); + for (const classKey of membersByTargetClassKey.keys()) { + mergedClasses.add(classKey); + } + for (const assertion of stagedSame) { + mergedClasses.union( + requireDefined(targetClassKeyOf.get(mergeKeyOf(assertion.a))), + requireDefined(targetClassKeyOf.get(mergeKeyOf(assertion.b))), + ); + } + const classKeyOf = new Map(); + for (const [key, targetClassKey] of targetClassKeyOf) { + classKeyOf.set(key, mergedClasses.find(targetClassKey)); + } + const targetClassKeysByClassKey = groupBy( + membersByTargetClassKey.keys(), + (targetClassKey) => mergedClasses.find(targetClassKey), + ); + + const separatedClassPairs = new Set(); + const separatingAssertionIdOf = new Map(); + const pairs = ledgerSeparatesNothing ? [] : fusionPairs(groups, classKeyOf); + // The ledger holds TARGET classes apart. A resulting class a staged `same` + // joined is separated from another when any target class inside the one is + // separated from any target class inside the other. + const targetPairs = pairs.flatMap((pair) => + (targetClassKeysByClassKey.get(pair.first) ?? []).flatMap((first) => + (targetClassKeysByClassKey.get(pair.second) ?? []).map((second) => ({ + pairKey: separationClassPairKey(pair.first, pair.second), + first, + second, + })), + ), + ); + if (targetPairs.length > 0) { + const verdicts = await bulkIsSeparated( + ctx.backend, + ctx.schema, + ctx.graphId, + targetPairs.map(({ first, second }) => ({ first, second })), + ctx.registry, + ); + for (const [index, targetPair] of targetPairs.entries()) { + if (verdicts[index] !== true) continue; + if (separatedClassPairs.has(targetPair.pairKey)) continue; + separatedClassPairs.add(targetPair.pairKey); + const witness = await loadSpanningDifferentAssertion( + ctx.backend, + ctx.schema, + ctx.graphId, + membersByTargetClassKey.get(targetPair.first) ?? [], + membersByTargetClassKey.get(targetPair.second) ?? [], + ); + if (witness !== undefined) { + separatingAssertionIdOf.set(targetPair.pairKey, witness.id); + } + } + } + for (const assertion of stagedDifferent) { + const first = requireDefined(classKeyOf.get(mergeKeyOf(assertion.a))); + const second = requireDefined(classKeyOf.get(mergeKeyOf(assertion.b))); + // An assertion inside one class is a contradiction, not a separation; the + // plan's identity reconciliation owns that refusal. + if (first === second) continue; + const pairKey = separationClassPairKey(first, second); + separatedClassPairs.add(pairKey); + if (!separatingAssertionIdOf.has(pairKey)) { + separatingAssertionIdOf.set(pairKey, assertion.id); + } + } + return { classKeyOf, separatedClassPairs, separatingAssertionIdOf }; +} + +/** The class-pair key for two participants, or undefined when either is unresolved or both share a class. */ +function separatedPairKey( + facts: IdentitySeparationFacts, + a: MergeKey, + b: MergeKey, +): string | undefined { + const first = facts.classKeyOf.get(a); + const second = facts.classKeyOf.get(b); + if (first === undefined || second === undefined || first === second) { + return undefined; + } + return separationClassPairKey(first, second); +} + +/** + * The `different` assertion ids a refusal should name for this pair — empty + * when the relation reported a separation the ledger's witness could not be + * resolved for (a mismatch the identity module's own rebuild guard owns). + */ +export function separatingAssertionIds( + facts: IdentitySeparationFacts, + a: MergeKey, + b: MergeKey, +): readonly string[] { + const key = separatedPairKey(facts, a, b); + const witness = + key === undefined ? undefined : facts.separatingAssertionIdOf.get(key); + return witness === undefined ? [] : [witness]; +} + +/** + * Whether the ledger holds these two candidate participants' identity classes + * apart. A pure lookup into facts already captured — never a read — so a plan + * builder can ask it synchronously. + */ +export function isSeparatedPair( + facts: IdentitySeparationFacts, + a: MergeKey, + b: MergeKey, +): boolean { + const key = separatedPairKey(facts, a, b); + return key !== undefined && facts.separatedClassPairs.has(key); +} diff --git a/packages/typegraph/src/graph-merge/identity-three-way.ts b/packages/typegraph/src/graph-merge/identity-three-way.ts new file mode 100644 index 000000000..26301bbc4 --- /dev/null +++ b/packages/typegraph/src/graph-merge/identity-three-way.ts @@ -0,0 +1,578 @@ +/** + * The identity survivor rule, the semantic and window-aware dedupe keys, and + * the THREE-WAY classifier built on them: ONE classifier for duplicate + * assertions, opposing relations and retract/reassert races. Duplicates reduce + * to one survivor and report the rest as dropped; opposing relations and a + * retract/reassert race have no rule that settles them, so each fails the plan + * with {@link IdentityMergeConflictError}. + * + * The plan-time dedupe and the post-remap re-dedupe that runs after endpoint + * canonicalization call the SAME comparator — a second copy of the survivor + * rule is exactly the kind of decision this repository's contract discipline + * forbids re-spelling. + */ +import { identityAssertionSemanticKey } from "../identity/assertion-key"; +import { + identityValidityWindowsOverlap, + isCurrentIdentityAssertion, +} from "../identity/validity-window"; +import { groupBy } from "../utils/array"; +import { requireDefined } from "../utils/presence"; +import { encodeTupleKey } from "../utils/tuple-key"; +import { IdentityMergeConflictError } from "./errors"; +import type { + StagedIdentityAssertion, + StagedRetraction, + StagingSet, +} from "./staging"; +import { + compareCodePoints, + type IdentityTransferAssertion, +} from "./typegraph-internal"; +import type { DroppedItem } from "./types"; + +/** + * The semantic key two identity assertions are compared under: the relation + * plus the code-point-normalized endpoint pair, WITHOUT the validity window. + * Two assertions sharing this key describe the same claim about the same + * pair, whatever window each one carries. + */ +function identitySemanticKey(assertion: IdentityTransferAssertion): string { + return identityAssertionSemanticKey( + assertion.relation, + assertion.a, + assertion.b, + ); +} + +/** + * The WINDOW-AWARE dedupe key: the semantic key, further split by validity + * window for a BOUNDED assertion (one with a `validTo`). An unbounded + * (current) assertion dedupes purely on the semantic key — there is only ever + * one "current" truth for a pair — while two branches asserting the SAME + * bounded window are treated as the same claim and two DIFFERENT bounded + * windows are kept distinct. + */ +export function identityDedupeKey( + assertion: IdentityTransferAssertion, +): string { + const semantic = identitySemanticKey(assertion); + if (assertion.validTo === undefined) return semantic; + return encodeTupleKey([semantic, assertion.validFrom, assertion.validTo]); +} + +/** + * Orders two colliding identity assertions so the caller can pick a + * deterministic survivor: earliest `validFrom` wins, and a tie breaks on the + * assertion id's code-point order. Callers needing the COMMITTED-id override + * (an id the target already holds with the exact staged truth always wins + * regardless of this order) apply it before falling back to this comparator. + */ +function compareIdentitySurvivors( + left: IdentityTransferAssertion, + right: IdentityTransferAssertion, +): number { + const byValidity = compareCodePoints(left.validFrom, right.validFrom); + return byValidity === 0 ? compareCodePoints(left.id, right.id) : byValidity; +} + +/** + * THE survivor decision for two identity assertions describing one semantic + * pair, and the only place the committed-id override and the comparator are + * spelled. Both paths that must choose between two colliding assertions — the + * staged classifier and the post-remap re-dedupe — reduce through this one + * function, so neither can spell a survivor rule of its own and drift. + * + * An id the target ALREADY holds with the exact staged truth always wins: the + * applier is idempotent per semantic pair, so a freshly minted branch id could + * never displace the target's committed row, and choosing it would report an + * id as applied that is never written while listing the target's own row as + * dropped. + */ +function pickIdentitySurvivor< + T extends Readonly<{ assertion: IdentityTransferAssertion }>, +>( + candidate: T, + incumbent: T, + committedIds: ReadonlySet, +): Readonly<{ winner: T; loser: T }> { + const candidateCommitted = committedIds.has(candidate.assertion.id); + const incumbentCommitted = committedIds.has(incumbent.assertion.id); + if (candidateCommitted !== incumbentCommitted) { + const [winner, loser] = + candidateCommitted ? + ([candidate, incumbent] as const) + : ([incumbent, candidate] as const); + return { winner, loser }; + } + const [winner, loser] = + compareIdentitySurvivors(candidate.assertion, incumbent.assertion) < 0 ? + ([candidate, incumbent] as const) + : ([incumbent, candidate] as const); + return { winner, loser }; +} + +/** + * Whether the loser is the very row that won: the same id carrying the same + * complete truth. Two branches staging the IDENTICAL row collapse silently — + * reporting one as dropped while it is the row applied would make the report + * self-contradictory. + */ +function isIdenticalIdentityRow( + left: IdentityTransferAssertion, + right: IdentityTransferAssertion, +): boolean { + return ( + left.id === right.id && + left.validFrom === right.validFrom && + (left.validTo ?? undefined) === (right.validTo ?? undefined) + ); +} + +/** + * Reason recorded when two branches asserted the SAME semantic pair and the + * survivor rule kept only one of the two assertion ids. + */ +export const DUPLICATE_IDENTITY_ASSERTION_DROP_REASON = + "identity:duplicate-assertion"; + +function droppedIdentityAssertion( + assertion: IdentityTransferAssertion, + reason: string, +): DroppedItem { + return { kind: "identity", id: assertion.id, reason }; +} + +/** + * The `(a.kind, a.id, b.kind, b.id)` endpoint tuple every identity key encoder + * is built from. One definition keeps their FIELD ORDER identical — the keys + * are compared against each other's encodings, so a drifted order in one + * encoder would silently stop matching rows the others key the same way. + */ +export function endpointTuple( + assertion: Readonly<{ + a: Readonly<{ kind: string; id: string }>; + b: Readonly<{ kind: string; id: string }>; + }>, +): readonly string[] { + return [assertion.a.kind, assertion.a.id, assertion.b.kind, assertion.b.id]; +} + +/** Structural (kind, id) tuple key for one identity assertion ENDPOINT PAIR. */ +function pairEndpointKey( + assertion: Parameters[0], +): string { + return encodeTupleKey(endpointTuple(assertion)); +} + +/** + * One endpoint pair whose staged claims OPPOSE each other: the first `same` and + * `different` of the pair whose validity windows actually overlap — the + * witness a refusal names. + */ +type OpposingRelationGroup = Readonly<{ + endpoint: string; + overlap: Readonly<{ same: T; different: T }>; +}>; + +function firstOverlappingOpposingPair( + same: readonly T[], + different: readonly T[], + assertionOf: (item: T) => IdentityTransferAssertion, +): Readonly<{ same: T; different: T }> | undefined { + for (const sameItem of same) { + for (const differentItem of different) { + if ( + identityValidityWindowsOverlap( + assertionOf(sameItem), + assertionOf(differentItem), + ) + ) { + return { same: sameItem, different: differentItem }; + } + } + } + return undefined; +} + +/** + * THE "do these staged claims oppose each other" decision: groups items by + * endpoint pair, splits each group into `same` and `different`, and keeps only + * the groups where the two sides overlap in valid time. Both callers — the + * staged classifier and the post-remap re-validation — read their groups from + * here, so neither can spell its own overlap rule or group the endpoints its + * own way. + */ +function opposingRelationGroups( + items: readonly T[], + assertionOf: (item: T) => IdentityTransferAssertion, +): readonly OpposingRelationGroup[] { + const byEndpoint = groupBy(items, (item) => + pairEndpointKey(assertionOf(item)), + ); + const groups: OpposingRelationGroup[] = []; + for (const [endpoint, group] of byEndpoint) { + const same = group.filter((item) => assertionOf(item).relation === "same"); + const different = group.filter( + (item) => assertionOf(item).relation === "different", + ); + const overlap = firstOverlappingOpposingPair(same, different, assertionOf); + if (overlap === undefined) continue; + groups.push({ endpoint, overlap }); + } + return groups; +} + +/** The one refusal both opposing-relations checks throw. */ +function opposingRelationsConflictError( + same: IdentityTransferAssertion, + different: IdentityTransferAssertion, + endpoint: string, +): IdentityMergeConflictError { + return new IdentityMergeConflictError( + "Branches asserted opposing identity relations for one endpoint pair.", + { details: { endpoint, assertions: [same, different] } }, + ); +} + +/** What classifying one identity pair (one semantic key, one validity window) decided. */ +export type IdentityPairOutcome = + | Readonly<{ kind: "unchanged" }> + | Readonly<{ + kind: "asserted"; + survivor: IdentityTransferAssertion; + superseded: readonly DroppedItem[]; + }> + | Readonly<{ kind: "retracted"; retraction: IdentityTransferAssertion }>; + +/** + * Reduces N candidate assertions for the SAME window to one survivor: an id + * already committed on the target with the exact staged truth ALWAYS wins + * (the applier is idempotent per pair; a fresh challenger would never be + * written), otherwise {@link compareIdentitySurvivors} decides. Two branches + * staging the IDENTICAL row (same id, same complete truth) collapse silently + * — reporting one as dropped while it is the very row applied would make the + * report self-contradictory. + */ +function assertedOutcome( + candidates: readonly StagedIdentityAssertion[], + committedIds: ReadonlySet, +): IdentityPairOutcome { + const [first, ...rest] = candidates; + let survivor = requireDefined(first); + const superseded: DroppedItem[] = []; + for (const candidate of rest) { + const picked = pickIdentitySurvivor(candidate, survivor, committedIds); + survivor = picked.winner; + if (isIdenticalIdentityRow(picked.loser.assertion, picked.winner.assertion)) + continue; + superseded.push( + droppedIdentityAssertion( + picked.loser.assertion, + DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, + ), + ); + } + return { kind: "asserted", survivor: survivor.assertion, superseded }; +} + +/** + * Reduces N staged retractions of the SAME base row to one: the EARLIEST + * staged `validTo` wins, so two branches ending the same base assertion at + * different instants settle deterministically rather than on staging order. + */ +export function reduceIdentityRetraction( + candidates: readonly StagedRetraction[], +): StagedRetraction { + return candidates.reduce((earliest, candidate) => + ( + compareCodePoints( + candidate.assertion.validTo ?? "", + earliest.assertion.validTo ?? "", + ) < 0 + ) ? + candidate + : earliest, + ); +} + +/** + * Reduces raw (unstaged) identity assertions across POTENTIALLY MANY semantic + * pairs to one survivor per pair, via {@link identityDedupeKey} / + * {@link compareIdentitySurvivors} — the same rule + * {@link classifyIdentityPair}'s absent-pair arm applies, generalized to a + * flat list spanning multiple pairs. + * + * `remapIdentityAssertionEndpoints` (`merge-identity.ts`) re-dedupes through + * here after canonicalization, which can collapse two previously-distinct + * pairs onto one semantic key after the plan-time classification already ran. + */ +export function dedupeIdentityAssertionsRaw( + assertions: readonly IdentityTransferAssertion[], + committedIds: ReadonlySet, +): Readonly<{ + survivors: readonly IdentityTransferAssertion[]; + dropped: readonly DroppedItem[]; +}> { + const survivorBySemantic = new Map(); + const dropped: DroppedItem[] = []; + for (const assertion of assertions) { + const key = identityDedupeKey(assertion); + const previous = survivorBySemantic.get(key); + if (previous === undefined) { + survivorBySemantic.set(key, assertion); + continue; + } + const picked = pickIdentitySurvivor( + { assertion }, + { assertion: previous }, + committedIds, + ); + const survivor = picked.winner.assertion; + const loser = picked.loser.assertion; + survivorBySemantic.set(key, survivor); + if (isIdenticalIdentityRow(loser, survivor)) continue; + dropped.push( + droppedIdentityAssertion(loser, DUPLICATE_IDENTITY_ASSERTION_DROP_REASON), + ); + } + return { + survivors: [...survivorBySemantic.values()].toSorted((left, right) => + compareCodePoints(identityDedupeKey(left), identityDedupeKey(right)), + ), + dropped, + }; +} + +/** + * Opposing-relations re-validation over a FLAT, unstaged assertion list — what + * `remapIdentityAssertionEndpoints` runs after canonicalization, since node + * reconciliation can pull two previously distinct endpoint pairs onto one + * canonical pair and manufacture a collision no branch ever staged. + */ +export function assertNoOpposingIdentityRelationsRaw( + assertions: readonly IdentityTransferAssertion[], +): void { + const [group] = opposingRelationGroups(assertions, (assertion) => assertion); + if (group === undefined) return; + throw opposingRelationsConflictError( + group.overlap.same, + group.overlap.different, + group.endpoint, + ); +} + +/** + * Classifies one identity pair — one semantic key, one validity window — + * against the staged base slice. `base` is the target's CURRENT truth for + * this pair at staging time (empty when the pair has none); `asserted` / + * `retracted` are every branch's staged claims for it. + * + * - no branch asserts and none retracts → `unchanged`. + * - base absent, one or more branches assert → `asserted`, with every + * duplicate id but the survivor `superseded`. + * - base present, branches retract and nobody reasserts → `retracted` + * (earliest end). + * - base present, a branch reasserts → `asserted`; when a retraction is also + * staged, {@link planIdentityThreeWay} ends the base row at the + * retraction's own instant. + * + * A fresh assertion for an already-present pair with NO accompanying + * retraction is not a race at plan time: the race detector only fires when a + * retraction is staged. + * + * A genuine RACE (base present, and the branches that retract the pair are not + * exactly the branches that reassert it under a new id) and an OPPOSING-RELATIONS + * collision (both `same` and `different` staged for one endpoint with + * overlapping windows) span more than one pair, so + * {@link planIdentityThreeWay} refuses them before any pair reaches here. + */ +export function classifyIdentityPair( + base: readonly IdentityTransferAssertion[], + asserted: readonly StagedIdentityAssertion[], + retracted: readonly StagedRetraction[], + committedIds: ReadonlySet, +): IdentityPairOutcome { + if (asserted.length > 0) return assertedOutcome(asserted, committedIds); + if (base.length === 0 || retracted.length === 0) return { kind: "unchanged" }; + return { + kind: "retracted", + retraction: reduceIdentityRetraction(retracted).assertion, + }; +} + +/** + * The plan-time result of three-way classifying every staged identity pair. + * Every staged assertion is either in `assertions` or named in `dropped`; + * every staged retraction is either in `retractions` or was reduced into the + * earliest-ending retraction of the same base row. + */ +export type IdentityThreeWayResult = Readonly<{ + assertions: readonly IdentityTransferAssertion[]; + retractions: readonly IdentityTransferAssertion[]; + dropped: readonly DroppedItem[]; +}>; + +/** + * Refuses an OPPOSING-RELATIONS collision: branches asserted BOTH `same` and + * `different` for one endpoint pair with overlapping validity windows. Spans + * two semantic keys (one per relation) sharing an endpoint pair, so it is + * checked once, up front, over every staged new assertion. + */ +function assertNoStagedOpposingRelations(staging: StagingSet): void { + const [group] = opposingRelationGroups( + staging.newIdentityAssertions, + (staged) => staged.assertion, + ); + if (group === undefined) return; + throw opposingRelationsConflictError( + group.overlap.same.assertion, + group.overlap.different.assertion, + group.endpoint, + ); +} + +/** + * Refuses a RETRACT/REASSERT RACE: branches disagree about whether a pair's + * committed truth survives. Two shapes, one rule — a staged retraction and a + * staged reassertion of one pair (under a new id) are convergent only when + * every branch that retracted the pair also reasserted it, and every branch + * that reasserted it also retracted it: + * + * - a branch retracts the pair while a DIFFERENT branch reasserts it without + * retracting it; + * - a branch retracts the pair and stops there while a DIFFERENT branch + * retracts AND reasserts it — keeping the reassertion would silently + * overrule the first branch's retraction. + * + * A branch that retracts AND reasserts the same pair itself, with no other + * branch disagreeing, is convergent, not a race. + * + * Only an assertion that holds NOW reasserts the pair. One that ended — or was + * written bounded — records history and leaves its branch's verdict on the + * pair's current truth to its retraction alone. + */ +function assertNoRetractReassertRace(staging: StagingSet): void { + const reassertions = staging.newIdentityAssertions.filter((staged) => + isCurrentIdentityAssertion(staged.assertion), + ); + const retractedBySemantic = groupBy( + staging.retractedIdentityAssertions, + (staged) => identitySemanticKey(staged.assertion), + ); + const reassertedBySemantic = groupBy(reassertions, (staged) => + identitySemanticKey(staged.assertion), + ); + for (const staged of reassertions) { + const retractions = ( + retractedBySemantic.get(identitySemanticKey(staged.assertion)) ?? [] + ).filter((candidate) => candidate.assertion.id !== staged.assertion.id); + if (retractions.length === 0) continue; + const reasserters = new Set( + ( + reassertedBySemantic.get(identitySemanticKey(staged.assertion)) ?? [] + ).map((other) => other.branchId), + ); + const selfRetracted = retractions.some( + (retraction) => retraction.branchId === staged.branchId, + ); + const retraction = + selfRetracted ? + retractions.find((candidate) => !reasserters.has(candidate.branchId)) + : retractions[0]; + if (retraction === undefined) continue; + throw new IdentityMergeConflictError( + "Branches contain a retract/reassert race for one identity pair.", + { + details: { + retractedAssertion: retraction.assertion, + retractedBy: retraction.branchId, + reassertedAssertion: staged.assertion, + reassertedBy: staged.branchId, + }, + suggestion: + "Reconcile the branches so that every branch that retracts this identity assertion either also reasserts it or none reasserts it, then re-plan the merge.", + }, + ); + } +} + +/** + * Three-way classifies every staged identity pair against the staged base + * slice. Opposing relations and a retract/reassert race fail the plan; + * everything else reduces to one assertion, one retraction, or no change per + * pair, with every displaced duplicate reported as dropped. + */ +export function planIdentityThreeWay( + staging: StagingSet, + committedIds: ReadonlySet, +): IdentityThreeWayResult { + assertNoStagedOpposingRelations(staging); + assertNoRetractReassertRace(staging); + + const assertions: IdentityTransferAssertion[] = []; + const retractions: IdentityTransferAssertion[] = []; + const dropped: DroppedItem[] = []; + + const baseByDedupe = groupBy(staging.baseIdentityAssertions, (assertion) => + identityDedupeKey(assertion), + ); + const baseIdToDedupeKey = new Map( + staging.baseIdentityAssertions.map((assertion) => [ + assertion.id, + identityDedupeKey(assertion), + ]), + ); + const assertedByDedupe = groupBy(staging.newIdentityAssertions, (staged) => + identityDedupeKey(staged.assertion), + ); + const retractedByDedupe = new Map(); + for (const staged of staging.retractedIdentityAssertions) { + const key = baseIdToDedupeKey.get(staged.assertion.id); + if (key === undefined) { + // A retraction with NO base group to classify against. The base slice is + // the target's CURRENT truth (open rows only) while a retraction is + // derived from an archival read, so a branch that retracts a row the + // target has ALREADY ended stages a retraction whose base row is absent + // here. That is still a stated retraction: it is planned, and + // `planIdentityChanges`' stored-truth filter is the one owner that + // decides whether it applies or is dropped with + // `identity:retraction-target-mismatch`. Dropping it here would make it + // vanish from the plan with no reported reason. + retractions.push(staged.assertion); + continue; + } + const group = retractedByDedupe.get(key) ?? []; + group.push(staged); + retractedByDedupe.set(key, group); + } + + const groupKeys = new Set([ + ...baseByDedupe.keys(), + ...assertedByDedupe.keys(), + ]); + for (const groupKey of groupKeys) { + const retracted = retractedByDedupe.get(groupKey) ?? []; + const outcome = classifyIdentityPair( + baseByDedupe.get(groupKey) ?? [], + assertedByDedupe.get(groupKey) ?? [], + retracted, + committedIds, + ); + if (outcome.kind === "unchanged") continue; + if (outcome.kind === "retracted") { + retractions.push(outcome.retraction); + continue; + } + assertions.push(outcome.survivor); + dropped.push(...outcome.superseded); + // CONVERGENT case: the base row is present and SOME branch retracted it + // (not a race — a race was refused above) while a reassertion also + // survived. The base row still ends, at its own honestly staged instant. + if (retracted.length > 0) { + retractions.push(reduceIdentityRetraction(retracted).assertion); + } + } + + return { assertions, retractions, dropped }; +} diff --git a/packages/typegraph/src/graph-merge/index.ts b/packages/typegraph/src/graph-merge/index.ts index c733aa6ab..a3551c27d 100644 --- a/packages/typegraph/src/graph-merge/index.ts +++ b/packages/typegraph/src/graph-merge/index.ts @@ -1,6 +1,8 @@ export type { + CompatibleMergeReview, MergePlanApplied, MergePlanApplyOptions, + MergePlanDecisionOptions, MergePlanReadContext, } from "./apply-callbacks"; // Public barrel for @nicia-ai/typegraph/graph-merge. @@ -30,6 +32,7 @@ export { } from "./candidate-review"; export type { CandidateWriteSet, + CandidateWriteSetPlan, CandidateWriteSetTarget, PlanCandidateWriteSetArgs, PlanCandidateWriteSetForEvolutionArgs, @@ -83,10 +86,18 @@ export { operateDurableBranch, scanDurableOperations, } from "./durable-operation"; -export type { MergeConstraintConflictErrorDetails } from "./errors"; +export type { + AcyclicityMergeConflictDetails, + AcyclicityMergeConflictEdge, + IdentityMergeConflictCode, + MergeConstraintConflictErrorDetails, + MergeErrorOptions, +} from "./errors"; export { + AcyclicityMergeConflictError, BaseVersionMismatchError, BranchError, + CandidatePlanningScopeError, CandidateSourceError, CandidateWriteSetError, DurableEvidenceUndeliveredError, @@ -100,6 +111,7 @@ export { InvalidMergePlanError, MatchEvidenceError, MERGE_ERROR_CODES, + MergeCompositionOrphanError, MergeConflictError, MergeConstraintConflictError, MergeError, @@ -117,6 +129,7 @@ export { export type { CandidateDiagnostic, CandidateDiagnostics, + ClusterExclusionReason, EntityRef, MatchEvidence, MatchSource, @@ -144,11 +157,12 @@ export { MERGE_OPTION_DEFAULTS, normalizeMergeOptions } from "./options"; export type { MergePlanAnchors, MergePlanArtifact, - MergePlanArtifactV1, - MergePlanArtifactV1Input, + MergePlanArtifactV2, + MergePlanArtifactV2Input, MergePlanBranchAnchor, MergePlanCandidateDiagnostic, MergePlanCanonicalMapping, + MergePlanCompositionOrphan, MergePlanDiagnostics, MergePlanDigest, MergePlanEdgeDelete, @@ -199,8 +213,7 @@ export type { } from "./review-schema"; export { MERGE_REVIEW_FORMAT_VERSION, - MERGE_REVIEW_FORMAT_VERSION_V1, - MERGE_REVIEW_FORMAT_VERSION_V2, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, } from "./review-schema"; export type { BaseNodeLookup, @@ -208,7 +221,17 @@ export type { KeylessConfig, SourceScope, } from "./sources"; -export type { IdentityAssertionWriteFacade } from "./typegraph-internal"; +export type { + CandidatePlanningScope, + CandidatePlanningScopeReason, + WholeTargetPlanningScope, +} from "./sparse-candidate-branch"; +export type { + IdentityAssertionWriteFacade, + IdentityDecisionProvenance, + IdentityRelation, + IdentityTransferAssertion, +} from "./typegraph-internal"; export type { BaseAmbiguity, BaseVersion, @@ -224,6 +247,7 @@ export type { Embedder, EntityResolution, GraphBranch, + IdentityUnresolvedConflict, IngestionBranch, IngestionNodeCollections, MergeBranch, diff --git a/packages/typegraph/src/graph-merge/merge-identity.ts b/packages/typegraph/src/graph-merge/merge-identity.ts index 22560804b..4350d57de 100644 --- a/packages/typegraph/src/graph-merge/merge-identity.ts +++ b/packages/typegraph/src/graph-merge/merge-identity.ts @@ -37,20 +37,27 @@ * `IdentitySeparationViolationError` — an `IDENTITY_`-coded refusal — is * translated here like any other applier refusal. */ -import { identityAssertionSemanticKey } from "../identity/assertion-key"; import { identityReferenceKey, normalizeIdentityPair, } from "../identity/reference"; -import { identityValidityWindowsOverlap } from "../identity/validity-window"; +import { isCurrentIdentityAssertion } from "../identity/validity-window"; +import { nowIso } from "../utils/date"; import { encodeTupleKey } from "../utils/tuple-key"; -import type { CanonicalEntity } from "./canonicalize"; +import { type CanonicalEntity, COMMITTED_TARGET_BRANCH } from "./canonicalize"; import { BaseVersionMismatchError, describeCause, IdentityMergeConflictError, MergeError, } from "./errors"; +import { + assertNoOpposingIdentityRelationsRaw, + dedupeIdentityAssertionsRaw, + endpointTuple, + identityDedupeKey, + planIdentityThreeWay, +} from "./identity-three-way"; import { compareMergeKeys, compareStrings, @@ -60,7 +67,7 @@ import { mergeKey, mergeKeyOf, } from "./node-key"; -import type { StagedIdentityAssertion, StagingSet } from "./staging"; +import type { StagingSet } from "./staging"; import { assertTemporalIdentityClosureConsistent } from "./temporal-identity-closure"; import { compareCodePoints, @@ -69,6 +76,7 @@ import { hasScopedIdentityReads, IdentityContradictionError, type IdentityTransferAssertion, + loadIdentityWindowHistory, NodeNotFoundError, type Store, storeBackend, @@ -92,50 +100,7 @@ export type IdentityPlanSlice = Readonly<{ canonicalOf: ReadonlyMap; }>; -/** - * The `(a.kind, a.id, b.kind, b.id)` endpoint tuple every identity key encoder - * below is built from. One definition keeps their FIELD ORDER identical — the - * keys are compared against each other's encodings, so a drifted order in one - * encoder would silently stop matching rows the others key the same way. - */ -function endpointTuple( - assertion: Readonly<{ a: IdentityEndpoint; b: IdentityEndpoint }>, -): readonly string[] { - return [assertion.a.kind, assertion.a.id, assertion.b.kind, assertion.b.id]; -} - -function identityEndpointKey(assertion: IdentityTransferAssertion): string { - return encodeTupleKey(endpointTuple(assertion)); -} - -function identitySemanticKey(assertion: IdentityTransferAssertion): string { - return identityAssertionSemanticKey( - assertion.relation, - assertion.a, - assertion.b, - ); -} - -function identityDedupeKey(assertion: IdentityTransferAssertion): string { - const semantic = identitySemanticKey(assertion); - if (assertion.validTo === undefined) return semantic; - return encodeTupleKey([semantic, assertion.validFrom, assertion.validTo]); -} - -function compareIdentitySurvivors( - left: IdentityTransferAssertion, - right: IdentityTransferAssertion, -): number { - const byValidity = compareCodePoints(left.validFrom, right.validFrom); - return byValidity === 0 ? compareCodePoints(left.id, right.id) : byValidity; -} - -/** - * Reason recorded when two branches asserted the SAME semantic pair and the - * survivor rule kept only one of the two assertion ids. - */ -export const DUPLICATE_IDENTITY_ASSERTION_DROP_REASON = - "identity:duplicate-assertion"; +export { DUPLICATE_IDENTITY_ASSERTION_DROP_REASON } from "./identity-three-way"; /** * Reason recorded when node reconciliation collapsed both endpoints of a `same` @@ -144,6 +109,15 @@ export const DUPLICATE_IDENTITY_ASSERTION_DROP_REASON = export const REDUNDANT_IDENTITY_ASSERTION_DROP_REASON = "identity:endpoints-collapsed"; +/** + * Reason recorded when node reconciliation fused both endpoints of a + * `different` assertion that no longer holds — retracted, or ended by deleting + * an endpoint. It forbade nothing, and its pair is now one node, so the + * history row has no pair left to describe. + */ +export const COLLAPSED_ENDED_DIFFERENT_DROP_REASON = + "identity:ended-different-collapsed"; + /** Reason recorded when endpoint remapping leaves no shared validity window. */ export const EMPTY_REMAPPED_IDENTITY_WINDOW_DROP_REASON = "identity:empty-remapped-window"; @@ -156,159 +130,6 @@ function droppedIdentityAssertion( return { kind: "identity", id: assertion.id, reason }; } -/** - * Keeps one CURRENT assertion per semantic pair, while retaining every - * distinct bounded window. Exact bounded duplicates still choose one survivor - * and report the loser. Survivors come back in window-aware key order. - * - * An id in `committedIds` (already committed on the target with the exact - * staged truth) ALWAYS wins over an uncommitted challenger, regardless of the - * {@link compareIdentitySurvivors} order: the applier is idempotent per - * dedupe key, so the challenger would never be written — picking it would - * report an id as applied that never lands while listing the target's own row - * as dropped. Between two ids of equal committed status the comparator - * decides. - */ -function dedupeIdentityAssertions( - assertions: readonly IdentityTransferAssertion[], - committedIds: ReadonlySet, -): Readonly<{ - survivors: readonly IdentityTransferAssertion[]; - dropped: readonly DroppedItem[]; -}> { - const survivorBySemantic = new Map(); - const dropped: DroppedItem[] = []; - for (const assertion of assertions) { - const key = identityDedupeKey(assertion); - const previous = survivorBySemantic.get(key); - if (previous === undefined) { - survivorBySemantic.set(key, assertion); - continue; - } - const assertionCommitted = committedIds.has(assertion.id); - const previousCommitted = committedIds.has(previous.id); - const [survivor, loser] = - assertionCommitted === previousCommitted ? - compareIdentitySurvivors(assertion, previous) < 0 ? - ([assertion, previous] as const) - : ([previous, assertion] as const) - : assertionCommitted ? ([assertion, previous] as const) - : ([previous, assertion] as const); - survivorBySemantic.set(key, survivor); - // Two branches staging the IDENTICAL row (same id, same complete truth — - // e.g. both imported one interchange document) is ONE assertion, not a - // survivor and a loser: reporting the id as dropped while it is applied - // would make the report self-contradictory. - if ( - loser.id === survivor.id && - loser.validFrom === survivor.validFrom && - (loser.validTo ?? undefined) === (survivor.validTo ?? undefined) - ) { - continue; - } - dropped.push( - droppedIdentityAssertion(loser, DUPLICATE_IDENTITY_ASSERTION_DROP_REASON), - ); - } - return { - survivors: [...survivorBySemantic.values()].toSorted((left, right) => - compareCodePoints(identityDedupeKey(left), identityDedupeKey(right)), - ), - dropped, - }; -} - -function assertNoOpposingIdentityRelations( - assertions: readonly IdentityTransferAssertion[], -): void { - const byEndpoint = new Map(); - for (const assertion of assertions) { - const key = identityEndpointKey(assertion); - const group = byEndpoint.get(key) ?? []; - group.push(assertion); - byEndpoint.set(key, group); - } - for (const [endpoint, group] of byEndpoint) { - const same = group.filter((assertion) => assertion.relation === "same"); - const different = group.filter( - (assertion) => assertion.relation === "different", - ); - for (const sameAssertion of same) { - for (const differentAssertion of different) { - if ( - !identityValidityWindowsOverlap(sameAssertion, differentAssertion) - ) { - continue; - } - throw new IdentityMergeConflictError( - "Branches asserted opposing identity relations for one endpoint pair.", - { - details: { - endpoint, - assertions: [sameAssertion, differentAssertion], - }, - }, - ); - } - } - } -} - -/** - * Refuses the retract/reassert RACE: one branch retracts the assertion for a - * semantic pair while a DIFFERENT branch re-asserts that pair under a new id - * WITHOUT retracting it — the branches disagree about whether the old truth still - * holds, and no rule can pick between "the pair is not asserted" and "the pair is - * asserted under a new id". - * - * Two nearby shapes are NOT races and must merge cleanly: - * - * - A single fork that retracts then re-asserts the same pair (a normal linear - * edit): its final state is simply "old id retracted, new id asserted". - * - CONVERGENT edits, where the re-asserting branch ALSO retracted the pair: - * every branch agrees the old assertion dies, and one went further by - * re-asserting. The merge applies both effects. - * - * Both hinge on which branch produced which change, so this consults the staged - * (branch-tagged) retractions — grouping by semantic key alone would drop exactly - * the provenance that separates a race from agreement. - */ -function assertNoRetractReassertRace(staging: StagingSet): void { - const retractedBySemantic = new Map(); - for (const staged of staging.retractedIdentityAssertions) { - const key = identitySemanticKey(staged.assertion); - const retractions = retractedBySemantic.get(key) ?? []; - retractions.push(staged); - retractedBySemantic.set(key, retractions); - } - for (const staged of staging.newIdentityAssertions) { - const retractions = - retractedBySemantic.get(identitySemanticKey(staged.assertion)) ?? []; - const selfRetracted = retractions.some( - (retraction) => retraction.branchId === staged.branchId, - ); - if (selfRetracted) { - continue; - } - const crossBranchRetraction = retractions.find( - (retraction) => retraction.assertion.id !== staged.assertion.id, - ); - if (crossBranchRetraction !== undefined) { - throw new IdentityMergeConflictError( - "Branches contain a retract/reassert race for one identity pair.", - { - details: { - retractedAssertion: crossBranchRetraction.assertion, - retractedBy: crossBranchRetraction.branchId, - reassertedAssertion: staged.assertion, - reassertedBy: staged.branchId, - }, - }, - ); - } - } -} - /** * Reason recorded when a branch retracted an assertion whose id identifies a * DIFFERENT complete truth on the target's current ledger. Ending that row @@ -339,7 +160,47 @@ export const RETRACTION_DELETION_OVERRULED_DROP_REASON = export const NO_STORED_ASSERTIONS: ReadonlyMap = new Map(); -/** @internal Exported for deterministic phase-level verification. */ +/** + * Structural validation: one assertion id was staged for retraction under two + * different identity truths — a data-integrity fault, checked independently of + * the three-way classification below. + */ +function assertRetractedIdsHaveOneTruth(staging: StagingSet): void { + const seenById = new Map(); + for (const staged of staging.retractedIdentityAssertions) { + const previous = seenById.get(staged.assertion.id); + if ( + previous !== undefined && + assertionIdentityKey(previous) !== assertionIdentityKey(staged.assertion) + ) { + throw new IdentityMergeConflictError( + "One assertion id was staged for retraction with two different identity truths.", + { + details: { + assertionId: staged.assertion.id, + first: previous, + second: staged.assertion, + }, + }, + ); + } + seenById.set(staged.assertion.id, staged.assertion); + } +} + +/** + * @internal Exported for deterministic phase-level verification. + * + * Plans every identity change for one merge: three-way classifies the staged + * assertions against the staged base slice ({@link planIdentityThreeWay}: the + * duplicate-assertion survivor rule, the opposing-relations refusal, and the + * retract/reassert race refusal), then applies the duties owned here: the raw + * one-id-one-truth checks, the + * committed-id set the classifier's survivor rule needs, the retraction + * target-truth filter against the target's ACTUAL stored rows (independent of + * — and stricter than — the staged base slice the classifier reasons about), + * and the assert/retract id-collision backstop. + */ export function planIdentityChanges( staging: StagingSet, storedIdentityRowsById: ReadonlyMap, @@ -348,61 +209,37 @@ export function planIdentityChanges( retractions: readonly IdentityTransferAssertion[]; dropped: readonly DroppedItem[]; }> { - assertNoOpposingIdentityRelations( + // Staged ids the target ALREADY holds with the exact staged truth. The + // classifier's survivor rule must prefer these: the applier is idempotent + // per semantic pair, so a freshly minted branch id can never displace the + // target's committed row — choosing it would report an id as applied that + // is never written while listing the target's own row as dropped. + const committedIds = committedIdentityIds( staging.newIdentityAssertions.map((staged) => staged.assertion), + storedIdentityRowsById, ); - assertNoRetractReassertRace(staging); - // One id, one truth — over the RAW staged assertions, BEFORE the semantic - // survivor dedupe: two branches staging one id for the same pair with - // different validFrom values collapse into one survivor under the semantic - // key (which excludes validity), so a later check would never see the + const classified = planIdentityThreeWay(staging, committedIds); + + // One id, one truth — over the RAW staged assertions, never the classifier's + // survivors: two branches staging one id for the same pair with different + // validFrom values collapse into one survivor under the semantic key (which + // excludes validity), so a check reading the survivors would never see the // collision — while the report would list the id as both applied and // dropped. + // + // ORDER IS BEHAVIOR. A staging set that trips more than one check reports + // the first of: opposing-relations, then the retract/reassert race (both + // inside `planIdentityThreeWay`), then one-id-one-truth, then the + // retraction's two-truths check. assertOneIdOneTruth( staging.newIdentityAssertions.map((staged) => staged.assertion), NO_STORED_ASSERTIONS, ); + assertRetractedIdsHaveOneTruth(staging); - // Staged ids the target ALREADY holds with the exact staged truth. The - // survivor dedupe must prefer these: the applier is idempotent per semantic - // pair, so a freshly minted branch id can never displace the target's - // committed row — choosing it would report an id as applied that is never - // written while listing the target's own row as dropped. - const committedIds = new Set( - staging.newIdentityAssertions - .map((staged) => staged.assertion) - .filter((assertion) => { - const stored = storedIdentityRowsById.get(assertion.id); - return ( - stored !== undefined && - assertionTruthKey(stored) === assertionTruthKey(assertion) - ); - }) - .map((assertion) => assertion.id), - ); - const deduped = dedupeIdentityAssertions( - staging.newIdentityAssertions.map((staged) => staged.assertion), - committedIds, - ); const retractionById = new Map(); - for (const staged of staging.retractedIdentityAssertions) { - const previous = retractionById.get(staged.assertion.id); - if ( - previous !== undefined && - assertionIdentityKey(previous) !== assertionIdentityKey(staged.assertion) - ) { - throw new IdentityMergeConflictError( - "One assertion id was staged for retraction with two different identity truths.", - { - details: { - assertionId: staged.assertion.id, - first: previous, - second: staged.assertion, - }, - }, - ); - } - retractionById.set(staged.assertion.id, staged.assertion); + for (const retraction of classified.retractions) { + retractionById.set(retraction.id, retraction); } const retractions: IdentityTransferAssertion[] = []; const retractionDropped: DroppedItem[] = []; @@ -410,7 +247,7 @@ export function planIdentityChanges( const stored = storedIdentityRowsById.get(assertionId); if ( stored !== undefined && - stored.validTo === undefined && + isCurrentIdentityAssertion(stored) && assertionIdentityKey(stored) !== assertionIdentityKey(retraction) ) { retractionDropped.push({ @@ -429,7 +266,7 @@ export function planIdentityChanges( // diff and the retraction truth filter each break every construction we // know — so refuse typed if a future path assembles it. const survivingIds = new Set( - deduped.survivors.map((survivor) => survivor.id), + classified.assertions.map((survivor) => survivor.id), ); for (const retraction of retractions) { if (survivingIds.has(retraction.id)) { @@ -440,11 +277,13 @@ export function planIdentityChanges( } } return { - assertions: deduped.survivors, + assertions: [...classified.assertions].sort((left, right) => + compareCodePoints(identityDedupeKey(left), identityDedupeKey(right)), + ), retractions: retractions.toSorted((left, right) => compareCodePoints(left.id, right.id), ), - dropped: [...deduped.dropped, ...retractionDropped], + dropped: [...classified.dropped, ...retractionDropped], }; } @@ -485,8 +324,11 @@ function resolveIdentityEndpoint( * `requireLiveEndpoints` guard would reject with a NodeNotFoundError, rolling back * the whole merge). After remapping it re-establishes the identity-pair invariants: * a `same` assertion whose endpoints collapse onto one survivor is redundant - * and dropped (reported), while a collapsed `different` assertion is a typed - * conflict; surviving endpoints are re-normalized into code-point order (the + * and dropped (reported), as is a collapsed `different` that no longer holds, + * while a collapsed CURRENT `different` assertion is a typed conflict. A row + * the target already holds with the staged truth is never reported dropped — + * it stays on the target untouched; + * surviving endpoints are re-normalized into code-point order (the * import path rejects a non-normalized pair), and assertions that now share a * semantic key are re-deduped by the same survivor rule * ({@link compareIdentitySurvivors}) that {@link planIdentityChanges} applied @@ -520,17 +362,29 @@ export function remapIdentityAssertionEndpoints( canonicalOf, retypeMap, ); + const stored = storedRowsById.get(assertion.id); + const committed = + stored !== undefined && + assertionTruthKey(stored) === assertionTruthKey(assertion); if (mergeKeyOf(remappedA) === mergeKeyOf(remappedB)) { - if (assertion.relation === "different") { + if ( + assertion.relation === "different" && + isCurrentIdentityAssertion(assertion) + ) { throw new IdentityMergeConflictError( "Node reconciliation collapsed a different-identity assertion onto one survivor.", { details: { assertion, survivor: remappedA } }, ); } + // A row the target already holds stays there as it is: the merge writes + // nothing for it, so reporting it dropped would contradict the target. + if (committed) continue; dropped.push( droppedIdentityAssertion( assertion, - REDUNDANT_IDENTITY_ASSERTION_DROP_REASON, + assertion.relation === "same" ? + REDUNDANT_IDENTITY_ASSERTION_DROP_REASON + : COLLAPSED_ENDED_DIFFERENT_DROP_REASON, ), ); continue; @@ -561,6 +415,7 @@ export function remapIdentityAssertionEndpoints( undefined, ); if (validTo !== undefined && compareCodePoints(validFrom, validTo) >= 0) { + if (committed) continue; dropped.push( droppedIdentityAssertion( assertion, @@ -589,10 +444,8 @@ export function remapIdentityAssertionEndpoints( // moved endpoints could only end as a false one-truth refusal or — // worse, if the challenger won the dedupe — a report/ledger divergence. // Refuse early with the specific cause instead. - const stored = storedRowsById.get(assertion.id); if ( - stored !== undefined && - assertionTruthKey(stored) === assertionTruthKey(assertion) && + committed && assertionTruthKey(result) !== assertionTruthKey(assertion) ) { throw new IdentityMergeConflictError( @@ -613,22 +466,12 @@ export function remapIdentityAssertionEndpoints( // still exactly matches its stored truth here. Without the recompute, the // branch's unwritable challenger could win this second dedupe and recreate // the applied-but-never-written report divergence. - const committedIds = new Set( - remapped - .filter((assertion) => { - const stored = storedRowsById.get(assertion.id); - return ( - stored !== undefined && - assertionTruthKey(stored) === assertionTruthKey(assertion) - ); - }) - .map((assertion) => assertion.id), - ); - const deduped = dedupeIdentityAssertions(remapped, committedIds); + const committedIds = committedIdentityIds(remapped, storedRowsById); + const deduped = dedupeIdentityAssertionsRaw(remapped, committedIds); // Node reconciliation can collapse previously distinct endpoint pairs onto // the same canonical pair, so the pre-reconciliation check above is not // sufficient on its own. - assertNoOpposingIdentityRelations(deduped.survivors); + assertNoOpposingIdentityRelationsRaw(deduped.survivors); return { assertions: deduped.survivors, dropped: [...dropped, ...deduped.dropped], @@ -650,27 +493,55 @@ export function remapIdentityAssertionEndpoints( * and is (not) the same as another, a second says it is gone, and unlike the * delete/modify case there is no policy the caller can set to arbitrate. * + * Only an assertion that holds NOW conflicts with a deletion this merge + * applies from a branch. One that ended — retracted, or ended by the very + * deletion of its endpoint — claims nothing about the entity's present, and the + * commit lands it as history: an ended row needs only its endpoint ROWS, which + * the merge's soft delete keeps, and that delete lands at the merge instant, + * after the row ended. + * + * A deletion the incremental TARGET already holds is different: it landed at the + * target's own, earlier instant, or removed the row outright, so even an ended + * assertion over it cannot be written. Every assertion over such a node is + * refused, except a row the target itself already holds with the exact staged + * truth — the applier writes nothing for it. + * * `nodeDeletions` is keyed by the deleted node's own kind, while the assertions * arrive post-retype, so a retyped identity is checked in BOTH forms. */ export function assertIdentityEndpointsNotDeleted( assertions: readonly IdentityTransferAssertion[], nodeDeletions: ReadonlyMap, + stagedDeletions: StagingSet["deletedNodes"], retypeMap: ReadonlyMap, + storedRowsById: ReadonlyMap, ): void { - const deletedIdentities = new Set(); - for (const key of nodeDeletions.keys()) { - deletedIdentities.add(key); - const retyped = retypeMap.get(key); - if (retyped !== undefined) { - deletedIdentities.add(mergeKey(retyped, idOf(key))); + const withRetypedForms = ( + keys: Iterable, + ): ReadonlySet => { + const identities = new Set(); + for (const key of keys) { + identities.add(key); + const retyped = retypeMap.get(key); + if (retyped !== undefined) identities.add(mergeKey(retyped, idOf(key))); } - } + return identities; + }; + const deletedIdentities = withRetypedForms(nodeDeletions.keys()); + const targetHeldDeletions = withRetypedForms( + stagedDeletions + .filter((staged) => staged.branchId === COMMITTED_TARGET_BRANCH) + .map((staged) => mergeKey(staged.node.kind, staged.node.id)) + .filter((key) => nodeDeletions.has(key)), + ); + const committedIds = committedIdentityIds(assertions, storedRowsById); for (const assertion of assertions) { + const current = isCurrentIdentityAssertion(assertion); for (const endpoint of [assertion.a, assertion.b]) { - if (!deletedIdentities.has(mergeKeyOf(endpoint))) { - continue; - } + const key = mergeKeyOf(endpoint); + if (!deletedIdentities.has(key)) continue; + if (!current && !targetHeldDeletions.has(key)) continue; + if (!current && committedIds.has(assertion.id)) continue; throw new IdentityMergeConflictError( "A branch asserted an identity relation over a node another branch deleted.", { details: { assertion, deletedEndpoint: endpoint } }, @@ -703,8 +574,10 @@ export function assertIdentityEndpointsNotDeleted( * `MergeError` — the documented `GRAPH_MERGE_IDENTITY_CONFLICT` code never fired. * * The check evaluates the ledger the merge would actually leave behind: the base's - * CURRENT assertions minus the staged retractions, plus the already remapped and - * deduped staged assertions. Base endpoints are put through the same + * CURRENT assertions, each one a planned retraction ends kept as the history it + * becomes (valid until the retraction's own end, so a branch claim that + * overlaps the time it still held is judged against it), plus the already + * remapped and deduped staged assertions. Base endpoints are put through the same * {@link resolveIdentityEndpoint} mapping, so an inherited assertion whose node * was folded or retyped is judged at its post-merge identity. The `same` * assertions are folded into equivalence classes and every `different` pair — @@ -722,7 +595,7 @@ export function assertIdentityEndpointsNotDeleted( */ export function assertNoContradictoryIdentityClosure( plannedAssertions: readonly IdentityTransferAssertion[], - retractions: readonly string[], + retractions: readonly IdentityTransferAssertion[], baseAssertions: readonly IdentityTransferAssertion[], deletedNodes: ReadonlySet, canonicalOf: ReadonlyMap, @@ -730,28 +603,42 @@ export function assertNoContradictoryIdentityClosure( identityContext: PlanIdentityContext, nodeUniverse: readonly Readonly<{ kind: string; id: string }>[], ): void { - const retracted = new Set(retractions); - // Deleting a node ends its assertions, so a base assertion touching a - // plan-deleted endpoint must not conduct in the simulated post-merge - // ledger. The commit-guard re-runs filter their fresh ledger the same way - // BEFORE calling here; applying the rule inside the checker keeps all - // three call sites on one rule even when a retraction was dropped (e.g. - // target-truth mismatch) and the cascade id never reaches `retractions`. - const survivesDeletion = ( + const retractionEndById = new Map( + retractions.map((retraction) => [retraction.id, retraction.validTo]), + ); + // The one rule for how a planned deletion changes the ledger, applied + // here for all three call sites, exactly as the commit's validator sees it: + // deleting a node ends the assertions still holding over it at the merge + // instant — the target held them until then, even where the branch ended + // them earlier, since the commit's delete ends them before any planned + // retraction runs — and never erases the history it already had. Applying + // it inside the checker also covers a retraction dropped as a target-truth + // mismatch, whose cascade id never reaches `retractions`. + const deletionInstant = nowIso(); + const touchesDeletedNode = ( assertion: Readonly<{ a: Readonly<{ kind: string; id: string }>; b: Readonly<{ kind: string; id: string }>; }>, ): boolean => - !deletedNodes.has(mergeKey(assertion.a.kind, assertion.a.id)) && - !deletedNodes.has(mergeKey(assertion.b.kind, assertion.b.id)); + deletedNodes.has(mergeKey(assertion.a.kind, assertion.a.id)) || + deletedNodes.has(mergeKey(assertion.b.kind, assertion.b.id)); const mergedLedger: readonly IdentityTransferAssertion[] = [ ...plannedAssertions, ...baseAssertions - .filter( - (assertion) => - !retracted.has(assertion.id) && survivesDeletion(assertion), - ) + .flatMap((assertion) => { + const retracted = retractionEndById.has(assertion.id); + const validTo = + assertion.validTo === undefined && touchesDeletedNode(assertion) ? + deletionInstant + : retracted ? retractionEndById.get(assertion.id) + : assertion.validTo; + if (retracted && validTo === undefined) return []; + if (validTo !== undefined && validTo <= assertion.validFrom) return []; + return validTo === assertion.validTo ? + [assertion] + : [{ ...assertion, validTo }]; + }) .map((assertion) => ({ ...assertion, a: resolveIdentityEndpoint(assertion.a, canonicalOf, retypeMap), @@ -898,12 +785,10 @@ export async function buildIdentityPeerProbe( // the CURRENT ledger touching the universe is read fresh here: the recheck // validates the plan against it, and its `different` slice is fingerprinted // for the in-transaction comparison. - const ledger = ( - await relevantLedgerAssertions(target, memberKeys, storeBackend(target)) - ).filter( - (assertion) => - !plannedDeletions.has(mergeKey(assertion.a.kind, assertion.a.id)) && - !plannedDeletions.has(mergeKey(assertion.b.kind, assertion.b.id)), + const ledger = await relevantLedgerAssertions( + target, + memberKeys, + storeBackend(target), ); return { probe: { @@ -914,7 +799,9 @@ export async function buildIdentityPeerProbe( relevantPeers.map((peer) => mergeKey(peer.kind, peer.id)), ), memberKeys, - differentFingerprint: differentLedgerFingerprint(ledger), + differentFingerprint: differentLedgerFingerprint( + withoutPlannedDeletions(ledger, plannedDeletions), + ), classFingerprints: snapshot.fingerprints, seeds, }, @@ -998,6 +885,21 @@ async function relevantLedgerAssertions( ); } +/** + * A ledger slice without the rows over nodes the plan deletes — the slice a + * drift fingerprint compares, since those rows end with the merge anyway. + */ +function withoutPlannedDeletions( + ledger: readonly LedgerAssertion[], + plannedDeletions: ReadonlySet, +): readonly LedgerAssertion[] { + return ledger.filter( + (assertion) => + !plannedDeletions.has(mergeKey(assertion.a.kind, assertion.a.id)) && + !plannedDeletions.has(mergeKey(assertion.b.kind, assertion.b.id)), + ); +} + /** Deterministic fingerprint of the `different` assertions in a ledger slice. */ /** @internal Deterministic fingerprint for commit-guard verification. */ export function differentLedgerFingerprint( @@ -1036,6 +938,28 @@ export function encodeClassFingerprint( ]); } +/** + * The ids among `assertions` the target ALREADY holds with the exact same + * complete truth — the set the three-way survivor rule must prefer, since the + * applier is idempotent per pair and never writes a fresh challenger over it. + */ +function committedIdentityIds( + assertions: readonly IdentityTransferAssertion[], + storedRowsById: ReadonlyMap, +): ReadonlySet { + return new Set( + assertions + .filter((assertion) => { + const stored = storedRowsById.get(assertion.id); + return ( + stored !== undefined && + assertionTruthKey(stored) === assertionTruthKey(assertion) + ); + }) + .map((assertion) => assertion.id), + ); +} + /** The complete truth an assertion id identifies, as a comparable string. */ export function assertionTruthKey(assertion: LedgerAssertion): string { return JSON.stringify([ @@ -1151,7 +1075,7 @@ export async function assertPlannedIdentityIdsFresh( const stored = storedById.get(retraction.id); if ( stored !== undefined && - stored.validTo === undefined && + isCurrentIdentityAssertion(stored) && assertionIdentityKey(stored) !== assertionIdentityKey(retraction) ) { throw new BaseVersionMismatchError( @@ -1306,6 +1230,38 @@ function identityRefusalCode(error: TypeGraphError): string | undefined { * @internal Exported for isolated verification of the translation contract. */ export function translateIdentityCommitError(error: unknown): unknown { + return asIdentityMergeConflict( + error, + (cause) => + `The identity applier refused the resolved plan inside the commit transaction — identity truth on the target is incompatible with the plan. Cause: ${cause}`, + // A target that moved after planning is refused earlier, by the plan + // fence and identity guards, with their own re-plan errors; reaching + // this arm means the plan-time simulation missed an applier rule, so a + // re-plan from unchanged state reproduces the refusal. + "The plan-time identity checks did not foresee this refusal, so re-planning against the same target refuses again. Resolve the conflict named in the cause — change the branch's assertion, or the target's identity truth — then re-plan the merge.", + ); +} + +/** + * The plan-time twin of {@link translateIdentityCommitError}: an identity + * refusal raised while the planner reads what the importer would read (a + * staged assertion the importer's own shape validation refuses) becomes the + * same typed conflict, through the same classification, before any write. + */ +export function translateIdentityPlanError(error: unknown): unknown { + return asIdentityMergeConflict( + error, + (cause) => + `A staged identity assertion cannot be applied to the target. Cause: ${cause}`, + "Correct the branch's assertion named in the cause, then re-plan the merge.", + ); +} + +function asIdentityMergeConflict( + error: unknown, + message: (cause: string) => string, + suggestion: string, +): unknown { if (error instanceof MergeError) return error; if (!(error instanceof TypeGraphError)) return error; const identityCode = identityRefusalCode(error); @@ -1315,15 +1271,11 @@ export function translateIdentityCommitError(error: unknown): unknown { (identityCode !== undefined && !IDENTITY_ENVIRONMENT_CODES.has(identityCode)); if (!identityRefusal) return error; - return new IdentityMergeConflictError( - `The identity applier refused the resolved plan inside the commit transaction — identity truth on the target is incompatible with the plan. Cause: ${describeCause(error)}`, - { - cause: error, - details: { ...(identityCode === undefined ? {} : { identityCode }) }, - suggestion: - "Retry once — the plan is recomputed from current state. If the refusal persists, the branches' identity truth conflicts with the target and needs manual resolution (change the branch's assertion, or resolve the target's truth first).", - }, - ); + return new IdentityMergeConflictError(message(describeCause(error)), { + cause: error, + details: { ...(identityCode === undefined ? {} : { identityCode }) }, + suggestion, + }); } /** @@ -1397,14 +1349,16 @@ export async function assertIdentityPeersStable( } // Negative truth: a `different` assertion committed (or retracted) in the // window changes plan legality without moving any class or peer. - const ledger = ( - await relevantLedgerAssertions(target, probe.memberKeys, txBackend) - ).filter( - (assertion) => - !probe.plannedDeletions.has(mergeKey(assertion.a.kind, assertion.a.id)) && - !probe.plannedDeletions.has(mergeKey(assertion.b.kind, assertion.b.id)), + const ledger = await relevantLedgerAssertions( + target, + probe.memberKeys, + txBackend, ); - if (differentLedgerFingerprint(ledger) !== probe.differentFingerprint) { + if ( + differentLedgerFingerprint( + withoutPlannedDeletions(ledger, probe.plannedDeletions), + ) !== probe.differentFingerprint + ) { throw new BaseVersionMismatchError( "mergeIncremental() validated identity against the `different` assertions visible at planning, but that ledger changed before the commit transaction. Committing the plan could contradict identity truth it never validated.", { details: {} }, @@ -1417,11 +1371,28 @@ export async function assertIdentityPeersStable( // added while a and b are already transitively connected (no class or // ledger-slice fingerprint moves) that becomes the surviving link once the // plan removes their bridge and asserts them different. + // The target's history too, read through the same window-ledger owner the + // plan and the importer use: a `different` committed AND ended in the + // window moves no current-ledger fingerprint, yet it contradicts a claim + // over the span it held — a target change, so it must refuse here as a + // re-plan rather than reach the importer's refusal. + const ledgerIds = new Set(ledger.map((assertion) => assertion.id)); + const history = + plan.identityAssertions.length === 0 ? + [] + : ( + await loadIdentityWindowHistory( + storeRuntime(target).identityContext(), + txBackend, + plan.identityAssertions, + nowIso(), + ) + ).filter((assertion) => !ledgerIds.has(assertion.id)); try { assertNoContradictoryIdentityClosure( plan.identityAssertions, - plan.identityRetractions.map((retraction) => retraction.id), - ledger, + plan.identityRetractions, + [...ledger, ...history], probe.plannedDeletions, plan.canonicalOf, plan.retypeMap, diff --git a/packages/typegraph/src/graph-merge/merge.ts b/packages/typegraph/src/graph-merge/merge.ts index 8caefcfea..d7da75448 100644 --- a/packages/typegraph/src/graph-merge/merge.ts +++ b/packages/typegraph/src/graph-merge/merge.ts @@ -4,12 +4,18 @@ import { IdentityEndpointValidityError } from "../errors"; import type { EvolutionPlan } from "../schema/evolution-plan"; import { resolveEvolvedTransactionStore } from "../store/runtime-port"; import type { TransactionContext } from "../store/types"; +import { groupBy } from "../utils/array"; +import { nowIso } from "../utils/date"; import { createDataKeyedBag, hasOwnKey } from "../utils/object"; import { requireDefined } from "../utils/presence"; import { assertMergeTransactionPristine } from "./adopted-transaction"; import { assertMergeCallbackResult, + assertOnlyDeclaredApplyOptions, + MERGE_PLAN_APPLY_OPTION_KEYS, + MERGE_PLAN_DECISION_OPTION_KEYS, type MergePlanApplyOptions, + type MergePlanDecisionOptions, mergePlanReadContext, } from "./apply-callbacks"; /** @@ -33,8 +39,8 @@ import { * property union under the stable, non-wall-clock conflict policy. * 6. DELETE/MODIFY — `resolveDeleteModify` (T8a): the authoritative final * liveness of every inherited node + the delete/modify conflicts. - * 7. TYPE-RECONCILE — `reconcileTypes` (T10) over the public-closure glue when - * `reconcileTypes: "ontology"`; otherwise a no-op. + * 7. TYPE-RECONCILE — `reconcileTypes` (T10) over the store's validated + * `KindRegistry` when `reconcileTypes: "ontology"`; otherwise a no-op. * 8. EDGE REPOINT — `repointEdges` (T9): repoint every staged edge onto its * cluster canonical, drop edges to finally-deleted endpoints, dedupe + union * edge props under the same conflict policy. @@ -61,6 +67,7 @@ import { hasRevisionAnchor, isLegacyBaseVersion, LEGACY_BASE_VERSION_REFUSAL, + optionalActiveSchemaHash, readActiveSchemaVersion, revisionAnchorOf, revisionOriginOf, @@ -76,7 +83,6 @@ import { canonicalizeCluster, COMMITTED_TARGET_BRANCH, } from "./canonicalize"; -import { buildSubClassClosure } from "./closures"; import type { ClusterResult } from "./clustering"; import { connectedComponents, @@ -92,18 +98,27 @@ import { resolveDeleteModify, resolveEdgeDeleteModify, } from "./delete-modify"; -import type { MergedEdge, StagedEdge } from "./edge-repoint"; +import type { + EndpointDeletedEdge, + MergedEdge, + StagedEdge, +} from "./edge-repoint"; import { BRANCH_CREATED_EDGE_ORIGIN, buildCanonicalMap, INHERITED_EDGE_ORIGIN, repointEdges, + withRepointedEndpoints, } from "./edge-repoint"; import { + AcyclicityMergeConflictError, BaseVersionMismatchError, describeCause, + IdentityMergeConflictError, InvalidMergeOptionsError, InvalidMergePlanError, + MERGE_ERROR_CODES, + MergeCompositionOrphanError, MergeError, MergePlanCapabilityError, MergePlanDigestMismatchError, @@ -115,10 +130,27 @@ import { translateMergeCommitError, UnsupportedMergePlanVersionError, } from "./errors"; -import type { CandidateDiagnostic, CandidateDiagnostics } from "./evidence"; -import { compareMatchEvidence } from "./evidence"; +import type { + CandidateDiagnostic, + CandidateDiagnostics, + ClusterExclusionReason, +} from "./evidence"; +import { compareMatchEvidence, entityRef } from "./evidence"; import { evolutionPlanningTarget } from "./evolution-target"; import { createRecordedBaseReader } from "./historical-base"; +import { + branchAncestryOf, + mergeIdentityDecision, + mergeIdentityDecisionFromArtifact, +} from "./identity-decision"; +import type { IdentitySeparationFacts } from "./identity-separation"; +import { + captureIdentitySeparationFacts, + isSeparatedPair, + NO_IDENTITY_SEPARATION_FACTS, + separatingAssertionIds, +} from "./identity-separation"; +import { reduceIdentityRetraction } from "./identity-three-way"; import { unwrapMergeBranches } from "./ingestion-branch"; import { assertIdentityEndpointsNotDeleted, @@ -136,6 +168,7 @@ import { remapIdentityAssertionEndpoints, RETRACTION_DELETION_OVERRULED_DROP_REASON, translateIdentityCommitError, + translateIdentityPlanError, } from "./merge-identity"; import { compareMergeKeys, @@ -147,17 +180,20 @@ import { mergeKeyOf, } from "./node-key"; import type { NormalizedMergeOptions } from "./options"; -import { normalizeMergeOptions } from "./options"; +import { normalizeMergeOptions, undeclaredResolveKindRefusal } from "./options"; import type { MergePlanAnchors, MergePlanArtifact, - MergePlanArtifactV1, - MergePlanArtifactV1Input, + MergePlanArtifactV2, + MergePlanArtifactV2Input, + MergePlanCompositionOrphan, MergePlanEdgeUpsert, MergePlanEntityRef, MergePlanNodeUpsert, MergePlanTargetFence, + MergePlanWrites, } from "./plan-schema"; +import { MERGE_PLAN_FORMAT_VERSION } from "./plan-schema"; import { constructMergePlanArtifact, validateMergePlanArtifact, @@ -189,32 +225,65 @@ import type { StagedNewEdge, StagedNewNode, StagedRetraction, + StagedWindowedEdge, StagingSet, } from "./staging"; import { stageBranches } from "./staging"; -import type { ModifiedNode, StateDiffBaseReader } from "./state-diff"; +import type { + ModifiedNode, + StateDiffBaseReader, + ValidWindow, +} from "./state-diff"; import type { ReconcileClusterInput } from "./type-reconcile"; import { mostSpecificCommonKind, reconcileTypes } from "./type-reconcile"; import type { Edge, + EdgeAcyclicityViolation, EdgeId, + GraphBackend, GraphDef, + GraphReadBackend, IdentityTransferAssertion, JsonValue, + KindRegistry, LineageDelta, Node, + NodeDeletePolicy, NodeId, + NodeRow, NodeType, + ProposedRelationEdge, Store, TransactionBackend, + TransactionDeleteNodeWithPolicy, TransactionOptions, UniqueIntrospection, ValidityEndMutation, } from "./typegraph-internal"; +import type { IdentityDecisionProvenance } from "./typegraph-internal"; import { + activeSchemaHash, + acyclicEdgeRelations, advanceRevisionClock, + BATCH_POINT_READ, + batchPointReadVerdict, + bindExtraIfReachable, + compositionEdgeAttachesPart, + createSqlSchema, + edgeKindIsInAcyclicRelation, + findLiveCompositionAttachment, + findLiveCompositionWhole, forceRecordedGraphRevision, forceWriteTransactionRevision, + getDialect, + getEdgeRowsByIds, + type GraphWriteLock, + isLiveNodeRow, + loadIdentityWindowHistory, + lockRecordedGraphWrite, + orientCompositionEndpoints, + planCompositionCascade, + readProposedEdgeAcyclicityViolations, readRecordedClock, readRevisionOrigin, runRetriedUnit, @@ -222,7 +291,11 @@ import { storeCaptureEnabled, storeRuntime, transactionBackend, + transactionDeleteNodeWithPolicy, TypeGraphError, + uncapturedGraphWriteLock, + withDeferredCompositionExistence, + withRecordedIdentityDecision, } from "./typegraph-internal"; import type { BaseAmbiguity, @@ -234,6 +307,7 @@ import type { Embedder, EntityResolution, GraphBranch, + IdentityUnresolvedConflict, MergeBranch, MergedCounts, MergeIncrementalArgs as MergeIncrementalArguments, @@ -426,10 +500,189 @@ async function embedMissingPairTexts( return new Map([...existing, ...(await embedSortedTexts(missing, embedder))]); } +/** + * The participant sets a fusion could occur WITHIN, so the separation probe + * costs one pair per pair the plan can actually merge rather than one per pair + * of participants. + * + * A cluster is always a subset of a connected component of the candidate-edge + * graph — the base and diameter guards only SPLIT components — so the + * components bound the transitive check exactly. Under + * `reconcileTypes: "ontology"` two staged nodes sharing a bare id can also fuse + * through a retype edge, so every bare id's members form one more group: a + * deliberately COARSER bound than `ontologyRetypeEdges` computes, chosen over + * a second call to it so the retype compatibility decision keeps one owner. + */ +function identityFusionGroups( + candidateEdges: readonly CandidateEdge[], + participants: readonly MergeKey[], + ontologyRetype: boolean, +): readonly (readonly MergeKey[])[] { + const groups = connectedComponents(candidateEdges, participants).map( + (component) => component.members, + ); + if (!ontologyRetype) return groups; + const byBareId = groupBy(participants, (key) => idOf(key)); + return [ + ...groups, + ...[...byBareId.values()].filter((members) => members.length > 1), + ]; +} + +/** + * The always-on separation VETO at the candidate-edge application point: a + * SCORED match between two entities the ledger holds apart is recall the + * ledger forbids, so the proposal is dropped, the merge continues, and the + * drop is reported as a typed `separation` conflict. + * + * A FORCED edge is left alone here on purpose. It is a DEFINITION — a shared + * unique value or a rediscovered base row — and refusing it at this point + * would fail merges the plan never had a problem with: the component base + * guard routinely severs a forced base match, so the two separated entities + * never land in one cluster and nothing is ever fused. The definitional + * refusal therefore runs on the edges that SURVIVE the base and diameter guards + * ({@link assertSurvivingEdgesNotSeparated}), where a contradiction is real. + */ +function applyIdentitySeparationVeto( + candidateEdges: readonly CandidateEdge[], + facts: IdentitySeparationFacts, +): Readonly<{ + edges: readonly CandidateEdge[]; + conflicts: readonly IdentityUnresolvedConflict[]; + // The edges the veto itself dropped — reported to `candidateDiagnostics` + // with `clusterDisposition: { kind: "excluded", reason: "separation" }` the + // same way the base and diameter guards report theirs, so the diagnostic + // surface never says "retained" about a pair the merge actually refused. + vetoedEdges: readonly CandidateEdge[]; +}> { + const edges: CandidateEdge[] = []; + const conflicts: IdentityUnresolvedConflict[] = []; + const vetoedEdges: CandidateEdge[] = []; + for (const edge of candidateEdges) { + if ( + edge.evidence.decision === "definitional" || + !isSeparatedPair(facts, edge.a, edge.b) + ) { + edges.push(edge); + continue; + } + vetoedEdges.push(edge); + const [source] = edge.evidence.sources; + conflicts.push({ + kind: "separation", + a: entityRef(edge.a), + b: entityRef(edge.b), + assertionIds: separatingAssertionIds(facts, edge.a, edge.b), + ...(source === undefined ? {} : { source }), + }); + } + return { edges, conflicts, vetoedEdges }; +} + +/** + * The separation veto's refusal of a DEFINITIONAL claim, applied to the edges + * that survived the base and diameter guards — the only edges that can still + * fuse anything. A definitional edge spanning two classes the ledger holds + * apart is a direct contradiction between two definitional claims, so the plan + * fails here rather than in the commit on the separation relation's + * ordered-pair CHECK, naming both entities, the assertion that separated them + * and the sources that proposed the match. + */ +function assertSurvivingEdgesNotSeparated( + survivingEdges: readonly CandidateEdge[], + facts: IdentitySeparationFacts, +): void { + if (facts.separatedClassPairs.size === 0) return; + for (const edge of survivingEdges) { + if (edge.evidence.decision !== "definitional") continue; + if (!isSeparatedPair(facts, edge.a, edge.b)) continue; + throw separationRefusal(edge.a, edge.b, facts, { + subject: "a definitional match between", + details: { sources: edge.evidence.sources }, + suggestion: + "Retract the `different` assertion separating these entities, or stop proposing them as one match, then re-plan the merge.", + }); + } +} + +/** + * The separation veto's refusal, for both application points that can still + * fail a plan: one message shape, one error code, and one `details` contract + * naming the two entities and the `different` assertion that separated them. + */ +function separationRefusal( + left: MergeKey, + right: MergeKey, + facts: IdentitySeparationFacts, + refusal: Readonly<{ + subject: string; + details: Readonly>; + suggestion: string; + }>, +): IdentityMergeConflictError { + return new IdentityMergeConflictError( + `Identity separation refuses ${refusal.subject} ${kindOf(left)}:${idOf(left)} and ${kindOf(right)}:${idOf(right)}: the identity ledger holds their classes apart.`, + { + code: MERGE_ERROR_CODES.identitySeparationConflict, + details: { + a: entityRef(left), + b: entityRef(right), + assertionIds: separatingAssertionIds(facts, left, right), + ...refusal.details, + }, + suggestion: refusal.suggestion, + }, + ); +} + +/** + * The always-on separation veto at the POST-CLUSTER application point: a + * TRANSITIVE fusion. `a`–`b` and `b`–`c` can each clear the threshold with no + * separated candidate edge among them, and the cluster still fuses `a` with + * `c`. A pure lookup into facts captured before planning, so plan construction + * stays synchronous. + */ +function assertClusterNotSeparated( + members: readonly MergeKey[], + facts: IdentitySeparationFacts, +): void { + if (facts.separatedClassPairs.size === 0) return; + for (const [index, left] of members.entries()) { + for (const right of members.slice(index + 1)) { + if (!isSeparatedPair(facts, left, right)) continue; + throw separationRefusal(left, right, facts, { + subject: "a merge cluster containing both", + details: { cluster: members.map((member) => entityRef(member)) }, + suggestion: + "Retract the `different` assertion separating these entities, or raise the match threshold so the cluster no longer spans them, then re-plan the merge.", + }); + } + } +} + +/** + * The ONE list merged branches are recorded as: the caller's branches, + * code-point by branch id. A plan artifact's `MergePlanAnchors.branches` and + * the identity decision's `branchAncestry` both read it here, so the same + * logical merge run directly and through a plan + `applyMergePlan` records the + * same replay provenance instead of two lists that only happen to agree. + * + * The reserved stand-in an incremental merge stages its committed target + * under is not a merged branch and is never recorded, whichever list the + * caller holds. + */ +function branchesInAnchorOrder( + branches: readonly GraphBranch[], +): readonly GraphBranch[] { + return branches + .filter((branch) => branch.id !== COMMITTED_TARGET_BRANCH) + .toSorted((left, right) => compareStrings(left.id, right.id)); +} + /** * Runs candidate generation for every kind that has a {@link ResolveConfig} by * driving the candidate SOURCES (`sources.ts`) over the shared SCORING stage - * (`scoring.ts`, §4): per kind, each source proposes pairs + forced edges off the + * (`scoring.ts`): per kind, each source proposes pairs + forced edges off the * scope, and the scoring stage turns them into the kind's candidate edges. * Accumulates edges + any base members across kinds and records comparison-ceiling * warnings. Kinds NOT in `resolve` (or with no staged new nodes) contribute no @@ -708,6 +961,12 @@ function indexNewNodesById( * `windowOnlyCarried` names the identities staged ONLY to carry an ending. Their * `branchId` is a write vehicle rather than a contribution (see the loop below), * which the provenance fold must know so it does not credit it. + * + * `deletionAbsorbed` holds the re-windowed copies a finally-deleted edge absorbs: + * never written, but still a branch's staged state, so the cross-branch + * composition check ({@link lostBranchAttachment}) can see a re-attachment the + * deletion would otherwise discard without a trace. Each carries the window its + * branch wrote. */ function buildStagedEdges( staging: StagingSet, @@ -717,10 +976,18 @@ function buildStagedEdges( ): Readonly<{ edges: readonly StagedEdge[]; windowOnlyCarried: ReadonlySet; + deletionAbsorbed: readonly StagedEdge[]; }> { const staged: StagedEdge[] = []; const stagedIdentities = new Set(); const windowOnlyCarried = new Set(); + const deletionAbsorbed: StagedEdge[] = []; + // Every branch diffs against the same merge base, so any re-windowed copy of + // an identity states that base's window. + const baseWindowByEdge = new Map(); + for (const item of staging.windowedEdges) { + baseWindowByEdge.set(mergeKeyOf(item.edge), item.edge.base); + } for (const items of staging.newEdgesByKind.values()) { for (const item of items) { staged.push(toStagedEdge(item.branchId, item)); @@ -730,6 +997,11 @@ function buildStagedEdges( const identity = mergeKeyOf(item.edge); stagedIdentities.add(identity); const validToChange = edgeValidityEnds.get(identity); + // A modified row whose window no branch moved holds the base's window. + const baseValidTo = + baseWindowByEdge.has(identity) ? + baseWindowByEdge.get(identity)?.validTo + : item.edge.row.valid_to; staged.push({ id: item.edge.id, kind: item.edge.kind, @@ -740,6 +1012,7 @@ function buildStagedEdges( toKind: item.edge.toKind, props: item.edge.forkProps as Readonly>, baseProps: item.edge.baseProps as Readonly>, + ...(baseValidTo === undefined ? {} : { baseValidTo }), branchId: item.branchId, ...(validToChange?.kind === "set" ? { validTo: validToChange.validTo } : validToChange?.kind === "clear" ? { clearValidTo: true as const } @@ -761,33 +1034,54 @@ function buildStagedEdges( // is credited from the resolution rather than from whoever carried the row. for (const item of staging.windowedEdges) { const identity = mergeKeyOf(item.edge); - const validToChange = edgeValidityEnds.get(identity); - if ( - validToChange === undefined || - stagedIdentities.has(identity) || - edgeDeletions.has(identity) - ) { + if (stagedIdentities.has(identity)) continue; + if (edgeDeletions.has(identity)) { + deletionAbsorbed.push( + windowedStagedEdge( + item, + item.edge.fork.validTo === undefined ? + { clearValidTo: true } + : { validTo: item.edge.fork.validTo }, + ), + ); continue; } + const validToChange = edgeValidityEnds.get(identity); + if (validToChange === undefined) continue; stagedIdentities.add(identity); windowOnlyCarried.add(identity); - staged.push({ - id: item.edge.id, - kind: item.edge.kind, - origin: INHERITED_EDGE_ORIGIN, - fromId: item.edge.fromId, - toId: item.edge.toId, - fromKind: item.edge.fromKind, - toKind: item.edge.toKind, - props: item.edge.props as Readonly>, - baseProps: item.edge.baseProps as Readonly>, - branchId: item.branchId, - ...(validToChange.kind === "set" ? - { validTo: validToChange.validTo } - : { clearValidTo: true as const }), - }); + staged.push( + windowedStagedEdge( + item, + validToChange.kind === "set" ? + { validTo: validToChange.validTo } + : { clearValidTo: true }, + ), + ); } - return { edges: staged, windowOnlyCarried }; + return { edges: staged, windowOnlyCarried, deletionAbsorbed }; +} + +/** The inherited {@link StagedEdge} for a re-windowed row, carrying `window`. */ +function windowedStagedEdge( + item: StagedWindowedEdge, + window: Readonly<{ validTo: string }> | Readonly<{ clearValidTo: true }>, +): StagedEdge { + const baseValidTo = item.edge.base.validTo; + return { + id: item.edge.id, + kind: item.edge.kind, + origin: INHERITED_EDGE_ORIGIN, + fromId: item.edge.fromId, + toId: item.edge.toId, + fromKind: item.edge.fromKind, + toKind: item.edge.toKind, + props: item.edge.props as Readonly>, + baseProps: item.edge.baseProps as Readonly>, + ...(baseValidTo === undefined ? {} : { baseValidTo }), + branchId: item.branchId, + ...window, + }; } /** @@ -930,6 +1224,8 @@ export type MergePlan = Readonly<{ // validation layers (the plan-time filter and the in-transaction freshness // guard) compare it to the target's row before the id is ended. identityRetractions: readonly IdentityTransferAssertion[]; + /** Scored matches the identity separation veto dropped. */ + identityConflicts: readonly IdentityUnresolvedConflict[]; }>; /** @@ -938,20 +1234,39 @@ export type MergePlan = Readonly<{ * order-independent given the captured `branchRank`. */ function buildInternalMergePlan( - staging: StagingSet, - candidateEdges: readonly CandidateEdge[], - candidateWarnings: readonly string[], - candidateDiagnostics: readonly CandidateDiagnostic[], - candidateDiagnosticsTotal: number, - baseMembers: readonly BaseMember[], - options: NormalizedMergeOptions, - branchRank: ReadonlyMap, - subClassClosure: ReturnType, - identityContext: PlanIdentityContext, - storedIdentityRowsById: ReadonlyMap, - targetPeers: readonly Readonly<{ kind: string; id: string }>[], - preferredBranchId?: BranchId, + input: Readonly<{ + staging: StagingSet; + candidateEdges: readonly CandidateEdge[]; + candidateWarnings: readonly string[]; + candidateDiagnostics: readonly CandidateDiagnostic[]; + candidateDiagnosticsTotal: number; + baseMembers: readonly BaseMember[]; + options: NormalizedMergeOptions; + branchRank: ReadonlyMap; + registry: KindRegistry; + storedIdentityRowsById: ReadonlyMap; + separationFacts: IdentitySeparationFacts; + separationConflicts: readonly IdentityUnresolvedConflict[]; + vetoedEdges: readonly CandidateEdge[]; + preferredBranchId?: BranchId | undefined; + }>, ): MergePlan { + const { + staging, + candidateEdges, + candidateWarnings, + candidateDiagnostics, + candidateDiagnosticsTotal, + baseMembers, + options, + branchRank, + registry, + storedIdentityRowsById, + separationFacts, + separationConflicts, + vetoedEdges, + preferredBranchId, + } = input; const identity = planIdentityChanges(staging, storedIdentityRowsById); const provenanceRecords: ProvenanceRecord[] = []; // The contributions already recorded, keyed by `contributionKey` — the sidecar @@ -1034,7 +1349,7 @@ function buildInternalMergePlan( const preferKind = isOntology ? (kinds: readonly string[]): string | undefined => - mostSpecificCommonKind(subClassClosure, kinds) + mostSpecificCommonKind(registry, kinds) : undefined; const clusterEdges = isOntology ? @@ -1042,8 +1357,7 @@ function buildInternalMergePlan( ...candidateEdges, ...ontologyRetypeEdges( newNodesById.keys(), - (kinds) => - mostSpecificCommonKind(subClassClosure, kinds) !== undefined, + (kinds) => mostSpecificCommonKind(registry, kinds) !== undefined, ), ] : candidateEdges; @@ -1076,7 +1390,18 @@ function buildInternalMergePlan( const survivingEdges = diameterGuard.survivingEdges.filter((edge) => baseSurvivingEdges.has(edge), ); - const excludedByEndpoints = new Map(); + // (4c) the second application point of the always-on separation veto, on the + // FINAL clusters and the edges that reached them — after the base and + // diameter guards have split what they split, so a match the guards + // already severed is never refused for a fusion it can no longer cause. The + // edge-level refusal runs first because it is the more specific diagnosis: + // a surviving DEFINITIONAL claim contradicting the ledger, named with the + // sources that proposed it, rather than the cluster it happens to sit in. + assertSurvivingEdgesNotSeparated(survivingEdges, separationFacts); + for (const cluster of clusters) { + assertClusterNotSeparated(cluster.members, separationFacts); + } + const excludedByEndpoints = new Map(); for (const excluded of [ ...guard.excludedEdges, ...diameterGuard.excludedEdges, @@ -1086,6 +1411,16 @@ function buildInternalMergePlan( excluded.reason, ); } + // The separation veto drops a candidate edge before it ever reaches the base + // or diameter guards, so it needs its own exclusion reason fed into the same + // map — otherwise the diagnostic surface says "retained" about a pair the + // merge actually refused for contradicting the identity ledger. + for (const edge of vetoedEdges) { + excludedByEndpoints.set( + JSON.stringify([edge.a, edge.b]), + "separation" as const, + ); + } const diagnosticsWithDisposition: CandidateDiagnostic[] = candidateDiagnostics.map((diagnostic): CandidateDiagnostic => { if (diagnostic.evidence.decision === "definitional") return diagnostic; @@ -1125,11 +1460,8 @@ function buildInternalMergePlan( // carries them in full, so a component spanning two same-id/different-kind committed // entities stays distinguishable in the report. const baseAmbiguities: BaseAmbiguity[] = guard.events.map((event) => ({ - baseIds: event.baseIds.map((key) => ({ kind: kindOf(key), id: idOf(key) })), - memberIds: event.memberIds.map((key) => ({ - kind: kindOf(key), - id: idOf(key), - })), + baseIds: event.baseIds.map((key) => entityRef(key)), + memberIds: event.memberIds.map((key) => entityRef(key)), })); // (5) canonicalize each cluster: min-id survivor + commutative prop union. @@ -1237,29 +1569,31 @@ function buildInternalMergePlan( stagedRetractionsById.set(staged.assertion.id, contributions); } const overruledRetractionDrops: DroppedItem[] = []; + const contributionOverruled = (staged: StagedRetraction): boolean => + staged.cause.kind === "cascade" && + !nodeDeletions.has( + mergeKey(staged.cause.deletedNode.kind, staged.cause.deletedNode.id), + ); const survivingRetractions = anyDeletionOverruled ? - identity.retractions.filter((retraction) => { + identity.retractions.flatMap((retraction) => { const contributions = stagedRetractionsById.get(retraction.id) ?? []; - const everyContributionOverruled = - contributions.length > 0 && - contributions.every( - (staged) => - staged.cause.kind === "cascade" && - !nodeDeletions.has( - mergeKey( - staged.cause.deletedNode.kind, - staged.cause.deletedNode.id, - ), - ), - ); - if (!everyContributionOverruled) return true; - overruledRetractionDrops.push({ - kind: "identity", - id: retraction.id, - reason: RETRACTION_DELETION_OVERRULED_DROP_REASON, - }); - return false; + const standing = contributions.filter( + (staged) => !contributionOverruled(staged), + ); + if (standing.length === contributions.length) return [retraction]; + if (standing.length === 0) { + overruledRetractionDrops.push({ + kind: "identity", + id: retraction.id, + reason: RETRACTION_DELETION_OVERRULED_DROP_REASON, + }); + return []; + } + // The ending survives on the contributions that still stand, so its + // instant and cause are theirs: an overruled deletion's cascade may + // not decide when the pair stopped being asserted. + return [reduceIdentityRetraction(standing).assertion]; }) : identity.retractions; for (const modification of deleteModify.survivingModifications) { @@ -1374,11 +1708,12 @@ function buildInternalMergePlan( } } - // (7) opt-in ontology type reconciliation over the public-closure glue. Inputs - // (incl. base member kinds) were collected per cluster in the canonicalize loop. + // (7) opt-in ontology type reconciliation over the store's validated + // KindRegistry. Inputs (incl. base member kinds) were collected per cluster + // in the canonicalize loop. const reconciliation = reconcileTypes( reconcileInputs, - subClassClosure, + registry, options.reconcileTypes, ); const reconciledClusterByEntityId = new Map(); @@ -1448,40 +1783,15 @@ function buildInternalMergePlan( assertIdentityEndpointsNotDeleted( identityRemap.assertions, nodeDeletions, + staging.deletedNodes, reconciliation.retypeMap, + storedIdentityRowsById, ); - // Each canonical entity enters the universe under the kind the commit will - // actually WRITE — the retyped one when the ontology cascade retypes it — - // while its deletion check runs against the source identity, which is how - // `nodeDeletions` is keyed. - const identityNodeUniverse = [ - ...canonicalEntities - .filter( - (entity) => - !nodeDeletions.has(mergeKey(entity.kind, entity.canonicalId)), - ) - .map((entity) => ({ - kind: - reconciliation.retypeMap.get( - mergeKey(entity.kind, entity.canonicalId), - ) ?? entity.kind, - id: entity.canonicalId, - })), - ...targetPeers.filter( - (node) => !nodeDeletions.has(mergeKey(node.kind, node.id)), - ), - ]; - assertNoContradictoryIdentityClosure( - identityRemap.assertions, - survivingRetractions.map((retraction) => retraction.id), - staging.baseIdentityAssertions, - new Set(nodeDeletions.keys()), - canonicalOf, - reconciliation.retypeMap, - identityContext, - identityNodeUniverse, - ); - const { edges: stagedEdges, windowOnlyCarried } = buildStagedEdges( + const { + edges: stagedEdges, + windowOnlyCarried, + deletionAbsorbed, + } = buildStagedEdges( staging, reconciledEdgeModifications.survivingModifications, validWindows.edgeEnds, @@ -1527,6 +1837,16 @@ function buildInternalMergePlan( weights, preferredBranchId, ); + assertNoLostBranchAttachments( + registry, + [ + ...repoint.endpointDeletedEdges, + ...deletionAbsorbed.map((staged) => + withRepointedEndpoints(staged, canonicalOf), + ), + ], + deletedNodeIdSet, + ); for (const merged of repoint.edges) { for (const sourceId of merged.mergedIds) { for (const branchId of stagedEdgeBranches.get(sourceId) ?? []) { @@ -1571,12 +1891,25 @@ function buildInternalMergePlan( // names that contain `|`; either makes the comparator non-total, so the // returned order would depend on stable-sort + insertion order and break the // order-independence the whole subsystem guarantees. - resolutions: resolutions.sort((left, right) => - compareMergeKeys( - mergeKey(left.kind, left.canonicalId), - mergeKey(right.kind, right.canonicalId), + // A resolution names the kind its survivor is WRITTEN under — the + // reconciled kind when the ontology cascade retypes the cluster — the same + // value the commit's node write, the artifact's `guards.retypes` and its + // resolution evidence check all read, so one owner decides it. The staged + // (pre-retype) survivor kind is the retype map's key. + resolutions: resolutions + .map((resolution) => ({ + ...resolution, + kind: + reconciliation.retypeMap.get( + mergeKey(resolution.kind, resolution.canonicalId), + ) ?? resolution.kind, + })) + .sort((left, right) => + compareMergeKeys( + mergeKey(left.kind, left.canonicalId), + mergeKey(right.kind, right.canonicalId), + ), ), - ), propertyConflicts: [...propertyConflicts, ...repoint.conflicts].sort( (left, right) => { const byEntity = compareMergeKeys( @@ -1630,9 +1963,33 @@ function buildInternalMergePlan( }), identityAssertions: identityRemap.assertions, identityRetractions: survivingRetractions, + identityConflicts: separationConflicts.toSorted((left, right) => + compareSeparationConflicts(left, right), + ), }; } +/** + * Order-independent report order for separation conflicts: by the endpoint + * pair, never insertion order, which would depend on which branch's diff + * happened to stage an entity first. + */ +function compareSeparationConflicts( + left: IdentityUnresolvedConflict, + right: IdentityUnresolvedConflict, +): number { + const byA = compareMergeKeys( + mergeKey(left.a.kind, left.a.id), + mergeKey(right.a.kind, right.a.id), + ); + return byA === 0 ? + compareMergeKeys( + mergeKey(left.b.kind, left.b.id), + mergeKey(right.b.kind, right.b.id), + ) + : byA; +} + /** * The canonical survivor identity of a cluster, taken DIRECTLY from its already-resolved * {@link CanonicalEntity} so edge repoint reuses the exact survivor `canonicalizeCluster` @@ -1888,13 +2245,47 @@ function nodeWriteWindowOptions( ): NodeWriteWindowOptions { const validFrom = write.validFrom === undefined ? {} : { validFrom: write.validFrom }; - if (write.clearValidTo === true) { - return { ...validFrom, clearValidTo: true }; - } - return { - ...validFrom, - ...(write.validTo === undefined ? {} : { validTo: write.validTo }), - }; + return { ...validFrom, ...validityEndMutationOf(write) }; +} + +/** + * THE projection of a write's end-of-validity state — a stated instant, or an + * explicit reopening — onto the mutation the collections accept. The live plan, + * its wire artifact, and the replayed artifact all read it, so a reopening can + * never be dropped by one of them. + */ +function validityEndMutationOf( + write: Readonly<{ + validTo?: string | undefined; + clearValidTo?: boolean | undefined; + }>, +): ValidityEndMutation { + if (write.clearValidTo === true) return { clearValidTo: true }; + return write.validTo === undefined ? {} : { validTo: write.validTo }; +} + +/** Whether a write states its own end of validity — an instant or an explicit reopening — rather than keeping the stored row's. */ +function statesValidityEnd( + write: Readonly<{ + validTo?: string | undefined; + clearValidTo?: boolean | undefined; + }>, +): boolean { + return write.validTo !== undefined || write.clearValidTo === true; +} + +/** Whether a live edge of `edgeKind` ending at `validTo` still attaches its `partKind` part. */ +function liveCompositionEdgeAttaches( + registry: KindRegistry, + partKind: string, + edgeKind: string, + validTo: string | undefined, +): boolean { + return compositionEdgeAttachesPart(registry, partKind, { + kind: edgeKind, + deleted_at: undefined, + valid_to: validTo, + }); } type MechanicalNodeWrite = Readonly<{ @@ -1910,12 +2301,54 @@ type MechanicalEdgeWrite = Readonly<{ item: EdgeUpsert; }>; +/** The node rows a plan deletes — the claims it releases — as `applyNodeRows` receives them. */ +function plannedNodeReleases( + plan: MergePlan, +): readonly MergePlanEntityRef[] { + return [...plan.nodeDeletions].map(([identity, kind]) => ({ + kind, + id: idOf(identity), + })); +} + +/** + * The node rows a plan writes, as `applyNodeRows` receives them: every planned + * write with its resolved after-image props (a modification's base keys the + * fork removed set to `undefined`, so the patch-merging upsert drops them) and + * its window mutation. + */ +function plannedNodeUpserts( + plan: MergePlan, +): readonly MechanicalNodeWrite[] { + return plannedNodeWrites(plan).map((write) => ({ + kind: write.kind, + id: write.id, + props: nodeWriteProps(write, (modification) => + commitModificationProps(modification.baseProps, modification.forkProps), + ), + ...(write.validFrom === undefined ? {} : { validFrom: write.validFrom }), + ...validityEndMutationOf(write), + })); +} + +/** + * Applies the plan's node deletions and upserts inside the apply transaction. + * + * `deleteNodeWithPolicy` is threaded down from the caller's own + * `target.transaction(tx => ...)` callback — the only scope holding `tx` + * itself — rather than re-derived from `target`: the outer Store's hook runner + * reports a delete's `onOperationEnd` the instant it runs, so a context built + * from `target` here would report success for a delete inside a merge that + * later rolls back. See {@link transactionDeleteNodeWithPolicy}. + */ async function applyNodeRows( target: Store, txBackend: TransactionBackend, nodesApi: TxNodes, deletions: readonly MergePlanEntityRef[], upserts: readonly MechanicalNodeWrite[], + edgeDeletions: readonly MergePlanEntityRef[], + deleteNodeWithPolicy: TransactionDeleteNodeWithPolicy, ): Promise { const afterImages = new Map>>(); const upsertsByKind = new Map(); @@ -1964,8 +2397,29 @@ async function applyNodeRows( releases: deletions, }, async () => { + // Routed through the internal runtime port rather than the collection + // facade, whose `delete(id)` takes no policy by design: enforcement + // stays on, so a part's own `restrict` edge still aborts the merge, while + // `cascadeComposition: false` leaves the parts to the plan, which already + // enumerates every one a composition delete would cascade to + // (`assertNoCompositionOrphans`, in `applyGraphRows`, verifies that + // enumeration is COMPLETE before this loop runs at all). + // + // The plan's own edge deletions are CONSUMED: the edge phase deletes + // them after this loop, so a `restrict` endpoint whose only blocking + // edge the plan also deletes — the order the branch itself had to take — + // is not refused, and no `cascade` / `disconnect` removes an edge the + // edge phase still owns. + const deletePolicy: NodeDeletePolicy = { + enforceDeleteBehavior: true, + cascadeComposition: false, + consumedEdgeIds: new Set(edgeDeletions.map((edge) => edge.id)), + }; for (const deletion of deletions) { - await nodeCollection(nodesApi, deletion.kind).delete(deletion.id); + await deleteNodeWithPolicy( + { kind: deletion.kind, id: deletion.id }, + deletePolicy, + ); } const committed = new Set(); for (const upsert of upserts) { @@ -1981,6 +2435,16 @@ async function applyNodeRows( ); } +/** + * Applies the plan's edge deletions and upserts inside the apply transaction. + * + * RELEASES BEFORE CLAIMS: every deletion lands first, then every upsert that + * ends a validity window, then the rest. A move is planned as "retire the old + * edge, write the new one", and a claim the retired edge still holds (a part's + * one whole, a `oneActive` slot) refuses the new edge if it is written first — + * which a single `bulkUpsertById` would do, since it runs its creates ahead of + * its updates. + */ async function applyEdgeRows( edgesApi: TxEdges, deletions: readonly MergePlanEntityRef[], @@ -1989,18 +2453,106 @@ async function applyEdgeRows( for (const deletion of deletions) { await edgeCollection(edgesApi, deletion.kind).delete(deletion.id); } + const endsWindow = (upsert: MechanicalEdgeWrite): boolean => + upsert.item.validTo !== undefined; + return ( + (await upsertEdgeRows( + edgesApi, + upserts.filter((upsert) => endsWindow(upsert)), + )) + + (await upsertEdgeRows( + edgesApi, + upserts.filter((upsert) => !endsWindow(upsert)), + )) + ); +} + +async function upsertEdgeRows( + edgesApi: TxEdges, + upserts: readonly MechanicalEdgeWrite[], +): Promise { const byKind = new Map(); for (const upsert of upserts) { const items = byKind.get(upsert.kind); if (items === undefined) byKind.set(upsert.kind, [upsert.item]); else items.push(upsert.item); } - let committed = 0; for (const [kind, items] of byKind) { await edgeCollection(edgesApi, kind).bulkUpsertById(items); - committed += items.length; } - return committed; + return upserts.length; +} + +type MergeGraphRows = Readonly<{ + /** The normalized write view the composition decisions read. */ + writes: MergePlanWrites; + nodePhase: MergeNodeWritePhase; + edgeUpserts: readonly MechanicalEdgeWrite[]; +}>; + +/** + * The node and edge rows of every merge apply — the live plan and the replayed + * artifact both land them through here, so they order the same writes and + * guard composition the same way. + * + * A plan's composition rows are valid together and not one at a time: a + * required part is written before its edge, a moved part loses its old edge + * before the new one lands. So the per-write existence rule is deferred for + * exactly the parts the plan could leave unattached + * ({@link requiredExistenceOrphanCandidates}), and re-asserted for those same + * parts against the final state, in this transaction, once the last edge is + * in ({@link assertNoUnattachedRequiredParts}). + */ +async function applyGraphRows( + target: Store, + txBackend: TransactionBackend, + nodesApi: TxNodes, + edgesApi: TxEdges, + rows: MergeGraphRows, + deleteNodeWithPolicy: TransactionDeleteNodeWithPolicy, + decision: IdentityDecisionProvenance | undefined, +): Promise> { + // Authoritative re-verification, before any row work below: a part + // attached to a deleted whole after the branch point (and neither deleted + // nor moved away by this plan) must abort the WHOLE apply. + await assertNoCompositionOrphans(target, txBackend, rows.writes); + + // Resolved before any row is written: a deleted composition edge's + // endpoints are read while it is still live in `txBackend`. + const unattachedCandidates = await requiredExistenceOrphanCandidates( + { graphId: target.graphId, registry: target.registry }, + txBackend, + rows.writes, + ); + + const applied = await withDeferredCompositionExistence( + txBackend, + unattachedCandidates, + async () => { + const nodePhase = await applyNodeWritePhase( + target, + txBackend, + nodesApi, + rows.nodePhase, + rows.writes.edgeDeletes, + deleteNodeWithPolicy, + decision, + ); + const edges = await applyEdgeRows( + edgesApi, + rows.writes.edgeDeletes, + rows.edgeUpserts, + ); + return { ...nodePhase, edges }; + }, + ); + + await assertNoUnattachedRequiredParts( + target, + txBackend, + unattachedCandidates, + ); + return applied; } async function applyIdentityRows( @@ -2008,6 +2560,10 @@ async function applyIdentityRows( txBackend: TransactionBackend, assertions: readonly IdentityTransferAssertion[], retractions: readonly IdentityTransferAssertion[], + // The governing merge decision every transition this apply causes carries. + // Threaded to the ONE owner of "which decision is in force" — the capture + // session `applyIdentityChangesForContext` opens — never re-derived here. + decision: IdentityDecisionProvenance | undefined, assertConsistent?: () => Promise, ): Promise> { try { @@ -2015,6 +2571,7 @@ async function applyIdentityRows( txBackend, retractions, assertions, + decision, ); if (assertConsistent !== undefined) await assertConsistent(); return { asserted: applied.created, retracted: applied.retracted }; @@ -2023,68 +2580,93 @@ async function applyIdentityRows( } } +type MergeNodeWritePhase = Readonly<{ + deletions: readonly MergePlanEntityRef[]; + upserts: readonly MechanicalNodeWrite[]; + identityRetractions: readonly IdentityTransferAssertion[]; +}>; + /** - * Applies a resolved {@link MergePlan} through a transaction's collection API. - * Shared by `commitPlan()` and the guarded `mergeIncremental()` commit path so - * both modes execute the same resolved semantics. + * The node write phase of every merge apply — the live plan and the replayed + * artifact both land their node rows through here, so they order the same + * writes the same way. + * + * A retraction naming a node whose validity window this merge ends is applied + * BEFORE the node rows: the store refuses to end the window of an endpoint a + * current assertion still names, so the only order a branch could have staged + * the pair in is the only order the apply can land it in. Every other + * retraction waits for the identity phase, which is handed the complete list + * again and skips the ones already ended here. A window end the plan did not + * pair with a retraction is still refused, as the typed identity conflict. + * + * The rows are written under the merge's governing decision, so a membership + * transition a node write causes as a side effect — a deleted node's same-ID + * fold detaching, an upserted node folding into a class — names the merge + * that caused it, as the identity rows' own transitions do. Edge writes cause + * no membership transition and so carry none. */ -async function applyInternalMergePlan( - plan: MergePlan, - nodesApi: TxNodes, - edgesApi: TxEdges, +async function applyNodeWritePhase( target: Store, txBackend: TransactionBackend, -): Promise { - const nodeDeletions = [...plan.nodeDeletions].map(([identity, kind]) => ({ - kind, - id: idOf(identity), - })); - const nodeUpserts = plannedNodeWrites(plan).map((write) => ({ - kind: write.kind, - id: write.id, - props: nodeWriteProps(write, (modification) => - commitModificationProps(modification.baseProps, modification.forkProps), - ), - ...(write.validFrom === undefined ? {} : { validFrom: write.validFrom }), - ...(write.clearValidTo === true ? { clearValidTo: true as const } - : write.validTo === undefined ? {} - : { validTo: write.validTo }), - })); + nodesApi: TxNodes, + phase: MergeNodeWritePhase, + edgeDeletions: readonly MergePlanEntityRef[], + deleteNodeWithPolicy: TransactionDeleteNodeWithPolicy, + decision: IdentityDecisionProvenance | undefined, +): Promise> { const validityEndedNodes = new Set( - nodeUpserts + phase.upserts .filter((write) => "validTo" in write) .map((write) => mergeKey(write.kind, write.id)), ); - const earlyIdentityRetractions = plan.identityRetractions.filter( - (retraction) => - validityEndedNodes.has(mergeKeyOf(retraction.a)) || - validityEndedNodes.has(mergeKeyOf(retraction.b)), - ); const earlyIdentity = await applyIdentityRows( target, txBackend, [], - earlyIdentityRetractions, + phase.identityRetractions.filter( + (retraction) => + validityEndedNodes.has(mergeKeyOf(retraction.a)) || + validityEndedNodes.has(mergeKeyOf(retraction.b)), + ), + decision, ); - let committedNodes: number; - try { - committedNodes = await applyNodeRows( + const applyRows = (): Promise => + applyNodeRows( target, txBackend, nodesApi, - nodeDeletions, - nodeUpserts, + phase.deletions, + phase.upserts, + edgeDeletions, + deleteNodeWithPolicy, ); + try { + const nodes = + decision === undefined ? + await applyRows() + : await withRecordedIdentityDecision(txBackend, decision, applyRows); + return { nodes, retracted: earlyIdentity.retracted }; } catch (error) { throw error instanceof IdentityEndpointValidityError ? translateIdentityCommitError(error) : error; } +} - const edgeDeletions = [...plan.edgeDeletions].map(([identity, kind]) => ({ - kind, - id: idOf(identity), - })); +/** + * Applies a resolved {@link MergePlan} through a transaction's collection API. + * Shared by `commitPlan()` and the guarded `mergeIncremental()` commit path so + * both modes execute the same resolved semantics. + */ +async function applyInternalMergePlan( + plan: MergePlan, + nodesApi: TxNodes, + edgesApi: TxEdges, + target: Store, + txBackend: TransactionBackend, + deleteNodeWithPolicy: TransactionDeleteNodeWithPolicy, + decision: IdentityDecisionProvenance | undefined, +): Promise { const edgeUpserts: MechanicalEdgeWrite[] = []; for (const edge of plan.mergedEdges) { // Honor a fork's property deletion on an inherited edge: drop base keys absent @@ -2109,16 +2691,26 @@ async function applyInternalMergePlan( to: finalEdgeEndpoint(plan, edge.toKind, edge.toId), props, ...(edge.validFrom === undefined ? {} : { validFrom: edge.validFrom }), - ...(edge.clearValidTo === true ? { clearValidTo: true as const } - : edge.validTo === undefined ? {} - : { validTo: edge.validTo }), + ...validityEndMutationOf(edge), }, }); } - const committedEdges = await applyEdgeRows( + const rows = await applyGraphRows( + target, + txBackend, + nodesApi, edgesApi, - edgeDeletions, - edgeUpserts, + { + writes: resolvedMergeWrites(plan), + nodePhase: { + deletions: plannedNodeReleases(plan), + upserts: plannedNodeUpserts(plan), + identityRetractions: plan.identityRetractions, + }, + edgeUpserts, + }, + deleteNodeWithPolicy, + decision, ); // The IDENTITY-APPLIER boundary: any refusal from the apply below is an @@ -2137,12 +2729,13 @@ async function applyInternalMergePlan( txBackend, plan.identityAssertions, plan.identityRetractions, + decision, () => assertMergedIdentityClassesConsistent(target, txBackend, plan), ); return { - nodes: committedNodes, - edges: committedEdges, + nodes: rows.nodes, + edges: rows.edges, // ACTUAL ledger effects from the applier: rows created (idempotent // exact/pair matches the target already held are excluded — the normal // incremental case, where the target's own additions are staged back at @@ -2151,7 +2744,7 @@ async function applyInternalMergePlan( // partial commit. identity: { asserted: appliedIdentity.asserted, - retracted: earlyIdentity.retracted + appliedIdentity.retracted, + retracted: rows.retracted + appliedIdentity.retracted, }, }; } @@ -2193,6 +2786,7 @@ export async function commitPlan( target: Store, plan: MergePlan, expectedBaseVersion?: BaseVersion, + decision?: IdentityDecisionProvenance, ): Promise { if ( expectedBaseVersion !== undefined && @@ -2246,6 +2840,8 @@ export async function commitPlan( tx.edges as unknown as TxEdges, target, transactionBackend(tx), + (work, policy) => transactionDeleteNodeWithPolicy(tx, work, policy), + decision, ); }, mergeCommitTransactionOptions(target)), ), @@ -2265,11 +2861,17 @@ async function runMergeCommit( /** * Isolation for the merge commit transaction. SERIALIZABLE closes the window - * between the in-transaction re-validation reads and COMMIT on multi-writer - * Postgres (SSI aborts a racing writer with SQLSTATE 40001, which - * {@link file://../backend/capabilities/retried-unit.ts runRetriedUnit} retries); SQLite and - * PGlite serialize writers by construction, and the SQLite backend ignores - * the option. + * between the in-transaction re-validation reads and COMMIT against other + * SERIALIZABLE writers on multi-writer Postgres (SSI aborts one of them with + * SQLSTATE 40001, which + * {@link file://../backend/capabilities/retried-unit.ts runRetriedUnit} retries). SSI does + * not arbitrate against a writer at a weaker isolation, so the decisions only + * the per-graph write fence protects — edge acyclicity, a composition whole's + * parts closure — do not rest on it: making one advances the graph's fence + * row, which fails this transaction with the same SQLSTATE when ANY writer + * that made such a decision committed after its snapshot, and the retry + * re-runs the commit against the graph that writer left. SQLite and PGlite serialize + * writers by construction, and the SQLite backend ignores the option. */ const MERGE_COMMIT_TX_OPTIONS = { isolationLevel: "serializable", @@ -2414,7 +3016,6 @@ type NodeCollectionLike = Readonly<{ data: Record, options?: Readonly<{ validFrom?: string | null }> & ValidityEndMutation, ) => Promise; - delete: (id: string) => Promise; }>; /** The edge-collection surface the commit uses (runtime, kind-string keyed). */ @@ -2526,8 +3127,15 @@ function tryNormalize( try { return ok(normalizeMergeOptions(optionsInput)); } catch (error) { + // A refusal the normalizer already spelled as a typed invalid-option error + // travels unchanged: re-wrapping it would bury the `details.option` the + // caller needs to know WHICH option was refused. Anything else — + // a zod parse failure, a bare validation throw — becomes the generic + // invalid-options refusal with the original attached as its cause. return err( - new InvalidMergeOptionsError("Invalid merge options.", { cause: error }), + error instanceof InvalidMergeOptionsError ? error : ( + new InvalidMergeOptionsError("Invalid merge options.", { cause: error }) + ), ); } } @@ -2591,21 +3199,28 @@ type ResolvedMerge = Readonly<{ target: Store; plan: MergePlan; options: NormalizedMergeOptions; + /** + * Root-first: the base (or fork-point) graph, then every branch merged. The + * ancestry the identity decision records for a `merge()` that never produced + * a durable plan artifact to read anchors from. + */ + branchAncestry: readonly string[]; expectedBaseVersion?: BaseVersion; incrementalGuard?: IncrementalCommitGuard; }>; -function assertPublicPlanCapability( +/** Why `target` cannot carry a public merge plan, or `undefined` when it can. */ +export function publicPlanCapabilityRefusal( target: Store, -): void { +): MergePlanCapabilityError | undefined { if (!storeBackend(target).capabilities.execution.interactiveTransactions) { - throw new MergePlanCapabilityError( + return new MergePlanCapabilityError( "Public merge plans require a transaction-capable target backend.", { details: { capability: "execution.interactiveTransactions" } }, ); } if (!target.revisionTrackingEnabled) { - throw new MergePlanCapabilityError( + return new MergePlanCapabilityError( "Public merge plans require durable target revision tracking.", { details: { capability: "revisionTracking" }, @@ -2614,6 +3229,14 @@ function assertPublicPlanCapability( }, ); } + return undefined; +} + +function assertPublicPlanCapability( + target: Store, +): void { + const refusal = publicPlanCapabilityRefusal(target); + if (refusal !== undefined) throw refusal; } export async function captureMergePlanTargetFence( @@ -2631,7 +3254,10 @@ export async function captureMergePlanTargetFence( schema: { managed: activeSchema !== undefined, version: activeSchema?.version ?? 1, - hash: activeSchema?.schema_hash ?? schemaHash, + hash: + activeSchema === undefined ? schemaHash : ( + await activeSchemaHash(activeSchema) + ), }, revision: { origin, revision: revision ?? null }, }; @@ -2724,19 +3350,14 @@ function resolvedNodeUpserts( ), ), ...(write.validFrom === undefined ? {} : { validFrom: write.validFrom }), - ...(write.validTo === undefined ? {} : { validTo: write.validTo }), + ...validityEndMutationOf(write), })); } -async function resolvedMergeArtifact( - resolved: ResolvedMerge, - mode: "snapshot" | "incremental", - targetFence: MergePlanTargetFence, - anchors: MergePlanAnchors, -): Promise { - const { plan, options } = resolved; - const nodeUpserts = resolvedNodeUpserts(plan); - const edgeUpserts = plan.mergedEdges.map((edge) => { +function resolvedEdgeUpserts( + plan: MergePlan, +): readonly MergePlanEdgeUpsert[] { + return plan.mergedEdges.map((edge) => { const baseProps = plan.inheritedEdgeBaseProps.get(edge.id); const props = baseProps === undefined ? @@ -2749,56 +3370,780 @@ async function resolvedMergeArtifact( to: finalEdgeEndpoint(plan, edge.toKind, edge.toId), ...splitWireProps(props), ...(edge.validFrom === undefined ? {} : { validFrom: edge.validFrom }), - ...(edge.validTo === undefined ? {} : { validTo: edge.validTo }), + ...validityEndMutationOf(edge), }; }); - const input: MergePlanArtifactV1Input = { - formatVersion: 1, +} + +/** + * THE normalized write view of a resolved plan — every row it deletes, upserts, + * asserts, or retracts, in the wire artifact's own shape. The artifact + * serializes exactly this, and every walk that asks "what does this merge + * write" (the required-existence orphan candidates, for one) reads it, so the + * live-plan apply path and the replayed-artifact apply path see one view of + * the same plan rather than two hand-maintained projections of it. + */ +export function resolvedMergeWrites( + plan: MergePlan, +): MergePlanWrites { + return { + nodeDeletes: plannedNodeReleases(plan), + nodeUpserts: resolvedNodeUpserts(plan), + edgeDeletes: [...plan.edgeDeletions].map(([identity, kind]) => + wireEntityRef(kind, idOf(identity)), + ), + edgeUpserts: resolvedEdgeUpserts(plan), + identityAssertions: plan.identityAssertions, + identityRetractions: plan.identityRetractions, + }; +} + +/** + * A resolved write set as the composition-orphan decisions read it: what the + * target's composition state becomes once the writes land, stated as an + * overlay on a backend that has not been written yet. + * + * Both orphan arms judge the POST-plan state. Read against the pre-apply + * target alone, a deleted whole still shows the part the same plan moves + * away, and a deleted part still shows up as a live row with no edge. + */ +type PlannedCompositionState = Readonly<{ + deletedNodes: ReadonlySet; + upsertedNodes: ReadonlySet; + /** + * Composition edges that no longer attach their part: deleted outright, or + * ended under a pair that counts only an open window as a membership. + */ + detachedEdgeIds: ReadonlySet; + /** The composition edge upserts, by the part each one names. */ + edgeUpsertsByPart: ReadonlyMap; +}>; + +function plannedCompositionState( + registry: KindRegistry, + writes: MergePlanWrites, +): PlannedCompositionState { + const detachedEdgeIds = new Set(writes.edgeDeletes.map((edge) => edge.id)); + const edgeUpsertsByPart = new Map(); + for (const upsert of writes.edgeUpserts) { + const partSide = registry.compositionPartSide(upsert.kind); + if (partSide === undefined) continue; + const part = partSide === "from" ? upsert.from : upsert.to; + if ( + upsert.validTo !== undefined && + !liveCompositionEdgeAttaches( + registry, + part.kind, + upsert.kind, + upsert.validTo, + ) + ) { + detachedEdgeIds.add(upsert.id); + continue; + } + const partKey = mergeKey(part.kind, part.id); + const upserts = edgeUpsertsByPart.get(partKey); + if (upserts === undefined) edgeUpsertsByPart.set(partKey, [upsert]); + else upserts.push(upsert); + } + return { + deletedNodes: new Set( + writes.nodeDeletes.map((node) => mergeKey(node.kind, node.id)), + ), + upsertedNodes: new Set( + writes.nodeUpserts.map((node) => mergeKey(node.kind, node.id)), + ), + detachedEdgeIds, + edgeUpsertsByPart, + }; +} + +/** + * Whether one of the plan's own edge upserts leaves `part` attached. An + * upsert that states no window keeps the stored row's, so that row is read; + * an upsert of a row the target does not hold yet is born open. + */ +async function plannedEdgeUpsertAttachesPart( + ctx: Readonly<{ graphId: string; registry: KindRegistry }>, + backend: GraphReadBackend, + part: MergePlanEntityRef, + planned: PlannedCompositionState, +): Promise { + const upserts = + planned.edgeUpsertsByPart.get(mergeKey(part.kind, part.id)) ?? []; + for (const upsert of upserts) { + const validTo = + statesValidityEnd(upsert) ? + upsert.validTo + : (await backend.getEdge(ctx.graphId, upsert.id))?.valid_to; + if ( + liveCompositionEdgeAttaches(ctx.registry, part.kind, upsert.kind, validTo) + ) { + return true; + } + } + return false; +} + +/** + * THE "deleted" composition-orphan finding, shared by the plan-time report + * and apply's authoritative re-verification: for every whole the write set + * deletes, re-read its LIVE parts closure (`planCompositionCascade`) and + * report every member the write set neither deletes nor carries away. + * + * A member is CARRIED AWAY when the write set detaches the edge that binds it + * into the closure — the first half of a move — and so is everything hanging + * from it: those parts leave with their own whole, which survives. Whether a + * carried-away part ends up attached anywhere is the "unattached" arm's + * question ({@link unattachedRequiredPartOrphansAmong}), not this one's. + * + * Every deleted whole gets its own walk, not only the deletion's "root" + * wholes: a plan can delete two unrelated wholes whose closures do not reach + * each other. The same live orphan can therefore be found twice at depth >= 2, + * once through the outer whole's closure and once through an inner, also + * deleted whole's own walk. A part has exactly one immediate whole at read + * time, so the two walks agree, and deduping by part keeps the first report. + */ +async function compositionOrphansAmong( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + }>, + backend: GraphBackend | TransactionBackend, + wholes: readonly MergePlanEntityRef[], + planned: PlannedCompositionState, +): Promise { + const orphansByPart = new Map(); + for (const whole of wholes) { + if (!ctx.registry.isCompositionWhole(whole.kind)) { + continue; + } + const cascadePlan = await planCompositionCascade( + ctx, + whole.kind, + whole.id, + backend, + ); + // `members` is leaf-first, so walking it backwards meets every member + // after the whole it hangs from. + const carriedAway = new Set(); + for (const member of cascadePlan.members.toReversed()) { + const partKey = mergeKey(member.kind, member.id); + if ( + planned.detachedEdgeIds.has(member.viaEdgeId) || + carriedAway.has(mergeKey(member.whole.kind, member.whole.id)) + ) { + carriedAway.add(partKey); + continue; + } + if (planned.deletedNodes.has(partKey)) continue; + if (orphansByPart.has(partKey)) continue; + // `member.whole`/`member.viaEdgeKind` are the pair `planCompositionCascade` + // already resolved to admit this member in the first place — the + // member's OWN immediate whole, not necessarily the cascade root + // (`whole`, above) once the closure is more than one level deep. + orphansByPart.set(partKey, { + part: { kind: member.kind, id: member.id }, + whole: member.whole, + viaEdgeKind: member.viaEdgeKind, + cause: "deleted", + }); + } + } + return [...orphansByPart.values()]; +} + +/** + * THE "moved" composition finding, shared by the plan-time report and apply's + * authoritative re-verification: every part the write set deletes that the + * target still holds under a live whole the write set neither deletes nor + * detaches the part from. + * + * A branch deletes a part by deleting it or by deleting its whole (the + * cascade), and in both cases its own diff carries the composition edge it + * removed. A part the target has since moved under another whole — or + * attached to one — hangs from an edge the branch never saw, so the plan + * neither deletes nor detaches it. Applying the plan would take a part away + * from a whole that survives, a whole the branch never touched: the mirror of + * the "deleted" arm ({@link compositionOrphansAmong}), where a surviving part + * loses the whole the plan deletes. + */ +async function compositionMovedPartsAmong( + ctx: Readonly<{ graphId: string; registry: KindRegistry }>, + backend: GraphReadBackend, + parts: readonly MergePlanEntityRef[], + planned: PlannedCompositionState, +): Promise { + const moved: MergePlanCompositionOrphan[] = []; + for (const part of parts) { + const attachment = await findLiveCompositionAttachment( + ctx.registry, + backend, + ctx.graphId, + part.kind, + part.id, + planned.detachedEdgeIds, + ); + if (attachment === undefined) continue; + const { whole } = attachment; + if (planned.deletedNodes.has(mergeKey(whole.kind, whole.id))) continue; + const wholeRow = await backend.getNode(ctx.graphId, whole.kind, whole.id); + if (wholeRow === undefined || !isLiveNodeRow(wholeRow)) continue; + moved.push({ + part, + whole: { kind: whole.kind, id: whole.id }, + viaEdgeKind: attachment.edge.kind, + cause: "moved", + }); + } + return moved; +} + +/** + * THE cross-branch composition finding: whether a branch's staged edge that the + * plan does not write because an endpoint is finally deleted — dropped by the + * repoint, or a re-windowed copy absorbed by the edge's own deletion — is a + * composition attachment the plan would silently lose, and if so which `cause` + * it is. + * + * One branch attached the part — moved it, created it under a whole, or + * re-attached it through a row ended at the merge base — while another + * branch's deletion took away one end of that attachment: + * + * - the part is deleted (directly, or by its old whole's cascade) and the + * whole the branch attached it to survives: `"moved"`, the multi-branch + * mirror of {@link compositionMovedPartsAmong}; `whole` is that survivor. + * - the whole is deleted and the part survives: `"deleted"`, the mirror of + * {@link compositionOrphansAmong}; `whole` is the deleted whole. + * + * Anything else is ordinary: an edge that does not realize a composition pair, + * a staged row that does not attach its part (the predicate every composition + * reader shares, {@link stagedRowAttachesForBranch}), a plan that deletes BOTH + * ends, and an INHERITED row that already attached at the merge base, whose + * loss is the ordinary cascade or an edge delete/modify case. The incremental + * target's own attachments are + * excluded too: {@link compositionOrphansAmong} and + * {@link compositionMovedPartsAmong} already judge those against the target's + * live rows, so one situation never gets two verdicts. + * + * `fromKey`/`toKey` are the post-repoint identities the drop was decided on, + * so the finding names the canonical nodes the plan actually deletes or keeps. + */ +function lostBranchAttachment( + registry: KindRegistry, + dropped: EndpointDeletedEdge, + deletedNodes: ReadonlySet, +): MergePlanCompositionOrphan | undefined { + const { staged } = dropped; + if (staged.branchId === COMMITTED_TARGET_BRANCH) return undefined; + const partSide = registry.compositionPartSide(staged.kind); + if (partSide === undefined) return undefined; + const sourcePartKind = partSide === "from" ? staged.fromKind : staged.toKind; + if (!stagedRowAttachesForBranch(registry, sourcePartKind, staged)) { + return undefined; + } + const [partKey, wholeKey] = + partSide === "from" ? + [dropped.fromKey, dropped.toKey] + : [dropped.toKey, dropped.fromKey]; + const partDeleted = deletedNodes.has(partKey); + if (partDeleted === deletedNodes.has(wholeKey)) return undefined; + return { + part: entityRef(partKey), + whole: entityRef(wholeKey), + viaEdgeKind: staged.kind, + cause: partDeleted ? "moved" : "deleted", + branchId: staged.branchId, + }; +} + +/** + * Whether the BRANCH is what makes this staged composition row attach its + * part: a row the branch created that attaches, or an inherited row that + * attaches in the staged copy but not at the merge base — a re-attachment + * through a row the base had already ended. An inherited row that attached + * at the base was not attached by the branch, whatever else it changed. + */ +function stagedRowAttachesForBranch( + registry: KindRegistry, + partKind: string, + staged: StagedEdge, +): boolean { + const attaches = (validTo: string | undefined): boolean => + liveCompositionEdgeAttaches(registry, partKind, staged.kind, validTo); + if (staged.origin === BRANCH_CREATED_EDGE_ORIGIN) { + return attaches(staged.validTo); + } + const stagedValidTo = + staged.clearValidTo === true ? + undefined + : (staged.validTo ?? staged.baseValidTo); + return attaches(stagedValidTo) && !attaches(staged.baseValidTo); +} + +/** + * Plan-time refusal of every {@link lostBranchAttachment}: throws + * `MergeCompositionOrphanError` for the first one — the repoint's drops in + * their (edge id, branch id) order, then the deletion-absorbed copies in + * staging order — so the refusal does not depend on the order the branches + * were passed in. + * + * Plan time, not apply time, because the conflict lives entirely in the + * branch diffs the plan froze: no target write can resolve it, so a later + * re-verification would only repeat this verdict. Every planning entry point + * builds its plan through `buildInternalMergePlan`, so all of them refuse + * here, ahead of the plan-time report — which is also why a required-existence + * part caught here is never additionally reported as `"unattached"`. + */ +function assertNoLostBranchAttachments( + registry: KindRegistry, + endpointDeletedEdges: readonly EndpointDeletedEdge[], + deletedNodes: ReadonlySet, +): void { + for (const dropped of endpointDeletedEdges) { + const finding = lostBranchAttachment(registry, dropped, deletedNodes); + if (finding !== undefined) { + throw new MergeCompositionOrphanError(finding); + } + } +} + +/** + * The plan-time composition-orphan REPORT: every finding from all three arms — + * {@link compositionOrphansAmong} (`cause: "deleted"`), + * {@link compositionMovedPartsAmong} (`cause: "moved"`) and + * {@link unattachedRequiredPartOrphansAmong} (`cause: "unattached"`) — for the + * plan's review payload. Every arm reads the target's current state through + * the plan's own {@link PlannedCompositionState}, so the report describes the + * graph the apply would leave, by the same functions the apply refuses with. + * + * Deliberately unlocked (`uncapturedGraphWriteLock`): plan time is a + * best-effort, racy dry-run read over the target's CURRENT state, not a + * write — there is no write transaction here to hold the per-graph write + * lock inside, and `assertPlanningFenceUnchanged` already re-verifies the + * broader plan-vs-target staleness question around this call. A concurrent + * attach this read races past is not a correctness gap: `applyGraphRows` + * re-runs these SAME checks, under a REAL lock, inside the apply transaction, + * and refuses with `MergeCompositionOrphanError` if any recurs there. + */ +async function planTimeCompositionOrphans( + target: Store, + writes: MergePlanWrites, +): Promise { + const ctx = { + graphId: target.graphId, + registry: target.registry, + lock: uncapturedGraphWriteLock(), + }; + const backend = storeBackend(target); + const planned = plannedCompositionState(target.registry, writes); + const deleted = await compositionOrphansAmong( + ctx, + backend, + writes.nodeDeletes, + planned, + ); + const moved = await compositionMovedPartsAmong( + ctx, + backend, + writes.nodeDeletes, + planned, + ); + const unattached = await unattachedRequiredPartOrphansAmong( + ctx, + backend, + await requiredExistenceOrphanCandidates(ctx, backend, writes), + batchedNodeReader(target, backend), + planned, + ); + return [...deleted, ...moved, ...unattached]; +} + +/** + * Apply's AUTHORITATIVE re-verification of the "deleted" and "moved" findings + * {@link planTimeCompositionOrphans} reports at plan time: run inside the + * apply transaction, under the per-graph write lock `lockRecordedGraphWrite` + * reentrantly confirms (or newly takes, on a target that had not yet needed + * it this transaction), so a concurrent attach cannot be missed. Throws + * `MergeCompositionOrphanError` on the first orphan found — before any row of + * the plan has been written, which is why it reads the write set as an + * overlay exactly as the plan-time report does. + */ +async function assertNoCompositionOrphans( + target: Store, + txBackend: TransactionBackend, + writes: MergePlanWrites, +): Promise { + const registry = target.registry; + if ( + !writes.nodeDeletes.some( + (deletion) => + registry.isCompositionWhole(deletion.kind) || + registry.isCompositionPart(deletion.kind), + ) + ) { + return; + } + const planned = plannedCompositionState(registry, writes); + await refuseFirstOrphan(target, txBackend, async (ctx) => [ + ...(await compositionOrphansAmong( + ctx, + txBackend, + writes.nodeDeletes, + planned, + )), + ...(await compositionMovedPartsAmong( + ctx, + txBackend, + writes.nodeDeletes, + planned, + )), + ]); +} + +/** + * The apply-time composition refusal both guards reduce to: take (or + * reentrantly confirm) the per-graph write lock, run `find` under it, and + * throw `MergeCompositionOrphanError` for the FIRST orphan it reports — one + * owner of which orphan a refusal names. + */ +async function refuseFirstOrphan( + target: Store, + txBackend: TransactionBackend, + find: ( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + }>, + ) => Promise, +): Promise { + const lock = await lockRecordedGraphWrite(txBackend, target.graphId); + const [firstOrphan] = await find({ + graphId: target.graphId, + registry: target.registry, + lock, + }); + if (firstOrphan !== undefined) { + throw new MergeCompositionOrphanError(firstOrphan); + } +} + +/** + * Every required-existence part this merge could newly orphan — the exact, + * bounded candidate set {@link unattachedRequiredPartOrphansAmong} scans, + * rather than a graph-wide `findNodesByKind` scan (the way + * `readCompositionUnattachedParts` audits a schema tightening). + * + * ONE walk over ONE view: `writes` is the plan's normalized write view + * ({@link resolvedMergeWrites}), the same shape the wire artifact carries, so + * the live-plan apply, the replayed-artifact apply (both through + * `applyGraphRows`) and the plan-time preview all derive their candidates + * here. A new source is added to this function and nowhere else. + * + * THREE sources, unioned (a part can be newly orphaned without its OWN row + * ever being rewritten — an edge-only change is enough): + * + * 1. Every node row this merge writes (`writes.nodeUpserts`) — a brand-new + * or re-canonicalized required-existence part. + * 2. Every required-existence endpoint of a composition edge THIS MERGE + * DELETES (`writes.edgeDeletes`) — an edge-level delete/modify conflict, + * or a repointed edge dropped for a finally-deleted endpoint, can sever + * a part's only attachment while the part's own row is never touched. + * Read from `backend` while the edge is STILL LIVE there — the apply + * resolves this list before it writes any row, then runs + * {@link unattachedRequiredPartOrphansAmong}'s actual CHECK only after the + * last one has landed (`applyGraphRows`). + * 3. Every required-existence endpoint of a composition edge in + * `writes.edgeUpserts` whose validity window THIS MERGE explicitly closes + * (`validTo` stated — reopening can only ATTACH, never orphan, so it needs + * no candidate) — a `population: "oneActive"` pair ended by this merge's + * own canonicalization, mirroring the write-path's own + * `assertCompositionExistencePreserved`. An edge whose window this merge + * does not mention at all is not what could newly close it, so it + * contributes no candidate. + * + * Only `registry.compositionExistence(kind) === "required"` kinds are worth + * tracking; candidates are deduped by identity. + */ +export async function requiredExistenceOrphanCandidates( + ctx: Readonly<{ graphId: string; registry: KindRegistry }>, + backend: GraphReadBackend, + writes: MergePlanWrites, +): Promise { + const candidates = new Map(); + const add = (kind: string, id: string): void => { + if (ctx.registry.compositionExistence(kind) !== "required") return; + candidates.set(mergeKey(kind, id), { kind, id }); + }; + const addPartSide = ( + edgeKind: string, + from: MergePlanEntityRef, + to: MergePlanEntityRef, + ): void => { + const partSide = ctx.registry.compositionPartSide(edgeKind); + if (partSide === undefined) return; + const { part } = orientCompositionEndpoints(partSide, from, to); + add(part.kind, part.id); + }; + + for (const upsert of writes.nodeUpserts) { + add(upsert.kind, upsert.id); + } + + for (const deletion of writes.edgeDeletes) { + if (!ctx.registry.isCompositionEdge(deletion.kind)) continue; + const edgeRow = await backend.getEdge(ctx.graphId, deletion.id); + if (edgeRow === undefined) continue; + addPartSide( + edgeRow.kind, + { kind: edgeRow.from_kind, id: edgeRow.from_id }, + { kind: edgeRow.to_kind, id: edgeRow.to_id }, + ); + } + + for (const upsert of writes.edgeUpserts) { + if (upsert.validTo === undefined) continue; + addPartSide(upsert.kind, upsert.from, upsert.to); + } + + return [...candidates.values()]; +} + +/** + * The declared composition pair a diagnostic names for a required-existence + * part with NO live whole at all — there is no attaching edge to resolve one + * from, so this picks the FIRST declared pair for the concrete kind + * (`CompositionRelation.pairs`' own code-point order). + * Presentational only: `ONTOLOGY_COMPOSITION_EXISTENCE_MIXED` already + * guarantees every pair a concrete part kind can match shares the same + * `existence`, so which ONE is named here never changes whether this is + * refused, only which `viaEdgeKind` the message credits. + */ +function firstCompositionPairFor( + registry: KindRegistry, + concretePartKind: string, +): Readonly<{ viaEdgeKind: string }> | undefined { + return registry + .compositionRelation() + .pairs.find((pair) => + registry.isAssignableTo(concretePartKind, pair.partKind), + ); +} + +/** + * THE "unattached" composition-orphan arm, `compositionOrphansAmong`'s + * sibling: reports every `candidate` that is live and resolves to no whole at + * all. A different question from `compositionOrphansAmong`'s ("would a planned + * DELETION orphan a part the plan neither deletes nor moves"), so it is its + * own function; both feed the identical `MergePlanCompositionOrphan` shape + * (distinguished only by `cause`) into the SAME + * `MergePlanReview.compositionOrphans` / `MergeCompositionOrphanError`. + * + * Reuses `findLiveCompositionWhole` — the SAME predicate the write-path + * detach refusal and `store.verifyConstraintFences()`'s `compositionExistence` + * family read — rather than a second, drift-prone spelling of "does this + * part have a live whole". + * + * `planned` is what separates the two callers, and the only thing that does. + * At APPLY time (`assertNoUnattachedRequiredParts`) every row of the write set + * has already landed in `backend`, so it is omitted and `backend` is the final + * state. At PLAN time nothing has been written, so the plan's own + * {@link PlannedCompositionState} stands in for those writes: a part the plan + * deletes is not a candidate, a part it creates is, an edge it detaches does + * not count, and an edge it writes does. + */ +async function unattachedRequiredPartOrphansAmong( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + }>, + backend: GraphReadBackend, + candidates: readonly MergePlanEntityRef[], + getNodes: BatchedNodeReader | undefined, + planned?: PlannedCompositionState, +): Promise { + const orphans: MergePlanCompositionOrphan[] = []; + for (const candidate of await liveOrphanCandidates( + ctx, + backend, + candidates, + getNodes, + planned, + )) { + const whole = await findLiveCompositionWhole( + ctx.registry, + backend, + ctx.graphId, + candidate.kind, + candidate.id, + planned?.detachedEdgeIds, + ); + if (whole !== undefined) continue; + if ( + planned !== undefined && + (await plannedEdgeUpsertAttachesPart(ctx, backend, candidate, planned)) + ) { + continue; + } + const pair = firstCompositionPairFor(ctx.registry, candidate.kind); + if (pair === undefined) continue; + orphans.push({ + part: candidate, + viaEdgeKind: pair.viaEdgeKind, + cause: "unattached", + }); + } + return orphans; +} + +/** The batched point read of node rows by kind, when a backend reaches one. */ +type BatchedNodeReader = ( + graphId: string, + kind: string, + ids: readonly string[], +) => Promise; + +/** + * The backend's batched point read, bound the way the interchange paths bind + * theirs: the verdict comes from the Store's own backend, while the binding is + * taken against the port the read will actually run on (a transaction target at + * apply time). `undefined` when the backend does not reach `getNodes`, which is + * the capability's declared `per-id getNode` fallback. + */ +function batchedNodeReader( + target: Store, + port: Readonly>>, +): BatchedNodeReader | undefined { + const bound = bindExtraIfReachable( + port, + batchPointReadVerdict(storeBackend(target)).extras.getNodes, + BATCH_POINT_READ.id, + ); + return bound?.getNodes; +} + +/** + * Narrows orphan candidates to the ones that are LIVE once the write set has + * landed: a live row on `backend`, or — under a not-yet-applied `planned` + * overlay — a row the plan upserts, and never one it deletes. A candidate + * that is not live is not an orphan: a retired part owes no whole. + * + * ONE batched read per kind when `getNodes` is reachable, the per-candidate + * point read otherwise. Candidates are every planned write of a + * required-existence kind, so the unbatched shape pays a round trip per part + * on the merge write path. + */ +async function liveOrphanCandidates( + ctx: Readonly<{ graphId: string }>, + backend: GraphReadBackend, + candidates: readonly MergePlanEntityRef[], + getNodes: BatchedNodeReader | undefined, + planned: PlannedCompositionState | undefined, +): Promise { + const liveKeys = new Set(planned?.upsertedNodes); + const unresolved = candidates.filter( + (candidate) => !liveKeys.has(mergeKey(candidate.kind, candidate.id)), + ); + if (getNodes === undefined) { + for (const candidate of unresolved) { + const row = await backend.getNode( + ctx.graphId, + candidate.kind, + candidate.id, + ); + if (row === undefined || row.deleted_at !== undefined) continue; + liveKeys.add(mergeKey(candidate.kind, candidate.id)); + } + } else { + for (const [kind, kindCandidates] of groupBy( + unresolved, + (candidate) => candidate.kind, + )) { + const ids = kindCandidates.map((candidate) => candidate.id); + for (const row of await getNodes(ctx.graphId, kind, ids)) { + if (row.deleted_at === undefined) liveKeys.add(mergeKey(kind, row.id)); + } + } + } + return candidates.filter((candidate) => { + const key = mergeKey(candidate.kind, candidate.id); + return liveKeys.has(key) && planned?.deletedNodes.has(key) !== true; + }); +} + +/** + * Apply's AUTHORITATIVE re-verification of + * {@link unattachedRequiredPartOrphansAmong}'s finding: run inside the apply + * transaction, under the per-graph write lock, AFTER the plan's last node and + * edge row has landed, so a required part's composition edge this merge just + * wrote (or just dropped or collapsed) is visible to the check. It is also + * the check the apply's deferred per-write existence rule falls due at + * (`applyGraphRows`). Throws `MergeCompositionOrphanError` on the first + * orphan found. + */ +async function assertNoUnattachedRequiredParts( + target: Store, + txBackend: TransactionBackend, + candidates: readonly MergePlanEntityRef[], +): Promise { + if (candidates.length === 0) return; + await refuseFirstOrphan(target, txBackend, (ctx) => + unattachedRequiredPartOrphansAmong( + ctx, + txBackend, + candidates, + batchedNodeReader(target, txBackend), + ), + ); +} + +async function resolvedMergeArtifact( + target: Store, + resolved: ResolvedMerge, + mode: "snapshot" | "incremental", + targetFence: MergePlanTargetFence, + anchors: MergePlanAnchors, +): Promise { + const { plan, options } = resolved; + const writes = resolvedMergeWrites(plan); + const compositionOrphans = await planTimeCompositionOrphans(target, writes); + const input: MergePlanArtifactV2Input = { + formatVersion: MERGE_PLAN_FORMAT_VERSION, mode, target: targetFence, anchors, proposed: { nodes: { - upserts: nodeUpserts.length, - deletions: plan.nodeDeletions.size, + upserts: writes.nodeUpserts.length, + deletions: writes.nodeDeletes.length, }, edges: { - upserts: edgeUpserts.length, - deletions: plan.edgeDeletions.size, + upserts: writes.edgeUpserts.length, + deletions: writes.edgeDeletes.length, }, identity: { - assertions: plan.identityAssertions.length, - retractions: plan.identityRetractions.length, + assertions: writes.identityAssertions.length, + retractions: writes.identityRetractions.length, }, }, - writes: { - nodeDeletes: [...plan.nodeDeletions].map(([identity, kind]) => - wireEntityRef(kind, idOf(identity)), - ), - nodeUpserts, - edgeDeletes: [...plan.edgeDeletions].map(([identity, kind]) => - wireEntityRef(kind, idOf(identity)), - ), - edgeUpserts, - identityAssertions: plan.identityAssertions, - identityRetractions: plan.identityRetractions, - }, + writes, guards: { canonicalMappings: [...plan.canonicalOf] .sort(([left], [right]) => compareMergeKeys(left, right)) .map(([member, canonical]) => ({ - member: wireEntityRef(kindOf(member), idOf(member)), - canonical: wireEntityRef(kindOf(canonical), idOf(canonical)), + member: entityRef(member), + canonical: entityRef(canonical), })), retypes: [...plan.retypeMap] .sort(([left], [right]) => compareMergeKeys(left, right)) .map(([entity, toKind]) => ({ - entity: wireEntityRef(kindOf(entity), idOf(entity)), + entity: entityRef(entity), toKind, })), - deletedNodes: [...plan.nodeDeletions].map(([identity, kind]) => - wireEntityRef(kind, idOf(identity)), - ), + deletedNodes: plannedNodeReleases(plan), ...(resolved.incrementalGuard === undefined ? {} : { @@ -2819,9 +4164,18 @@ async function resolvedMergeArtifact( baseAmbiguities: plan.baseAmbiguities, provenanceRecords: plan.provenanceRecords, warnings: plan.warnings, + compositionOrphans, ...(plan.candidateDiagnostics === undefined ? {} : { diagnostics: plan.candidateDiagnostics }), + // Optional and omitted when empty, so a merge the separation veto never + // touched produces the same review object it always has. + ...(plan.identityConflicts.length === 0 ? + {} + : { + identityConflicts: + plan.identityConflicts as unknown as readonly JsonValue[], + }), }, provenance: { includeInReport: options.provenance, @@ -2831,6 +4185,156 @@ async function resolvedMergeArtifact( return constructMergePlanArtifact(input); } +/** + * The resolved plan's edge writes, projected to the shape the shared + * acyclicity predicate reads: FINAL `(kind, id)` endpoints — through + * {@link finalEdgeEndpoint}, exactly as the commit itself resolves them + * (`applyInternalMergePlan`, `applyWireMergeWrites`) — and the window END each + * write leaves on its row, so the plan-time preview and the eventual write can + * never disagree about what the plan actually writes. A write that states an + * end (`validTo` or `clearValidTo`) leaves that one; a write that states none + * keeps the stored row's, which is read here — the same rule + * {@link plannedEdgeUpsertAttachesPart} applies — and a row the target does + * not hold yet is born open. The shared predicate then drops a row its + * relation does not count in that state (an ended composition membership). + * + * An edge whose kind is in no acyclic relation is dropped here already; + * {@link readProposedEdgeAcyclicityViolations} would drop it too, but + * filtering here keeps the mapped list — and the stored-row read — free of + * edges no relation cares about. + */ +async function resolvedPlanAcyclicityCandidates( + target: Store, + plan: MergePlan, +): Promise { + const relationEdges = plan.mergedEdges.filter((edge) => + edgeKindIsInAcyclicRelation(target.graph, target.registry, edge.kind), + ); + const keepsStoredEnd = (edge: MergedEdge): boolean => + !statesValidityEnd(edge); + const backend = storeBackend(target); + const storedRows = await getEdgeRowsByIds( + backend, + batchPointReadVerdict(backend), + target.graphId, + relationEdges.filter((edge) => keepsStoredEnd(edge)).map((edge) => edge.id), + ); + return relationEdges.map((edge) => { + const from = finalEdgeEndpoint(plan, edge.fromKind, edge.fromId); + const to = finalEdgeEndpoint(plan, edge.toKind, edge.toId); + return { + edgeId: edge.id, + edgeKind: edge.kind, + fromKind: from.kind, + fromId: from.id, + toKind: to.kind, + toId: to.id, + validTo: + keepsStoredEnd(edge) ? storedRows.get(edge.id)?.valid_to : edge.validTo, + }; + }); +} + +/** + * Builds the typed plan-time conflict from the first violated relation. A + * plan can violate several relations at once (for example when composition + * relations overlap), so every other violated relation is named in + * `details.additionalRelations` without complicating the primary message. + */ +function acyclicityMergeConflict( + violations: readonly EdgeAcyclicityViolation[], + proposedById: ReadonlyMap, +): AcyclicityMergeConflictError { + const [firstViolation, ...remaining] = violations; + if (firstViolation === undefined) { + throw new MergeError( + "acyclicityMergeConflict called with no violations — this is a defect in the caller, not a real conflict.", + ); + } + const edges = firstViolation.edgeIds.map((edgeId) => + requireDefined(proposedById.get(edgeId)), + ); + const describedEdges = edges + .map( + (edge) => + `${edge.edgeKind} ${edge.fromKind}:${edge.fromId} -> ${edge.toKind}:${edge.toId}`, + ) + .join(", "); + return new AcyclicityMergeConflictError( + `The merge plan would close a cycle in acyclic relation "${firstViolation.relation}": ${describedEdges}.`, + { + details: { + relation: firstViolation.relation, + edges, + ...(remaining.length === 0 ? + {} + : { + additionalRelations: remaining.map((violation) => ({ + relation: violation.relation, + edgeIds: violation.edgeIds, + })), + }), + }, + }, + ); +} + +/** + * Plan time: does the resolved plan's projected edge writes — layered + * onto the target's CURRENT live edges, less the edges the same plan deletes + * — close a cycle in a declared-acyclic relation. Apply removes those edges + * before it writes, so a reversal or re-rooting inside the relation is judged + * by the DAG it produces, not by rows that will be gone. Runs for every commit mode (`merge()`'s direct commit and + * `planMerge()`'s reviewable artifact both flow through `resolveMerge`, + * before either branches to its own `complete` callback), so a reviewer of + * either surface sees the SAME typed conflict instead of only discovering + * the cycle when the write is attempted. + * + * Read-only and lock-free (`readProposedEdgeAcyclicityViolations`): this is + * a PREVIEW, not the write gate. It runs before any per-graph write lock + * exists (planning does no write) and is inherently racy against a + * concurrent writer of the same relation, which is fine — the actual commit + * re-verifies under the per-graph write lock regardless (the existing + * write-path fence, unchanged by this check), and stays + * the sole authority. This function only turns an otherwise-silent future + * refusal into an up-front, reviewable conflict. + */ +async function assertResolvedPlanEdgesAcyclic( + target: Store, + plan: MergePlan, +): Promise { + if (acyclicEdgeRelations(target.graph, target.registry).length === 0) return; + const proposed = await resolvedPlanAcyclicityCandidates(target, plan); + if (proposed.length === 0) return; + + const backend = storeBackend(target); + const violations = await readProposedEdgeAcyclicityViolations( + { + graphId: target.graphId, + registry: target.registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + operation: "mergePlanAcyclicity", + }, + target.graph, + proposed, + // Every stored row the plan rewrites is replaced by its proposed state + // (or dropped, when that state is no member), and every row it deletes is + // gone: the walk judges the relation the plan produces. + [ + ...[...plan.edgeDeletions.keys()].map((identity) => idOf(identity)), + ...proposed.map((edge) => edge.edgeId), + ], + ); + if (violations.length === 0) return; + + const proposedById = new Map( + proposed.map((edge) => [edge.edgeId, edge] as const), + ); + throw acyclicityMergeConflict(violations, proposedById); +} + async function resolveMerge( store: Store, target: Store, @@ -2872,6 +4376,12 @@ async function resolveMerge( } } + const resolveKindRefusal = undeclaredResolveKindRefusal( + target.graph, + options.resolve, + ); + if (resolveKindRefusal !== undefined) return err(resolveKindRefusal); + // A branch must be a DATA fork. A schema operation on the branch's store // after forking (evolve, migrateSchema, removeKinds) mutates rows through // its own preflights, and the state diff would project those side effects @@ -2895,12 +4405,14 @@ async function resolveMerge( anchor === undefined ? branchSchemaRow !== undefined : branchSchemaRow?.version !== anchor.version || - branchSchemaRow.schema_hash !== anchor.hash; + (await activeSchemaHash(branchSchemaRow)) !== anchor.hash; } else { const sourceSchemaRow = await storeBackend(store).getActiveSchema( store.graphId, ); - drifted = branchSchemaRow?.schema_hash !== sourceSchemaRow?.schema_hash; + drifted = + (await optionalActiveSchemaHash(branchSchemaRow)) !== + (await optionalActiveSchemaHash(sourceSchemaRow)); } if (drifted) { return err( @@ -2910,7 +4422,7 @@ async function resolveMerge( details: { branchId: branch.id, branchSchemaVersion: branchSchemaRow?.version, - branchSchemaHash: branchSchemaRow?.schema_hash, + branchSchemaHash: await optionalActiveSchemaHash(branchSchemaRow), anchor: "schemaAnchor" in branch ? branch.schemaAnchor : "absent", }, }, @@ -2959,7 +4471,15 @@ async function resolveMerge( for (const kind of introspection.kinds) { introspectionKinds.set(kind.name, kind.unique); } - const subClassClosure = buildSubClassClosure(introspection.ontology); + // The registry is built from the same `graph.ontology` `introspect()` + // projects onto `{ metaEdge, from, to }` pairs (store/introspect.ts), and + // unlike that raw projection it has already passed + // `validateOntologyRelations` — including the equivalence-class refusal. + // `store`, not `target`: on the incremental path `store` is the fork + // point, which is the ontology source type reconciliation has always + // used, and the schema-drift guard above already refuses a branch whose + // schema differs. + const registry = store.registry; // (3) candidate generation across every resolved kind. When an embedder is // configured, precompute the STAGED texts' vectors ONCE (a batched step) so @@ -2992,6 +4512,34 @@ async function resolveMerge( if (isErr(candidates)) { return err(candidates.error); } + const baseMembers = candidates.data.baseMembers; + + // The separation veto is ON for every identity-enabled merge: a + // `different` assertion is an integrity fact, not a recall heuristic — + // whether the target already holds it or a merged branch carries it. The + // facts are captured ONCE here, before planning, so the candidate-edge + // veto below and the post-cluster transitive assertion inside + // `buildInternalMergePlan` read one fact set and cannot disagree — and so + // plan construction stays synchronous. + const separationFacts = + target.graph.identity === undefined ? + NO_IDENTITY_SEPARATION_FACTS + : await captureIdentitySeparationFacts( + target, + identityFusionGroups( + candidates.data.edges, + [ + ...new Set([ + ...[...stagedNewByKind].flatMap(([kind, entries]) => + entries.map((entry) => mergeKey(kind, entry.node.id)), + ), + ...baseMembers.map((member) => mergeKeyOf(member)), + ]), + ], + options.reconcileTypes === "ontology", + ), + staging.newIdentityAssertions.map((staged) => staged.assertion), + ); // Same-id folding joins nodes no assertion names, so the plan-time // contradiction simulation needs the LIVE target peers sharing any staged @@ -3009,7 +4557,7 @@ async function resolveMerge( entries.map((entry) => entry.node.id), ), ...staging.modifiedNodes.map((entry) => entry.node.id), - ...candidates.data.baseMembers.map((member) => member.id), + ...baseMembers.map((member) => member.id), ...staging.newIdentityAssertions.flatMap((staged) => [ staged.assertion.a.id, staged.assertion.b.id, @@ -3052,20 +4600,32 @@ async function resolveMerge( }; // (4–8) resolve the whole merge into a commit-ready plan. - const plan = buildInternalMergePlan( - staging, + const veto = applyIdentitySeparationVeto( candidates.data.edges, - candidates.data.warnings, - candidates.data.diagnostics, - candidates.data.diagnosticsTotal, - candidates.data.baseMembers, + separationFacts, + ); + const plan = buildInternalMergePlan({ + staging, + candidateEdges: veto.edges, + candidateWarnings: candidates.data.warnings, + candidateDiagnostics: candidates.data.diagnostics, + candidateDiagnosticsTotal: candidates.data.diagnosticsTotal, + baseMembers, options, branchRank, - subClassClosure, - identityContext, + registry, storedIdentityRowsById, - targetPeers, + separationFacts, + separationConflicts: veto.conflicts, + vetoedEdges: veto.vetoedEdges, preferredBranchId, + }); + await assertPlannedIdentityClosureConsistent( + target, + staging, + plan, + identityContext, + targetPeers, ); // The commit guard's baseline must be a VALIDATED state: re-probe the @@ -3091,6 +4651,13 @@ async function resolveMerge( ); } + // Plan time: does the resolved plan's edge writes — after + // canonicalization and repointing — close a cycle in a declared-acyclic + // relation. Runs for BOTH commit modes, exactly like the one-id-one-truth + // check above: a typed conflict here, before either callback runs, is + // what lets a `planMerge()` review see the cycle without ever writing. + await assertResolvedPlanEdgesAcyclic(target, plan); + let identityGuard: IdentityPeerProbe | undefined; if (identityProbeIds !== undefined && incremental !== undefined) { const freshPeers = @@ -3108,7 +4675,7 @@ async function resolveMerge( const canonicalIdentityOf = plan.canonicalOf; assertNoContradictoryIdentityClosure( plan.identityAssertions, - plan.identityRetractions.map((retraction) => retraction.id), + plan.identityRetractions, // The FRESH target ledger, not the pre-planning staging capture — // a `different` committed in the window must invalidate the plan // here, as a typed conflict, before it can become the baseline. @@ -3119,7 +4686,15 @@ async function resolveMerge( // its assertions and SPLITS the class, so pre-linking the filtered // remainder would falsely reject a plan that deletes the bridge and // asserts the ends different. - built.ledger, + [ + ...built.ledger, + ...( + await loadPlannedIdentityHistory(target, plan.identityAssertions) + ).filter( + (assertion) => + !built.ledger.some((current) => current.id === assertion.id), + ), + ], new Set(plan.nodeDeletions.keys()), canonicalIdentityOf, plan.retypeMap, @@ -3149,7 +4724,7 @@ async function resolveMerge( {} : { identityPeerProbe: identityGuard }), plannedBaseMatchKeys: new Set( - candidates.data.baseMembers.map((member) => mergeKeyOf(member)), + baseMembers.map((member) => mergeKeyOf(member)), ), targetNodeVersions: inheritedBaselines?.nodes ?? staging.targetNodeVersions, @@ -3163,6 +4738,10 @@ async function resolveMerge( target, plan, options, + branchAncestry: branchAncestryOf( + store.graphId, + branchesInAnchorOrder(branches).map((branch) => branch.id as string), + ), ...(expectedBaseVersion === undefined ? {} : { expectedBaseVersion }), ...(incrementalGuard === undefined ? {} : { incrementalGuard }), }), @@ -3196,10 +4775,20 @@ async function commitResolvedMerge( if (provenanceStore !== undefined && isErr(provenanceStore)) { throw provenanceStore.error; } + // An unreviewed `merge()` has no plan artifact and therefore no digest, but + // it still explains itself: the branch ancestry it combined. + const decision = mergeIdentityDecision({ + branchAncestry: resolved.branchAncestry, + }); const merged = resolved.incrementalGuard === undefined ? - await commitPlan(target, plan, resolved.expectedBaseVersion) - : await commitIncrementalPlan(target, plan, resolved.incrementalGuard); + await commitPlan(target, plan, resolved.expectedBaseVersion, decision) + : await commitIncrementalPlan( + target, + plan, + resolved.incrementalGuard, + decision, + ); const provenance: ProvenanceIndex = options.provenance ? @@ -3241,6 +4830,7 @@ async function commitResolvedMerge( {} : { candidateDiagnostics: plan.candidateDiagnostics }), ...(provenancePersisted === undefined ? {} : { provenancePersisted }), + identityConflicts: plan.identityConflicts, }; } @@ -3266,6 +4856,90 @@ function incrementalSchemaError(): MergeError { ); } +/** + * The plan-time contradiction verdict over the ledger the merge would leave + * behind: the base's current rows plus the target's own history — the window + * ledger the commit's identity importer validates against, read through the + * one shared loader with the importer's own requests — so a claim that + * contradicts target history is refused here, typed and before any write, + * instead of by the importer inside the commit. + */ +async function assertPlannedIdentityClosureConsistent( + target: Store, + staging: StagingSet, + plan: MergePlan, + identityContext: PlanIdentityContext, + targetPeers: readonly Readonly<{ kind: string; id: string }>[], +): Promise { + const history = await loadPlannedIdentityHistory( + target, + plan.identityAssertions, + ); + const baseIds = new Set( + staging.baseIdentityAssertions.map((assertion) => assertion.id), + ); + assertNoContradictoryIdentityClosure( + plan.identityAssertions, + plan.identityRetractions, + [ + ...staging.baseIdentityAssertions, + ...history.filter((assertion) => !baseIds.has(assertion.id)), + ], + new Set(plan.nodeDeletions.keys()), + plan.canonicalOf, + plan.retypeMap, + identityContext, + identityNodeUniverse(plan, targetPeers), + ); +} + +/** + * Every node the committed target holds, which the identity simulation treats + * as present at every coordinate. Each canonical entity enters under the kind + * the commit will actually WRITE — the retyped one when the ontology cascade + * retypes it — while its deletion check runs against the source identity, + * which is how `nodeDeletions` is keyed. + */ +function identityNodeUniverse( + plan: MergePlan, + targetPeers: readonly Readonly<{ kind: string; id: string }>[], +): readonly Readonly<{ kind: string; id: string }>[] { + return [ + ...plan.canonicalEntities + .filter( + (entity) => + !plan.nodeDeletions.has(mergeKey(entity.kind, entity.canonicalId)), + ) + .map((entity) => ({ + kind: + plan.retypeMap.get(mergeKey(entity.kind, entity.canonicalId)) ?? + entity.kind, + id: entity.canonicalId, + })), + ...targetPeers.filter( + (node) => !plan.nodeDeletions.has(mergeKey(node.kind, node.id)), + ), + ]; +} + +async function loadPlannedIdentityHistory( + target: Store, + assertions: readonly IdentityTransferAssertion[], +): Promise { + if (target.graph.identity === undefined || assertions.length === 0) return []; + const ctx = storeRuntime(target).identityContext(); + try { + return await loadIdentityWindowHistory( + ctx, + ctx.backend, + assertions, + nowIso(), + ); + } catch (error) { + throw translateIdentityPlanError(error); + } +} + /** Builds a durable, reviewable snapshot merge plan without mutating the target. */ export async function planMerge( store: Store, @@ -3297,12 +4971,10 @@ export async function planMerge( const anchors: MergePlanAnchors = { kind: "snapshot", base: { graphId: store.graphId, baseVersion: precondition.data }, - branches: [...branches] - .sort((left, right) => compareStrings(left.id, right.id)) - .map((branch) => ({ - branchId: branch.id, - baseVersion: branch.base, - })), + branches: branchesInAnchorOrder(branches).map((branch) => ({ + branchId: branch.id, + baseVersion: branch.base, + })), }; return resolveMerge( store, @@ -3314,7 +4986,13 @@ export async function planMerge( precondition.data, async (resolved) => { await assertPlanningFenceUnchanged(target, targetFence); - return resolvedMergeArtifact(resolved, "snapshot", targetFence, anchors); + return resolvedMergeArtifact( + target, + resolved, + "snapshot", + targetFence, + anchors, + ); }, ); } @@ -3375,9 +5053,10 @@ export async function planMergeForEvolution( const anchors: MergePlanAnchors = { kind: "snapshot", base: { graphId: store.graphId, baseVersion: precondition.data }, - branches: [...branches] - .sort((left, right) => compareStrings(left.id, right.id)) - .map((branch) => ({ branchId: branch.id, baseVersion: branch.base })), + branches: branchesInAnchorOrder(branches).map((branch) => ({ + branchId: branch.id, + baseVersion: branch.base, + })), }; return resolveMerge( target, @@ -3389,7 +5068,10 @@ export async function planMergeForEvolution( precondition.data, async (resolved) => { await assertPlanningFenceUnchanged(store, baselineFence); + // The evolved candidate, not the baseline: composition orphans are + // judged under the registry the evolution commits, as apply will. return resolvedMergeArtifact( + target, resolved, "snapshot", resultingFence, @@ -3488,12 +5170,10 @@ export async function planMergeIncrementalForEvolution( baseVersion: forkVersion, schema: resultingFence.schema, }, - branches: [...branches] - .sort((left, right) => compareStrings(left.id, right.id)) - .map((branch) => ({ - branchId: branch.id, - baseVersion: forkVersion, - })), + branches: branchesInAnchorOrder(branches).map((branch) => ({ + branchId: branch.id, + baseVersion: forkVersion, + })), }; return resolveMerge( target, @@ -3508,7 +5188,10 @@ export async function planMergeIncrementalForEvolution( undefined, async (resolved) => { await assertPlanningFenceUnchanged(store, baselineFence); + // The evolved candidate, not the baseline: composition orphans are + // judged under the registry the evolution commits, as apply will. return resolvedMergeArtifact( + target, resolved, "incremental", resultingFence, @@ -3615,17 +5298,19 @@ async function planMergeIncrementalWithPruning( schema: { managed: forkActiveSchema !== undefined, version: forkActiveSchema?.version ?? 1, - hash: forkActiveSchema?.schema_hash ?? forkSchema, + hash: + forkActiveSchema === undefined ? forkSchema : ( + await activeSchemaHash(forkActiveSchema) + ), }, }, // `validateForkPointVersions` established exact equality for every - // branch; the common fork version is the review anchor. - branches: [...branches] - .sort((left, right) => compareStrings(left.id, right.id)) - .map((branch) => ({ - branchId: branch.id, - baseVersion: forkVersion, - })), + // branch; the common fork version is the review anchor. Order is anchor + // order so identity ancestry follows the plan's branches. + branches: branchesInAnchorOrder(branches).map((branch) => ({ + branchId: branch.id, + baseVersion: forkVersion, + })), }; return resolveMerge( forkStore, @@ -3642,6 +5327,7 @@ async function planMergeIncrementalWithPruning( await assertPlanningFenceUnchanged(target, targetFence); await assertForkPointUnchanged(prepared.data.precondition); return resolvedMergeArtifact( + target, resolved, "incremental", targetFence, @@ -3720,7 +5406,7 @@ async function preflightWireMergeWrites( target: Store, nodesApi: TxNodes, edgesApi: TxEdges, - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, ): Promise { const graphNodes = target.graph.nodes as Record; const graphEdges = target.graph.edges as Record< @@ -4000,7 +5686,7 @@ async function preflightWireMergeWrites( async function assertMergePlanFenceInsideTransaction( target: Store, txBackend: TransactionBackend, - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, requireFreshSnapshot: boolean, ): Promise { await lockMergeTargetWrite(txBackend, { @@ -4021,7 +5707,10 @@ async function assertMergePlanFenceInsideTransaction( const liveSchema = { managed: activeSchema !== undefined, version: activeSchema?.version ?? 1, - hash: activeSchema?.schema_hash ?? (await computeSchemaComponent(target)), + hash: + activeSchema === undefined ? + await computeSchemaComponent(target) + : await activeSchemaHash(activeSchema), }; if ( liveSchema.managed !== artifact.target.schema.managed || @@ -4069,7 +5758,7 @@ async function assertMergePlanFenceInsideTransaction( } function identityConsistencySeeds( - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, profile: "fold" | "ignore" | undefined, ): readonly MergePlanEntityRef[] { const deleted = new Set( @@ -4102,49 +5791,58 @@ async function applyWireMergeWrites( nodesApi: TxNodes, edgesApi: TxEdges, txBackend: TransactionBackend, - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, + deleteNodeWithPolicy: TransactionDeleteNodeWithPolicy, + decision: IdentityDecisionProvenance | undefined, ): Promise { - const committedNodes = await applyNodeRows( + const identityAssertions = artifact.writes + .identityAssertions as readonly IdentityTransferAssertion[]; + const identityRetractions = artifact.writes + .identityRetractions as readonly IdentityTransferAssertion[]; + const rows = await applyGraphRows( target, txBackend, nodesApi, - artifact.writes.nodeDeletes, - artifact.writes.nodeUpserts.map((upsert) => ({ - kind: upsert.kind, - id: upsert.id, - props: wireWriteProps(upsert.setProps, upsert.unsetProps), - ...(upsert.validFrom === undefined ? - {} - : { validFrom: upsert.validFrom }), - ...(upsert.validTo === undefined ? {} : { validTo: upsert.validTo }), - })), - ); - const committedEdges = await applyEdgeRows( edgesApi, - artifact.writes.edgeDeletes, - artifact.writes.edgeUpserts.map((upsert) => ({ - kind: upsert.kind, - item: { - id: upsert.id, - from: upsert.from, - to: upsert.to, - props: wireWriteProps(upsert.setProps, upsert.unsetProps), - ...(upsert.validFrom === undefined ? - {} - : { validFrom: upsert.validFrom }), - ...(upsert.validTo === undefined ? {} : { validTo: upsert.validTo }), + { + writes: artifact.writes, + nodePhase: { + deletions: artifact.writes.nodeDeletes, + upserts: artifact.writes.nodeUpserts.map((upsert) => ({ + kind: upsert.kind, + id: upsert.id, + props: wireWriteProps(upsert.setProps, upsert.unsetProps), + ...(upsert.validFrom === undefined ? + {} + : { validFrom: upsert.validFrom }), + ...validityEndMutationOf(upsert), + })), + identityRetractions, }, - })), + edgeUpserts: artifact.writes.edgeUpserts.map((upsert) => ({ + kind: upsert.kind, + item: { + id: upsert.id, + from: upsert.from, + to: upsert.to, + props: wireWriteProps(upsert.setProps, upsert.unsetProps), + ...(upsert.validFrom === undefined ? + {} + : { validFrom: upsert.validFrom }), + ...validityEndMutationOf(upsert), + }, + })), + }, + deleteNodeWithPolicy, + decision, ); - const identityAssertions = artifact.writes - .identityAssertions as readonly IdentityTransferAssertion[]; - const identityRetractions = artifact.writes - .identityRetractions as readonly IdentityTransferAssertion[]; + const appliedIdentity = await applyIdentityRows( target, txBackend, identityAssertions, identityRetractions, + decision, () => storeRuntime(target).assertIdentityClassesConsistentAtTarget( txBackend, @@ -4166,17 +5864,17 @@ async function applyWireMergeWrites( ); } return { - nodes: committedNodes, - edges: committedEdges, + nodes: rows.nodes, + edges: rows.edges, identity: { asserted: appliedIdentity.asserted, - retracted: appliedIdentity.retracted, + retracted: rows.retracted + appliedIdentity.retracted, }, }; } function reportFromArtifact( - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, merged: MergedCounts, warnings: readonly string[], provenancePersisted?: MergeReport["provenancePersisted"], @@ -4210,6 +5908,8 @@ function reportFromArtifact( .diagnostics as unknown as CandidateDiagnostics, }), ...(provenancePersisted === undefined ? {} : { provenancePersisted }), + identityConflicts: (artifact.review.identityConflicts ?? + []) as unknown as readonly IdentityUnresolvedConflict[], }; } @@ -4217,7 +5917,7 @@ function reportFromArtifact( export async function validateMergePlanForTarget( target: Store, input: MergePlanArtifact, -): Promise { +): Promise { let validation: Awaited>; try { validation = await validateMergePlanArtifact(input); @@ -4246,9 +5946,10 @@ export async function validateMergePlanForTarget( async function applyValidatedMergePlanInTransaction( target: Store, tx: TransactionContext, - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, options: MergePlanApplyOptions, requireFreshSnapshot: boolean, + decision: IdentityDecisionProvenance | undefined, ): Promise { const { beforeApply, afterApply } = options; const txBackend = transactionBackend(tx); @@ -4280,6 +5981,8 @@ async function applyValidatedMergePlanInTransaction( tx.edges as unknown as TxEdges, txBackend, artifact, + (work, policy) => transactionDeleteNodeWithPolicy(tx, work, policy), + decision, ); if (afterApply !== undefined) { assertMergeCallbackResult( @@ -4296,9 +5999,16 @@ export async function applyMergePlan( input: MergePlanArtifact, options: MergePlanApplyOptions> = {}, ): Promise, MergeError>> { - let artifact: MergePlanArtifactV1; + let artifact: MergePlanArtifactV2; + let decision: IdentityDecisionProvenance; try { + assertOnlyDeclaredApplyOptions(options, MERGE_PLAN_APPLY_OPTION_KEYS); artifact = await validateMergePlanForTarget(target, input); + // Built from evidence already in hand — the artifact's own digest, the + // anchors it names, and the review revalidation that names this plan. + // Nothing here is read back or recomputed, and a field the apply cannot + // evidence stays absent. + decision = mergeIdentityDecisionFromArtifact(artifact, options); } catch (error) { return err( error instanceof MergeError ? error : ( @@ -4339,6 +6049,7 @@ export async function applyMergePlan( artifact, options, composed, + decision, ), transactionOptions, ), @@ -4395,13 +6106,19 @@ export async function applyMergePlan( * rejects rather than accidentally committing partial work. Plans requesting * persisted provenance are refused because the sidecar cannot yet be enlisted in * this caller-owned transaction. + * + * `options` carries the same decision provenance `applyMergePlan` records — the + * compatible review revalidation that names this plan, and the ingestion + * source — so a plan applied here records the same identity decision. */ export async function applyMergePlanInTransaction( target: Store, tx: TransactionContext>, input: MergePlanArtifact, + options: MergePlanDecisionOptions = {}, ): Promise> { try { + assertOnlyDeclaredApplyOptions(options, MERGE_PLAN_DECISION_OPTION_KEYS); let effectiveTarget: Store; try { effectiveTarget = resolveEvolvedTransactionStore(tx, target); @@ -4425,12 +6142,14 @@ export async function applyMergePlanInTransaction( } const txBackend = transactionBackend(tx); await assertMergeTransactionPristine(effectiveTarget, txBackend); + const decision = mergeIdentityDecisionFromArtifact(artifact, options); const merged = await applyValidatedMergePlanInTransaction( effectiveTarget, tx, artifact, {}, true, + decision, ); return reportFromArtifact(artifact, merged, artifact.review.warnings); } catch (error) { @@ -5585,6 +7304,7 @@ async function commitIncrementalPlan( target: Store, plan: MergePlan, guard: IncrementalCommitGuard, + decision?: IdentityDecisionProvenance, ): Promise { if (!storeBackend(target).capabilities.execution.interactiveTransactions) { throw new MergeError( @@ -5668,6 +7388,8 @@ async function commitIncrementalPlan( edgesApi, target, transactionBackend(tx), + (work, policy) => transactionDeleteNodeWithPolicy(tx, work, policy), + decision, ); }, mergeCommitTransactionOptions(target)), ), diff --git a/packages/typegraph/src/graph-merge/namespace-fork.ts b/packages/typegraph/src/graph-merge/namespace-fork.ts index 5c6dfcc22..9e9bd69ea 100644 --- a/packages/typegraph/src/graph-merge/namespace-fork.ts +++ b/packages/typegraph/src/graph-merge/namespace-fork.ts @@ -6,6 +6,7 @@ import { backendDerivationRoot } from "../backend/derive-backend"; import { defaultPostgresTableNames } from "../backend/drizzle/schema/postgres-table-names"; import { + type GraphRelationKey, graphRelationsProvisionedBy, resolveGraphRelationNames, } from "../backend/graph-relations"; @@ -81,6 +82,45 @@ function forkRelationTables(backend: GraphBackend): readonly string[] { } const FORK_LEDGER = "typegraph_namespace_fork_operations"; +/** + * The relations a fork's content digest names whether or not they hold rows: + * the graph-scoped inventory as the first release that recorded a fork proof + * knew it. FROZEN. A recorded proof hashed one entry per relation here, so + * adding to or removing from this list changes the digest of every proof + * already in a ledger. + * + * A relation that joined the inventory later is named in the digest only when + * it holds rows for the graph ({@link forkContentDigest}). A fork taken before + * it existed holds none, so its recorded digest keeps verifying across the + * upgrade that adds the relation, and the relation is covered from the first + * row it holds. + */ +const DIGEST_BASELINE_RELATION_KEYS = [ + "nodes", + "edges", + "recordedNodes", + "recordedEdges", + "recordedClock", + "revisionOrigins", + "revisionChanges", + "identityAssertions", + "recordedIdentityAssertions", + "identityClosure", + "identitySeparation", + "uniques", + "edgeClaims", + "schemaVersions", + "fulltext", + "indexMaterializations", + "contributionMaterializations", + "kindRemovals", + "reconciliationMarkers", +] as const satisfies readonly GraphRelationKey[]; + +const DIGEST_BASELINE_TABLES: ReadonlySet = new Set( + DIGEST_BASELINE_RELATION_KEYS.map((key) => FORK_RELATION_NAMES[key]), +); + type QuerySession = Pick; type JsonRow = Readonly>; type RowEnvelope = Readonly<{ row: JsonRow }>; @@ -208,6 +248,25 @@ async function presentRelations( return new Set(rows.map((row) => row.name)); } +/** + * `relationTables` as `session`'s database provisions them. Every baseline + * relation is required and kept as stated, so a missing one still fails + * loudly; a relation that joined the inventory later is kept only where it + * exists, since a target prepared before it did holds no rows in it. + */ +async function provisionedRelationTables( + session: QuerySession, + relationTables: readonly string[], +): Promise { + const present = await presentRelations( + session, + relationTables.filter((table) => !DIGEST_BASELINE_TABLES.has(table)), + ); + return relationTables.filter( + (table) => DIGEST_BASELINE_TABLES.has(table) || present.has(table), + ); +} + /** A relation that does not exist yet holds no rows for any graph. */ async function vectorGraphRows( session: QuerySession, @@ -271,26 +330,69 @@ async function digestRows(rows: readonly JsonRow[]): Promise { ); } -async function digestGraph( - session: QuerySession, - graph: GraphDef, - relationTables: readonly string[], +/** One relation's rows for the forked graph, as the content digest reads them. */ +type DigestedRelation = readonly [table: string, rows: readonly JsonRow[]]; + +/** + * THE content digest of a forked graph: one `[table, rowDigest]` entry per + * graph-scoped relation in copy order, then one per vector table. The copy + * (hashing the source snapshot) and every verification (hashing the target) + * assemble it here, so they cannot disagree about which relations it names. + * + * A graph-scoped relation outside {@link DIGEST_BASELINE_RELATION_KEYS} is + * left out while it holds no rows; see that constant for why. + */ +async function forkContentDigest( + relations: readonly DigestedRelation[], + vectors: readonly DigestedRelation[], ): Promise { const digests: [string, string][] = []; - for (const table of relationTables) { - const rows = await graphRows(session, table, graph.id); - digests.push([table, await digestRows(forkedRows(graph, table, rows))]); + for (const [table, rows] of relations) { + if (rows.length === 0 && !DIGEST_BASELINE_TABLES.has(table)) continue; + digests.push([table, await digestRows(rows)]); } - for (const [table, rows] of await vectorGraphRows( - session, - vectorRelations(graph), - graph.id, - )) { + for (const [table, rows] of vectors) { digests.push([table, await digestRows(rows)]); } return sha256Hex(JSON.stringify(digests), 32); } +/** + * The graph-scoped relations' forked rows on `session`, in copy order. A + * relation that joined the inventory after the digest baseline may not exist + * on a target prepared before it did; it then holds no rows. + */ +async function forkedGraphRows( + session: QuerySession, + graph: GraphDef, + relationTables: readonly string[], +): Promise { + const provisioned = new Set( + await provisionedRelationTables(session, relationTables), + ); + const relations: DigestedRelation[] = []; + for (const table of relationTables) { + relations.push([ + table, + provisioned.has(table) ? + forkedRows(graph, table, await graphRows(session, table, graph.id)) + : [], + ]); + } + return relations; +} + +async function digestGraph( + session: QuerySession, + graph: GraphDef, + relationTables: readonly string[], +): Promise { + return forkContentDigest( + await forkedGraphRows(session, graph, relationTables), + await vectorGraphRows(session, vectorRelations(graph), graph.id), + ); +} + /** * The index a materialization row records as built, resolved against the * graph's declarations. Rows for failed or in-flight builds are skipped: the @@ -449,7 +551,10 @@ async function assertEmpty( session, vectorRelations(graph), ); - for (const table of [...relationTables, ...presentVectorTables]) { + for (const table of [ + ...(await provisionedRelationTables(session, relationTables)), + ...presentVectorTables, + ]) { const rows = await queryRows( session, sql`SELECT EXISTS(SELECT 1 FROM ${sql.identifier(table)} WHERE graph_id = ${graph.id}) AS present`, @@ -581,7 +686,10 @@ function namespaceForkResult( ); } const journal = REVISION_CHANGES_TABLE; - for (const table of relationTables) { + for (const table of await provisionedRelationTables( + targetTx, + relationTables, + )) { if (table === journal) continue; await queryRows( targetTx, @@ -827,7 +935,7 @@ export async function forkGraphNamespace( await assertIndependentDatabase(sourceTx, targetBackend, source.graphId); return targetBackend.transaction(async (targetTx) => { await assertEmpty(targetTx, source.graph, relationTables); - const sourceDigests: [string, string][] = []; + const copiedRows = new Map(); for (const table of relationTables) { if (table === REVISION_CHANGES_TABLE) continue; const rows = forkedRows( @@ -839,7 +947,7 @@ export async function forkGraphNamespace( await assertSupportedContributions(targetTx, source.graph, rows); if (table === INDEX_MATERIALIZATIONS_TABLE) await assertPhysicalIndexes(targetTx, source.graph, rows); - sourceDigests.push([table, await digestRows(rows)]); + copiedRows.set(table, rows); await insertRows(targetTx, table, rows); } // The destination triggers may have journaled the copied node, edge, @@ -854,23 +962,19 @@ export async function forkGraphNamespace( REVISION_CHANGES_TABLE, source.graphId, ); - sourceDigests.splice( - relationTables.indexOf(REVISION_CHANGES_TABLE), - 0, - [REVISION_CHANGES_TABLE, await digestRows(sourceJournalRows)], - ); + copiedRows.set(REVISION_CHANGES_TABLE, sourceJournalRows); await insertRows(targetTx, REVISION_CHANGES_TABLE, sourceJournalRows); - for (const [table, rows] of await vectorGraphRows( + const copiedVectors = await vectorGraphRows( sourceTx, vectorRelations(source.graph), source.graphId, - )) { - sourceDigests.push([table, await digestRows(rows)]); + ); + for (const [table, rows] of copiedVectors) { await insertRows(targetTx, table, rows); } - const snapshotDigest = await sha256Hex( - JSON.stringify(sourceDigests), - 32, + const snapshotDigest = await forkContentDigest( + relationTables.map((table) => [table, copiedRows.get(table) ?? []]), + copiedVectors, ); const targetDigest = await digestGraph( targetTx, diff --git a/packages/typegraph/src/graph-merge/options.ts b/packages/typegraph/src/graph-merge/options.ts index e20a2a666..21390cf43 100644 --- a/packages/typegraph/src/graph-merge/options.ts +++ b/packages/typegraph/src/graph-merge/options.ts @@ -14,7 +14,8 @@ import { z } from "zod"; -import { createDataKeyedBag } from "../utils/object"; +import { createDataKeyedBag, isPlainObject } from "../utils/object"; +import { InvalidMergeOptionsError } from "./errors"; import type { GraphDef } from "./typegraph-internal"; import type { BranchId, @@ -28,6 +29,7 @@ import type { ResolveConfig, ResolvedCluster, ResolveMap, + SimilarityStrategy, } from "./types"; /** @@ -112,6 +114,190 @@ const mergeOptionsScalarSchema = z.object({ .optional(), }); +/** + * Every key {@link MergeOptions} declares. The `satisfies` record is total over + * the type, so adding an option without listing it here fails to compile. + */ +const MERGE_OPTION_KEYS: ReadonlySet = new Set( + Object.keys({ + resolve: true, + reconcileTypes: true, + onPropertyConflict: true, + onBasePropertyConflict: true, + onDeleteModifyConflict: true, + onComparisonCeiling: true, + canonical: true, + provenance: true, + persistProvenance: true, + embedder: true, + target: true, + maxComparisonsPerKind: true, + candidateDiagnostics: true, + clusterMaxDiameter: true, + branchOrder: true, + provenanceWeights: true, + } satisfies Readonly>), +); + +/** Every key a per-kind {@link ResolveConfig} declares. */ +const RESOLVE_CONFIG_KEYS: ReadonlySet = new Set( + Object.keys({ + block: true, + blockIndex: true, + keyless: true, + similarity: true, + threshold: true, + } satisfies Readonly>), +); + +/** Every key `ResolveConfig.keyless` declares. */ +const KEYLESS_KEYS: ReadonlySet = new Set( + Object.keys({ window: true } satisfies Readonly< + Record, true> + >), +); + +type StrategyArm = Extract< + SimilarityStrategy, + Readonly<{ kind: Kind }> +>; + +function declaredStrategyKeys( + keys: Readonly, true>>, +): ReadonlySet { + return new Set(Object.keys(keys)); +} + +/** + * Every key each {@link SimilarityStrategy} arm declares, by its `kind`. The + * phantom `__graph` binding is never set at runtime, so it is not listed. + */ +const SIMILARITY_STRATEGY_KEYS: Readonly< + Record> +> = { + hybrid: declaredStrategyKeys>({ + kind: true, + fields: true, + weights: true, + }), + vector: declaredStrategyKeys>({ + kind: true, + field: true, + }), + fulltext: declaredStrategyKeys>({ + kind: true, + fields: true, + }), + custom: declaredStrategyKeys>({ + kind: true, + score: true, + }), +}; + +/** Every key a hybrid strategy's `weights` declares. */ +const HYBRID_WEIGHT_KEYS: ReadonlySet = new Set( + Object.keys({ vector: true, fulltext: true } satisfies Readonly< + Record< + keyof NonNullable< + Extract>["weights"] + >, + true + > + >), +); + +/** + * Refuses a key `declared` does not list. Normalization reads each known field + * by name, so an undeclared key would otherwise be dropped silently and the + * merge would run as though the caller had never stated it. `path` names the + * enclosing option, so the refusal names the exact key — `resolve.Person.blok` + * rather than `blok`. + */ +export function assertOnlyDeclaredKeys( + value: object, + declared: ReadonlySet, + path: string, + optionNoun = "merge option", +): void { + const unknownKey = Object.keys(value).find((key) => !declared.has(key)); + if (unknownKey === undefined) return; + const option = path === "" ? unknownKey : `${path}.${unknownKey}`; + throw new InvalidMergeOptionsError(`Unknown ${optionNoun} "${option}".`, { + details: { option }, + }); +} + +/** Refuses an option the merge does not declare, at the top level. */ +function assertOnlyDeclaredOptions(options: object): void { + assertOnlyDeclaredKeys(options, MERGE_OPTION_KEYS, ""); +} + +/** + * Refuses an undeclared key anywhere in one kind's resolve config: the config + * itself, its `keyless` bag, its similarity strategy (whose declared keys + * depend on the strategy's `kind`, itself refused when unknown) and a hybrid + * strategy's `weights`. + */ +function assertResolveConfigDeclared( + kind: string, + config: Readonly>, +): void { + const path = `resolve.${kind}`; + assertOnlyDeclaredKeys(config, RESOLVE_CONFIG_KEYS, path); + if (isPlainObject(config["keyless"])) { + assertOnlyDeclaredKeys(config["keyless"], KEYLESS_KEYS, `${path}.keyless`); + } + const similarity = config["similarity"]; + if (!isPlainObject(similarity)) return; + const strategyKind = similarity["kind"]; + const strategyKeys = + ( + typeof strategyKind === "string" && + Object.hasOwn(SIMILARITY_STRATEGY_KEYS, strategyKind) + ) ? + SIMILARITY_STRATEGY_KEYS[strategyKind as SimilarityStrategy["kind"]] + : undefined; + if (strategyKeys === undefined) { + const option = `${path}.similarity.kind`; + throw new InvalidMergeOptionsError( + `Unknown similarity strategy ${JSON.stringify(strategyKind)} for merge option "${path}.similarity": expected "hybrid", "vector", "fulltext", or "custom".`, + { details: { option, received: strategyKind } }, + ); + } + assertOnlyDeclaredKeys(similarity, strategyKeys, `${path}.similarity`); + if (isPlainObject(similarity["weights"])) { + assertOnlyDeclaredKeys( + similarity["weights"], + HYBRID_WEIGHT_KEYS, + `${path}.similarity.weights`, + ); + } +} + +/** + * The refusal a `resolve` entry for a node kind the graph does not declare + * earns, or `undefined` when every entry names a declared kind. A misspelled + * kind would otherwise resolve nothing, and the merge would match that kind by + * id only as though no resolve config had been stated. + * + * Separate from {@link normalizeMergeOptions}, which is graph-agnostic: the + * merge pipeline asks it against the graph it actually plans for. + */ +export function undeclaredResolveKindRefusal( + graph: GraphDef, + resolve: Readonly>, +): InvalidMergeOptionsError | undefined { + const unknownKind = Object.keys(resolve).find( + (kind) => !Object.hasOwn(graph.nodes, kind), + ); + if (unknownKind === undefined) return undefined; + const option = `resolve.${unknownKind}`; + return new InvalidMergeOptionsError( + `Unknown merge option "${option}": graph "${graph.id}" declares no node kind "${unknownKind}".`, + { details: { option } }, + ); +} + /** * Fully-normalized merge options: every default resolved, the (validated) * pass-through fields attached. Downstream phases consume this, never the raw @@ -181,6 +367,7 @@ function validateResolveMap( // Data-keyed: node kind names supplied by the caller's resolve map. const validated = createDataKeyedBag>(); for (const [kind, config] of Object.entries(configs)) { + assertResolveConfigDeclared(kind, config); const parsed = resolveConfigScalarSchema.safeParse({ threshold: config.threshold, }); @@ -240,12 +427,14 @@ function validateProvenanceWeights( * caller-boundary concern, surfaced as a thrown error per project conventions; * `merge()` converts it back to a typed `MergeError` at its own boundary. * - * @throws if a threshold is outside `[0, 1]`, `maxComparisonsPerKind` is - * negative/non-integer, or `clusterMaxDiameter` is non-positive. + * @throws if an option is not one {@link MergeOptions} declares, a threshold is + * outside `[0, 1]`, `maxComparisonsPerKind` is negative/non-integer, or + * `clusterMaxDiameter` is non-positive. */ export function normalizeMergeOptions( options: MergeOptions = {}, ): NormalizedMergeOptions { + assertOnlyDeclaredOptions(options); const scalar = mergeOptionsScalarSchema.parse({ reconcileTypes: options.reconcileTypes, onDeleteModifyConflict: options.onDeleteModifyConflict, diff --git a/packages/typegraph/src/graph-merge/plan-canonical.ts b/packages/typegraph/src/graph-merge/plan-canonical.ts index ab9c87e1d..cf70e7b59 100644 --- a/packages/typegraph/src/graph-merge/plan-canonical.ts +++ b/packages/typegraph/src/graph-merge/plan-canonical.ts @@ -2,13 +2,13 @@ import { sortedReplacer } from "../schema/canonical"; import { sha256Hex } from "../utils/hash"; import { MERGE_PLAN_DIGEST_ALGORITHM, - type MergePlanArtifactV1, - type MergePlanArtifactV1Input, + type MergePlanArtifactV2, + type MergePlanArtifactV2Input, } from "./plan-schema"; /** Returns the recursively key-sorted JSON representation used for plan identity. */ export function canonicalMergePlanJson( - artifact: MergePlanArtifactV1 | MergePlanArtifactV1Input, + artifact: MergePlanArtifactV2 | MergePlanArtifactV2Input, ): string { const digestless = "digest" in artifact ? omitDigest(artifact) : artifact; return JSON.stringify(digestless, sortedReplacer); @@ -16,16 +16,16 @@ export function canonicalMergePlanJson( /** Computes the full 256-bit digest of every plan field except `digest` itself. */ export async function computeMergePlanDigest( - artifact: MergePlanArtifactV1 | MergePlanArtifactV1Input, + artifact: MergePlanArtifactV2 | MergePlanArtifactV2Input, ): Promise { return sha256Hex(canonicalMergePlanJson(artifact), 32); } -/** Adds the canonical digest to an otherwise complete V1 artifact. */ +/** Adds the canonical digest to an otherwise complete V2 artifact. */ export async function finalizeMergePlanArtifact( - artifact: MergePlanArtifactV1Input, -): Promise { - const finalized: MergePlanArtifactV1 = { + artifact: MergePlanArtifactV2Input, +): Promise { + const finalized: MergePlanArtifactV2 = { ...artifact, digest: { algorithm: MERGE_PLAN_DIGEST_ALGORITHM, @@ -35,10 +35,10 @@ export async function finalizeMergePlanArtifact( const canonical: unknown = JSON.parse( JSON.stringify(finalized, sortedReplacer), ); - return canonical as MergePlanArtifactV1; + return canonical as MergePlanArtifactV2; } -function omitDigest(artifact: MergePlanArtifactV1): MergePlanArtifactV1Input { +function omitDigest(artifact: MergePlanArtifactV2): MergePlanArtifactV2Input { const { digest: _digest, ...digestless } = artifact; return digestless; } diff --git a/packages/typegraph/src/graph-merge/plan-schema.ts b/packages/typegraph/src/graph-merge/plan-schema.ts index 1cfa61f08..60deed007 100644 --- a/packages/typegraph/src/graph-merge/plan-schema.ts +++ b/packages/typegraph/src/graph-merge/plan-schema.ts @@ -1,9 +1,22 @@ import { z } from "zod"; -import { compareEntityRefs, compareMatchSources } from "./evidence"; +import { + CLUSTER_EXCLUSION_REASONS, + type ClusterExclusionReason, + compareEntityRefs, + compareMatchSources, +} from "./evidence"; import type { JsonValue } from "./typegraph-internal"; -export const MERGE_PLAN_FORMAT_VERSION = 1 as const; +// Bumped 1 -> 2 for the composition-orphan review field: a required field +// added under the SAME version number would make `mergePlanArtifactV2Schema` +// (`.strict()`) reject every plan artifact serialized before composition +// existed as "malformed", when the correct signal is "this artifact predates +// a format this library version understands" — `parseMergePlanArtifact` +// reads `formatVersion` before any other validation specifically to draw +// that distinction (`unsupported-version` vs `malformed`), and a same-version +// field addition would defeat it silently by falling through to the schema. +export const MERGE_PLAN_FORMAT_VERSION = 2 as const; export const MERGE_PLAN_DIGEST_ALGORITHM = "sha256" as const; export type MergePlanEntityRef = Readonly<{ kind: string; id: string }>; @@ -55,6 +68,8 @@ export type MergePlanNodeUpsert = Readonly<{ unsetProps: readonly string[]; validFrom?: string | null | undefined; validTo?: string | undefined; + /** Reopens the row: its upper bound is cleared. Exclusive with `validTo`. */ + clearValidTo?: true | undefined; }>; export type MergePlanEdgeDelete = MergePlanEntityRef; @@ -68,6 +83,8 @@ export type MergePlanEdgeUpsert = Readonly<{ unsetProps: readonly string[]; validFrom?: string | null | undefined; validTo?: string | undefined; + /** Reopens the row: its upper bound is cleared. Exclusive with `validTo`. */ + clearValidTo?: true | undefined; }>; export type MergePlanIdentityAssertion = Readonly<{ @@ -183,7 +200,7 @@ export type MergePlanCandidateDiagnostic = Readonly<{ | "retained" | Readonly<{ kind: "excluded"; - reason: "diameter" | "baseAmbiguity"; + reason: ClusterExclusionReason; }> | undefined; }>; @@ -202,6 +219,47 @@ export type MergePlanTypeReconciliation = Readonly<{ decisiveEdges?: readonly MergePlanMatchEvidence[] | undefined; }>; +/** + * A composition part this merge would separate from its whole without the + * target's consent. Three independent causes, all surfaced through the SAME + * shape and the SAME `MergeCompositionOrphanError` — a caller distinguishes + * them only by `cause`, never by a second error class: + * + * - `"deleted"`: a whole the plan deletes has a live + * part the plan does NOT itself delete — attached on the target after the + * branch point, or independently of it — found by re-reading the parts + * closure (`planCompositionCascade`) against every planned node deletion. + * `whole` is that about-to-be-deleted whole. + * - `"moved"`: the mirror case. The plan deletes a part — directly, or + * because the branch's cascade from its old whole deleted it — that the + * target has meanwhile moved under (or attached to) a live whole the plan + * neither deletes nor detaches it from. `whole` is that surviving whole. + * - `"unattached"`: a required-existence part THIS + * MERGE WRITES resolves, after canonicalization, to no live whole at + * all — its composition edge was dropped or collapsed while both + * endpoints survive. There is no whole to name, so `whole` is absent. + * + * Surfaced here so a dry run reports it; apply re-verifies the same finding + * under the write lock and refuses with `MergeCompositionOrphanError` when + * it recurs. + * + * Between branches, the same two shapes are refused at plan time instead of + * reported, with `branchId` naming the branch whose attachment would be lost: + * `"moved"` when one branch attached a part to a whole that survives while + * another deletes the part, `"deleted"` when one branch attached a surviving + * part to a whole another deletes. No target write can resolve that conflict, + * so such a finding never appears in `MergePlanReview.compositionOrphans`. + */ +export type MergePlanCompositionOrphan = Readonly<{ + part: MergePlanEntityRef; + /** Present for `cause: "deleted"` and `"moved"`; absent for `cause: "unattached"` — there is no whole to name. */ + whole?: MergePlanEntityRef; + viaEdgeKind: string; + cause: "deleted" | "moved" | "unattached"; + /** The branch whose staged attachment the merge would lose; absent when the conflict is with the target's own state. */ + branchId?: string; +}>; + export type MergePlanReview = Readonly<{ resolutions: readonly MergePlanEntityResolution[]; conflicts: readonly JsonValue[]; @@ -212,7 +270,10 @@ export type MergePlanReview = Readonly<{ baseAmbiguities: readonly JsonValue[]; provenanceRecords: readonly JsonValue[]; warnings: readonly string[]; + compositionOrphans: readonly MergePlanCompositionOrphan[]; diagnostics?: MergePlanDiagnostics | undefined; + /** Optional, omitted when empty — see the review schema's format-version note. */ + identityConflicts?: readonly JsonValue[] | undefined; }>; export type MergePlanProvenanceOptions = Readonly<{ @@ -225,7 +286,7 @@ export type MergePlanDigest = Readonly<{ value: string; }>; -export type MergePlanArtifactV1 = Readonly<{ +export type MergePlanArtifactV2 = Readonly<{ formatVersion: typeof MERGE_PLAN_FORMAT_VERSION; digest: MergePlanDigest; mode: "snapshot" | "incremental"; @@ -239,9 +300,9 @@ export type MergePlanArtifactV1 = Readonly<{ }>; /** Current public merge-plan artifact type. */ -export type MergePlanArtifact = MergePlanArtifactV1; +export type MergePlanArtifact = MergePlanArtifactV2; -export type MergePlanArtifactV1Input = Omit; +export type MergePlanArtifactV2Input = Omit; const nonEmptyStringSchema = z.string().min(1); // Zod 4's number schema rejects NaN and infinities by default. @@ -315,6 +376,20 @@ const mergePlanAnchorsSchema = z.discriminatedUnion("kind", [ incrementalAnchorsSchema, ]); +function statesAtMostOneValidityEnd( + upsert: Readonly<{ + validTo?: string | undefined; + clearValidTo?: true | undefined; + }>, +): boolean { + return upsert.validTo === undefined || !upsert.clearValidTo; +} + +const VALIDITY_END_EXCLUSIVITY_ISSUE = { + message: 'A write states either "validTo" or "clearValidTo", never both', + path: ["clearValidTo"], +}; + const nodeUpsertSchema = z .object({ kind: nonEmptyStringSchema, @@ -323,8 +398,10 @@ const nodeUpsertSchema = z unsetProps: z.array(nonEmptyStringSchema), validFrom: nonEmptyStringSchema.nullable().optional(), validTo: nonEmptyStringSchema.optional(), + clearValidTo: z.literal(true).optional(), }) - .strict(); + .strict() + .refine(statesAtMostOneValidityEnd, VALIDITY_END_EXCLUSIVITY_ISSUE); const edgeUpsertSchema = z .object({ @@ -336,8 +413,10 @@ const edgeUpsertSchema = z unsetProps: z.array(nonEmptyStringSchema), validFrom: nonEmptyStringSchema.nullable().optional(), validTo: nonEmptyStringSchema.optional(), + clearValidTo: z.literal(true).optional(), }) - .strict(); + .strict() + .refine(statesAtMostOneValidityEnd, VALIDITY_END_EXCLUSIVITY_ISSUE); const mergePlanIdentityAssertionSchema = z .object({ @@ -527,7 +606,7 @@ const diagnosticsSchema = z z .object({ kind: z.literal("excluded"), - reason: z.enum(["diameter", "baseAmbiguity"]), + reason: z.enum(CLUSTER_EXCLUSION_REASONS), }) .strict(), ]) @@ -541,6 +620,32 @@ const diagnosticsSchema = z }) .strict(); +const identityProvenanceRecordSchema = z + .object({ + role: z.enum(["node", "edge"]), + canonicalId: nonEmptyStringSchema, + canonicalKind: nonEmptyStringSchema, + branchId: nonEmptyStringSchema, + sourceId: nonEmptyStringSchema, + }) + .strict(); + +/** + * The public `IdentityUnresolvedConflict`, validated STRICTLY rather than as + * opaque JSON: an entry the merge could not have produced fails at parse, + * where the plan artifact is read, rather than at the point a consumer reaches + * into a field that is not there. + */ +const identityUnresolvedConflictSchema = z + .object({ + kind: z.literal("separation"), + a: mergePlanEntityRefSchema, + b: mergePlanEntityRefSchema, + assertionIds: z.array(nonEmptyStringSchema), + source: matchSourceSchema.optional(), + }) + .strict(); + const mergePlanReviewSchema = z .object({ resolutions: z.array(entityResolutionSchema), @@ -611,11 +716,22 @@ const mergePlanReviewSchema = z entity: z.enum(["node", "edge"]), kind: nonEmptyStringSchema, id: nonEmptyStringSchema, - validTo: nonEmptyStringSchema, + validTo: nonEmptyStringSchema.optional(), + clearValidTo: z.literal(true).optional(), claimedBy: z.array(nonEmptyStringSchema), precedence: z.literal("target").optional(), }) - .strict(), + .strict() + .refine( + (resolution) => + (resolution.validTo === undefined) === + (resolution.clearValidTo === true), + { + message: + 'A validity end states exactly one of "validTo" or "clearValidTo"', + path: ["clearValidTo"], + }, + ), ), baseAmbiguities: z.array( z @@ -625,19 +741,25 @@ const mergePlanReviewSchema = z }) .strict(), ), - provenanceRecords: z.array( + provenanceRecords: z.array(identityProvenanceRecordSchema), + warnings: z.array(z.string()), + compositionOrphans: z.array( z .object({ - role: z.enum(["node", "edge"]), - canonicalId: nonEmptyStringSchema, - canonicalKind: nonEmptyStringSchema, - branchId: nonEmptyStringSchema, - sourceId: nonEmptyStringSchema, + part: mergePlanEntityRefSchema, + whole: mergePlanEntityRefSchema.optional(), + viaEdgeKind: nonEmptyStringSchema, + cause: z.enum(["deleted", "moved", "unattached"]), + branchId: nonEmptyStringSchema.optional(), }) .strict(), ), - warnings: z.array(z.string()), diagnostics: diagnosticsSchema.optional(), + // Optional and omitted when empty (the `diagnostics` precedent above): a + // merge the separation veto never touched produces a review object + // byte-identical to one written before the veto existed, so + // `MERGE_PLAN_FORMAT_VERSION` stays at 2 and those artifacts still parse. + identityConflicts: z.array(identityUnresolvedConflictSchema).optional(), }) .strict(); @@ -648,7 +770,7 @@ const mergePlanDigestSchema = z }) .strict(); -const mergePlanArtifactV1BaseSchema = z +const mergePlanArtifactV2BaseSchema = z .object({ formatVersion: z.literal(MERGE_PLAN_FORMAT_VERSION), digest: mergePlanDigestSchema, @@ -665,15 +787,15 @@ const mergePlanArtifactV1BaseSchema = z }) .strict(); -const mergePlanArtifactV1InputBaseSchema = mergePlanArtifactV1BaseSchema.omit({ +const mergePlanArtifactV2InputBaseSchema = mergePlanArtifactV2BaseSchema.omit({ digest: true, }); -type ParsedMergePlanArtifactV1Input = z.infer< - typeof mergePlanArtifactV1InputBaseSchema +type ParsedMergePlanArtifactV2Input = z.infer< + typeof mergePlanArtifactV2InputBaseSchema >; function addSemanticIssues( - artifact: ParsedMergePlanArtifactV1Input, + artifact: ParsedMergePlanArtifactV2Input, ctx: z.RefinementCtx, ): void { if (artifact.mode !== artifact.anchors.kind) { @@ -831,8 +953,8 @@ function addSemanticIssues( } function addResolutionEvidenceIssues( - artifact: ParsedMergePlanArtifactV1Input, - resolution: ParsedMergePlanArtifactV1Input["review"]["resolutions"][number], + artifact: ParsedMergePlanArtifactV2Input, + resolution: ParsedMergePlanArtifactV2Input["review"]["resolutions"][number], resolutionIndex: number, ctx: z.RefinementCtx, ): void { @@ -916,7 +1038,7 @@ function addResolutionEvidenceIssues( } function addEvidenceIssues( - evidence: ParsedMergePlanArtifactV1Input["review"]["resolutions"][number]["decisiveEdges"][number], + evidence: ParsedMergePlanArtifactV2Input["review"]["resolutions"][number]["decisiveEdges"][number], path: readonly (string | number)[], ctx: z.RefinementCtx, ): void { @@ -1012,12 +1134,12 @@ function entityKey(entity: MergePlanEntityRef): string { return JSON.stringify([entity.kind, entity.id]); } -export const mergePlanArtifactV1InputSchema = - mergePlanArtifactV1InputBaseSchema.superRefine((artifact, ctx) => +export const mergePlanArtifactV2InputSchema = + mergePlanArtifactV2InputBaseSchema.superRefine((artifact, ctx) => addSemanticIssues(artifact, ctx), ); -export const mergePlanArtifactV1Schema = - mergePlanArtifactV1BaseSchema.superRefine((artifact, ctx) => +export const mergePlanArtifactV2Schema = + mergePlanArtifactV2BaseSchema.superRefine((artifact, ctx) => addSemanticIssues(artifact, ctx), ); diff --git a/packages/typegraph/src/graph-merge/plan-wire.ts b/packages/typegraph/src/graph-merge/plan-wire.ts index 1fecbc762..bd68070e8 100644 --- a/packages/typegraph/src/graph-merge/plan-wire.ts +++ b/packages/typegraph/src/graph-merge/plan-wire.ts @@ -6,10 +6,10 @@ import { } from "./plan-canonical"; import { MERGE_PLAN_FORMAT_VERSION, - type MergePlanArtifactV1, - type MergePlanArtifactV1Input, - mergePlanArtifactV1InputSchema, - mergePlanArtifactV1Schema, + type MergePlanArtifactV2, + type MergePlanArtifactV2Input, + mergePlanArtifactV2InputSchema, + mergePlanArtifactV2Schema, } from "./plan-schema"; type MergePlanParseFailure = @@ -17,7 +17,7 @@ type MergePlanParseFailure = | Readonly<{ kind: "malformed"; issues: z.core.$ZodIssue[] }>; export type MergePlanParseResult = - | Readonly<{ success: true; artifact: MergePlanArtifactV1 }> + | Readonly<{ success: true; artifact: MergePlanArtifactV2 }> | Readonly<{ success: false; error: MergePlanParseFailure }>; export type MergePlanDigestResult = @@ -25,7 +25,7 @@ export type MergePlanDigestResult = | Readonly<{ valid: false; expected: string; received: string }>; export type MergePlanValidationResult = - | Readonly<{ success: true; artifact: MergePlanArtifactV1 }> + | Readonly<{ success: true; artifact: MergePlanArtifactV2 }> | Readonly<{ success: false; error: @@ -37,7 +37,7 @@ export type MergePlanValidationResult = }>; }>; -/** Parses an untrusted value without conflating an unknown version with bad V1 data. */ +/** Parses an untrusted value without conflating an unknown version with bad V2 data. */ export function parseMergePlanArtifact(input: unknown): MergePlanParseResult { const version = readFormatVersion(input); if (version !== MERGE_PLAN_FORMAT_VERSION) { @@ -46,7 +46,7 @@ export function parseMergePlanArtifact(input: unknown): MergePlanParseResult { error: { kind: "unsupported-version", received: version }, }; } - const parsed = mergePlanArtifactV1Schema.safeParse(input); + const parsed = mergePlanArtifactV2Schema.safeParse(input); if (!parsed.success) { return { success: false, @@ -55,13 +55,13 @@ export function parseMergePlanArtifact(input: unknown): MergePlanParseResult { } return { success: true, - artifact: parsed.data as unknown as MergePlanArtifactV1, + artifact: parsed.data as unknown as MergePlanArtifactV2, }; } /** Verifies the digest on a structurally valid artifact. */ export async function verifyMergePlanDigest( - artifact: MergePlanArtifactV1, + artifact: MergePlanArtifactV2, ): Promise { const expected = await computeMergePlanDigest(artifact); const received = artifact.digest.value; @@ -90,17 +90,18 @@ export async function validateMergePlanArtifact( return parsed; } -/** Constructs and hashes a V1 wire artifact. */ +/** Constructs and hashes a V2 wire artifact. */ export async function constructMergePlanArtifact( - input: MergePlanArtifactV1Input, -): Promise { - const validatedInput = mergePlanArtifactV1InputSchema.parse(input); + input: MergePlanArtifactV2Input, +): Promise { + const validatedInput = mergePlanArtifactV2InputSchema.parse(input); return finalizeMergePlanArtifact( - validatedInput as unknown as MergePlanArtifactV1Input, + validatedInput as unknown as MergePlanArtifactV2Input, ); } -function readFormatVersion(input: unknown): unknown { +/** Reads a wire artifact's `formatVersion` before any schema validation. */ +export function readFormatVersion(input: unknown): unknown { if (input === null || typeof input !== "object" || Array.isArray(input)) { return undefined; } @@ -108,6 +109,6 @@ function readFormatVersion(input: unknown): unknown { } export type { - MergePlanArtifactV1, - MergePlanArtifactV1Input, + MergePlanArtifactV2, + MergePlanArtifactV2Input, } from "./plan-schema"; diff --git a/packages/typegraph/src/graph-merge/provenance-store.ts b/packages/typegraph/src/graph-merge/provenance-store.ts index 4374a7400..a78799a7a 100644 --- a/packages/typegraph/src/graph-merge/provenance-store.ts +++ b/packages/typegraph/src/graph-merge/provenance-store.ts @@ -147,6 +147,7 @@ import type { Store, } from "./typegraph-internal"; import { + activeSchemaHash, asCompiledRowsSql, asCompiledStatementSql, computeSchemaHash, @@ -567,7 +568,8 @@ async function inspectSidecarGraphId( const ownedHash = await computeSchemaHash( serializeSchema(graph, activeSchema.version), ); - if (activeSchema.schema_hash === ownedHash) { + const activeHash = await activeSchemaHash(activeSchema); + if (activeHash === ownedHash) { // The current sidecar schema with no marker. This module writes the marker // FIRST, so it cannot have produced this state: whatever registered the // schema, it was not an interrupted creation of ours. Contents are not @@ -580,7 +582,7 @@ async function inspectSidecarGraphId( const legacyHash = await computeSchemaHash( serializeSchema(buildProvenanceGraph(targetGraphId), activeSchema.version), ); - if (activeSchema.schema_hash !== legacyHash) { + if (activeHash !== legacyHash) { return { state: "refuse", reason: "application-graph" }; } return LEGACY_SCHEMA_DISPOSITION[ diff --git a/packages/typegraph/src/graph-merge/review-baseline.ts b/packages/typegraph/src/graph-merge/review-baseline.ts index a372a21f2..2c93b5bb2 100644 --- a/packages/typegraph/src/graph-merge/review-baseline.ts +++ b/packages/typegraph/src/graph-merge/review-baseline.ts @@ -84,7 +84,7 @@ export async function captureReviewBaseline( } /** - * Captures a V2 review baseline over candidate references and their exact + * Captures a candidate-scoped review baseline over candidate references and their exact * archival identity closure. The retained scope is re-read during approval * revalidation; newly connected peers/assertions therefore change the evidence. */ diff --git a/packages/typegraph/src/graph-merge/review-schema.ts b/packages/typegraph/src/graph-merge/review-schema.ts index f3530a067..7b47a217f 100644 --- a/packages/typegraph/src/graph-merge/review-schema.ts +++ b/packages/typegraph/src/graph-merge/review-schema.ts @@ -9,13 +9,38 @@ import type { MergePlanDigest, MergePlanEntityRef, } from "./plan-schema"; -import { mergePlanArtifactV1Schema } from "./plan-schema"; +import { mergePlanArtifactV2Schema } from "./plan-schema"; import type { JsonValue } from "./typegraph-internal"; -/** Default review format, retained for callers that validate V1 artifacts. */ -export const MERGE_REVIEW_FORMAT_VERSION = 1 as const; -export const MERGE_REVIEW_FORMAT_VERSION_V1 = 1 as const; -export const MERGE_REVIEW_FORMAT_VERSION_V2 = 2 as const; +// A review embeds its plan under the strict plan schema, so a review stored +// before the plan's own 1 -> 2 bump (`MERGE_PLAN_FORMAT_VERSION`) can never +// validate. Formats 1 (whole-target baseline) and 2 (candidate-scoped +// baseline) embedded a version-1 plan; both evidence modes are numbered past +// them so a stored one is refused as an unsupported version rather than as +// malformed. + +/** The default review format: its baseline covers the whole target. */ +export const MERGE_REVIEW_FORMAT_VERSION = 3 as const; +/** The opt-in review format whose baseline covers the candidate's own scope. */ +export const MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED = 4 as const; + +/** Every review format this library version validates, in ascending order. */ +export const SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS = [ + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, +] as const; + +type MergeReviewFormatVersion = + (typeof SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS)[number]; + +/** Whether a stored review's `formatVersion` is one this library validates. */ +export function isSupportedMergeReviewFormatVersion( + formatVersion: unknown, +): formatVersion is MergeReviewFormatVersion { + return ( + SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS as readonly unknown[] + ).includes(formatVersion); +} /** Application-owned identity of policy code and all opaque/external dependencies. */ export type MergeReviewPolicy = Readonly<{ @@ -32,12 +57,15 @@ export type MergeReviewRow = MergePlanEntityRef & digest?: string | undefined; }>; -/** V1 retains a global baseline; V2 records the exact candidate identity scope. */ +/** + * The default format retains a whole-target baseline; the candidate-scoped + * format records the exact candidate identity scope. + */ export type MergeReviewBaseline = Readonly<{ rows: readonly MergeReviewRow[]; identityDigest: string; scope?: "referenced" | undefined; - /** Present in V2: endpoint scope retained to make revalidation exact. */ + /** Present in the candidate-scoped format: endpoint scope retained to make revalidation exact. */ identityReferences?: readonly MergePlanEntityRef[] | undefined; /** Candidate assertion IDs whose unrelated ID collisions affect import. */ identityAssertionIds?: readonly string[] | undefined; @@ -45,13 +73,11 @@ export type MergeReviewBaseline = Readonly<{ /** * Immutable review evidence, distinct from its single-use execution plan. - * Both versions support candidate write sets. Authenticate stored artifacts + * Both formats review candidate write sets. Authenticate stored artifacts * separately. */ export type MergeReviewArtifact = Readonly<{ - formatVersion: - | typeof MERGE_REVIEW_FORMAT_VERSION_V1 - | typeof MERGE_REVIEW_FORMAT_VERSION_V2; + formatVersion: MergeReviewFormatVersion; kind: "candidate-write-set"; digest: MergePlanDigest; writeSet: CandidateWriteSet; @@ -95,8 +121,8 @@ export const mergeReviewPolicySchema = z export const mergeReviewArtifactSchema = z .object({ formatVersion: z.union([ - z.literal(MERGE_REVIEW_FORMAT_VERSION_V1), - z.literal(MERGE_REVIEW_FORMAT_VERSION_V2), + z.literal(MERGE_REVIEW_FORMAT_VERSION), + z.literal(MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED), ]), kind: z.literal("candidate-write-set"), digest: z @@ -105,7 +131,7 @@ export const mergeReviewArtifactSchema = z writeSet: CandidateWriteSetSchema, policy: mergeReviewPolicySchema, options: z.json(), - plan: mergePlanArtifactV1Schema, + plan: mergePlanArtifactV2Schema, baseline: z .object({ rows: z.array( diff --git a/packages/typegraph/src/graph-merge/sparse-candidate-branch.ts b/packages/typegraph/src/graph-merge/sparse-candidate-branch.ts index d9ae7b802..a6f3cc7a3 100644 --- a/packages/typegraph/src/graph-merge/sparse-candidate-branch.ts +++ b/packages/typegraph/src/graph-merge/sparse-candidate-branch.ts @@ -1,7 +1,12 @@ import type { EdgeRow } from "../backend/types"; import type { GraphData } from "../interchange"; import { validateImportProperties } from "../interchange/import"; -import { EDGE_CARDINALITY_SPECS } from "../store/claims/edge-claims"; +import { acyclicEdgeRelations } from "../store/acyclicity"; +import { COMPOSITION_RELATION_NAME } from "../store/claims/axis"; +import { + edgeCardinalityAxisReferences, + edgeCardinalitySpec, +} from "../store/claims/edge-claims"; import { resolveEdgeMatchIdentityStorage } from "../store/edge-match-key"; import { type CandidateIdentityScope, @@ -10,6 +15,8 @@ import { import type { CandidateWriteSet } from "./candidate-write-set"; import { parseRowProps } from "./canonical-props"; import { CandidateWriteSetError } from "./errors"; +import { captureMergePlanTargetFence } from "./merge"; +import type { MergePlanTargetFence } from "./plan-schema"; import type { GraphBackend, GraphDef, @@ -35,90 +42,223 @@ import { graphWithoutNodeUniqueness } from "./working-copy"; type EntityReference = Readonly<{ kind: string; id: string }>; /** + * Why candidate planning stages the whole target instead of the candidate's + * own rows. Each scope produces a correct plan; the reason names the cost. + */ +export type CandidatePlanningScopeReason = + | "composition" + | "acyclic-relation" + | "target-side-cardinality" + | "missing-active-source-read" + | "missing-match-identity-read" + | "missing-scoped-identity-reads" + | "match-identity-owner-not-clone-visible" + | "planned-evolution"; + +/** The rows candidate planning stages, and why when it is not the candidate's own. */ +export type CandidatePlanningScope = + Readonly<{ scope: "candidate" }> | WholeTargetPlanningScope; + +/** {@link CandidatePlanningScope}'s whole-target decision. */ +export type WholeTargetPlanningScope = Readonly<{ + scope: "whole-target"; + reason: CandidatePlanningScopeReason; +}>; + +const CANDIDATE_SCOPE = Object.freeze({ scope: "candidate" } as const); + +export function wholeTargetPlanningScope( + reason: CandidatePlanningScopeReason, +): WholeTargetPlanningScope { + return { scope: "whole-target", reason }; +} + +/** + * The scope a target admits before any candidate is read: the one owner of + * candidate-planning eligibility. Callers have already established that the + * target can carry a public merge plan (revision tracking included). + * * Cardinality peers use the exact claim key and holder liveness. `oneActive` * needs the optional active-only source read so ended history cannot make a * candidate lookup grow with the graph. Durable match identity requires an * exact keyed owner read. Ontology disjointness uses live same-id siblings. + * + * A target-side cardinality, an acyclic relation and composition (which is + * both a one-whole claim and an acyclic relation) each constrain rows the + * sparse base never reads: the holders at an edge's target endpoint, and the + * paths between its endpoints. A graph declaring any of them plans against + * the whole target instead. */ -export function canUseSparseCandidatePlanning( +export function candidatePlanningScope( target: Store, -): boolean { - const hasActiveSourceRead = - storeBackend(target).findActiveEdgesBySourceV1 !== undefined; - return ( - target.revisionTrackingEnabled && - (target.graph.identity === undefined || - hasScopedIdentityReads(storeRuntime(target))) && - Object.values(target.graph.edges).every( - (edge) => - (edge.cardinality !== "oneActive" || hasActiveSourceRead) && - (edge.matchIdentity === undefined || - storeBackend(target).findEdgesByMatchIdentity !== undefined), +): CandidatePlanningScope { + const acyclicRelations = acyclicEdgeRelations(target.graph, target.registry); + if ( + acyclicRelations.some( + (relation) => relation.name === COMPOSITION_RELATION_NAME, ) - ); + ) + return wholeTargetPlanningScope("composition"); + if (acyclicRelations.length > 0) + return wholeTargetPlanningScope("acyclic-relation"); + const edges = Object.values(target.graph.edges); + if ( + edges.some((edge) => + edgeCardinalityAxisReferences(edge).some( + (axis) => axis.direction === "target", + ), + ) + ) + return wholeTargetPlanningScope("target-side-cardinality"); + const backend = storeBackend(target); + if ( + backend.findActiveEdgesBySourceV1 === undefined && + edges.some((edge) => edge.cardinality === "oneActive") + ) + return wholeTargetPlanningScope("missing-active-source-read"); + if ( + backend.findEdgesByMatchIdentity === undefined && + edges.some((edge) => edge.matchIdentity !== undefined) + ) + return wholeTargetPlanningScope("missing-match-identity-read"); + if ( + target.graph.identity !== undefined && + !hasScopedIdentityReads(storeRuntime(target)) + ) + return wholeTargetPlanningScope("missing-scoped-identity-reads"); + return CANDIDATE_SCOPE; +} + +/** + * The durable match-identity owners named by candidate edges. `malformed` + * lists the edges whose owner key cannot be derived (an unknown kind, invalid + * properties, or an identity value that cannot be stored); `owners` still + * covers every edge whose key can be. Import refuses each malformed edge + * itself, so staging such a candidate reports its own validation error. + */ +export type CandidateMatchIdentityOwnerRead = + | Readonly<{ status: "complete"; owners: readonly EdgeRow[] }> + | Readonly<{ + status: "malformed"; + owners: readonly EdgeRow[]; + malformed: readonly EntityReference[]; + }>; + +/** + * The decided scope. A candidate scope carries what seeds its sparse base: the + * durable match-identity owners its edges name, read under `fence`. + */ +export type CandidatePlanningScopeResolution = + | Readonly<{ + planningScope: typeof CANDIDATE_SCOPE; + fence: MergePlanTargetFence; + ownerRead: CandidateMatchIdentityOwnerRead; + }> + | Readonly<{ + planningScope: WholeTargetPlanningScope; + }>; + +/** + * {@link candidatePlanningScope} refined by the candidate itself. The owner + * query controls whether sparse import sees an existing edge, so it is bound + * to one target revision with the sparse seed; an owner excluded from the + * clone projection plans against the whole target. + */ +export async function resolveCandidatePlanningScope( + target: Store, + writeSet: CandidateWriteSet, +): Promise { + const planningScope = candidatePlanningScope(target); + if (planningScope.scope === "whole-target") return { planningScope }; + const fence = await captureMergePlanTargetFence(target); + const ownerRead = await readCandidateMatchIdentityOwners(target, writeSet); + if (!matchIdentityOwnersAreCloneVisible(ownerRead.owners)) + return { + planningScope: wholeTargetPlanningScope( + "match-identity-owner-not-clone-visible", + ), + }; + return { planningScope, fence, ownerRead }; } /** - * Reads only the durable owners named by candidate edges. `undefined` means - * the exact lookup cannot be performed safely (for example, a custom backend - * omits the optional capability or a candidate key cannot be derived). + * Reads only the durable owners named by candidate edges. Requires the keyed + * owner read whenever a candidate edge declares a match identity, which + * {@link candidatePlanningScope} establishes before this runs. */ export async function readCandidateMatchIdentityOwners( target: Store, writeSet: CandidateWriteSet, -): Promise { - const backend = storeBackend(target); - const readOwners = backend.findEdgesByMatchIdentity; - if (readOwners === undefined) { - return ( - Object.values(target.graph.edges).some( - (edge) => edge.matchIdentity !== undefined, - ) - ) ? - undefined - : []; - } +): Promise { const identities = new Map< string, Readonly<{ kind: string; name: string; key: string }> >(); - try { - for (const edge of writeSet.edges) { - const registration = target.graph.edges[edge.kind]; - if (registration === undefined) return undefined; - const validatedProperties = validateImportProperties( - edge.properties, - registration.type.schema, - "allow", - ); - if (!validatedProperties.success) return undefined; - const identity = resolveEdgeMatchIdentityStorage( - registration.matchIdentity, - { - fromKind: edge.from.kind, - fromId: edge.from.id, - toKind: edge.to.kind, - toId: edge.to.id, - props: validatedProperties.data, - }, - { graphId: target.graphId, edgeKind: edge.kind }, - ); - if (identity === undefined) continue; - identities.set(JSON.stringify([edge.kind, identity.name, identity.key]), { - kind: edge.kind, - name: identity.name, - key: identity.key, - }); + const malformed: EntityReference[] = []; + for (const edge of writeSet.edges) { + const identity = candidateEdgeMatchIdentity(target, edge); + if (identity === "malformed") { + malformed.push({ kind: edge.kind, id: edge.id }); + continue; } + if (identity === undefined) continue; + identities.set(JSON.stringify([edge.kind, identity.name, identity.key]), { + kind: edge.kind, + name: identity.name, + key: identity.key, + }); + } + const owners = await readMatchIdentityOwners(target, [ + ...identities.values(), + ]); + return malformed.length === 0 ? + { status: "complete", owners } + : { status: "malformed", owners, malformed }; +} + +/** The durable owner key one candidate edge names, as import would derive it. */ +function candidateEdgeMatchIdentity( + target: Store, + edge: CandidateWriteSet["edges"][number], +): Readonly<{ name: string; key: string }> | "malformed" | undefined { + const registration = target.graph.edges[edge.kind]; + if (registration === undefined) return "malformed"; + if (registration.matchIdentity === undefined) return undefined; + const validatedProperties = validateImportProperties( + edge.properties, + registration.type.schema, + "allow", + ); + if (!validatedProperties.success) return "malformed"; + try { + return resolveEdgeMatchIdentityStorage( + registration.matchIdentity, + { + fromKind: edge.from.kind, + fromId: edge.from.id, + toKind: edge.to.kind, + toId: edge.to.id, + props: validatedProperties.data, + }, + { graphId: target.graphId, edgeKind: edge.kind }, + ); } catch { - // Let the full-clone/import path own malformed candidate validation. - return undefined; + return "malformed"; + } +} + +async function readMatchIdentityOwners( + target: Store, + identities: readonly Readonly<{ kind: string; name: string; key: string }>[], +): Promise { + if (identities.length === 0) return []; + const readOwners = storeBackend(target).findEdgesByMatchIdentity; + if (readOwners === undefined) { + throw new CandidateWriteSetError( + "Candidate planning requires the keyed durable match-identity owner read.", + ); } - if (identities.size === 0) return []; - return readOwners({ - graphId: target.graphId, - identities: [...identities.values()], - }); + return readOwners({ graphId: target.graphId, identities }); } /** Whether every returned owner is included in the default clone projection. */ @@ -189,7 +329,7 @@ async function sparseBaseDocument( for (const edge of writeSet.edges) { const cardinality = target.graph.edges[edge.kind]?.cardinality ?? "many"; if (cardinality === "many") continue; - const spec = EDGE_CARDINALITY_SPECS[cardinality]; + const spec = edgeCardinalitySpec({ direction: "source", cardinality }); if (spec.holderLiveness === "liveAndActive") { addActiveRead(edge.kind, edge.from.kind, edge.from.id); continue; diff --git a/packages/typegraph/src/graph-merge/temporal-identity-closure.ts b/packages/typegraph/src/graph-merge/temporal-identity-closure.ts index 9a0d4bff2..6be454a7e 100644 --- a/packages/typegraph/src/graph-merge/temporal-identity-closure.ts +++ b/packages/typegraph/src/graph-merge/temporal-identity-closure.ts @@ -52,6 +52,8 @@ type KindFirstReferenceChange = Readonly<{ type TemporalClosureState = Readonly<{ assertions: readonly IdentityTransferAssertion[]; + /** Every window boundary, ascending: leaf `i` is `[boundaries[i], boundaries[i + 1])`. */ + boundaries: readonly string[]; context: PlanIdentityContext; references: readonly IdentityReference[]; referenceIndexByKey: ReadonlyMap; @@ -330,7 +332,20 @@ function componentKindsInReferenceOrder( .map(([kind]) => kind); } -function assertCoordinateConsistent(state: TemporalClosureState): void { +/** The valid-time window leaf `leaf` of the traversal covers. */ +function leafWindow( + state: TemporalClosureState, + leaf: number, +): Readonly<{ validFrom: string; validTo?: string }> { + const validFrom = requireDefined(state.boundaries[leaf]); + const validTo = state.boundaries[leaf + 1]; + return validTo === undefined ? { validFrom } : { validFrom, validTo }; +} + +function assertCoordinateConsistent( + state: TemporalClosureState, + leaf: number, +): void { if (state.sharedIdViolationCount.value > 0) { const activeById = new Map(); for (const [index, reference] of state.references.entries()) { @@ -364,6 +379,7 @@ function assertCoordinateConsistent(state: TemporalClosureState): void { details: { assertion, sameClass: activeClassMembers(state, root), + window: leafWindow(state, leaf), }, }, ); @@ -436,7 +452,7 @@ function visitTemporalEvents( } if (right - left === 1) { - assertCoordinateConsistent(state); + assertCoordinateConsistent(state, left); } else { const middle = Math.floor((left + right) / 2); visitTemporalEvents(events, state, node * 2, left, middle); @@ -539,6 +555,7 @@ export function assertTemporalIdentityClosureConsistent( const state: TemporalClosureState = { assertions, + boundaries: sortedBoundaries, context: identityContext, references, referenceIndexByKey, diff --git a/packages/typegraph/src/graph-merge/type-reconcile.ts b/packages/typegraph/src/graph-merge/type-reconcile.ts index 4ccb6f4a6..faf881715 100644 --- a/packages/typegraph/src/graph-merge/type-reconcile.ts +++ b/packages/typegraph/src/graph-merge/type-reconcile.ts @@ -1,4 +1,3 @@ -import { requireDefined } from "../utils/presence"; /** * Opt-in ontology type reconciliation (design §6 / §7, T10). * @@ -6,18 +5,26 @@ import { requireDefined } from "../utils/presence"; * may carry DIFFERING `node.kind` values across branches — e.g. one branch staged * a `Doctor` while another staged the more-specific `SpecialistDoctor` for what is * really the same person. With `reconcileTypes: "ontology"` enabled, this module - * uses the PUBLIC-closure glue (T2a, `closures.ts`) to decide whether those kinds - * are subClassOf-compatible and, if so, collapses the cluster to the - * MOST-SPECIFIC common type, recording a {@link TypeReconciliation}. Genuinely - * incompatible kinds (siblings, disjoint trees) are FLAGGED — never silently - * collapsed — and surfaced as a {@link DroppedItem}. + * uses the store's own validated `KindRegistry` to decide whether those kinds are + * subClassOf-compatible and, if so, collapses the cluster to the MOST-SPECIFIC + * common type, recording a {@link TypeReconciliation}. Genuinely incompatible + * kinds (siblings, disjoint trees) are FLAGGED — never silently collapsed — and + * surfaced as a {@link DroppedItem}. * - * MOST-SPECIFIC = the unique minimum of the subclass partial order restricted to + * `equivalentTo` is mutual subsumption: the registry folds an equivalence + * class into `subClassAncestors`/`subClassDescendants` before its transitive + * closure, so `registry.isAssignableTo` is already true both ways for two + * equivalent kinds. That is what makes "most specific" a single predicate + * (`isAssignableTo`) rather than a subsumption check plus a separate equivalence + * check: reconciling against a private closure and reconciling against the + * registry a query runs on can no longer disagree about which kinds are the same + * class. + * + * MOST-SPECIFIC = the unique minimum of `registry.isAssignableTo` restricted to * the cluster's distinct kinds: the kind `T` such that every OTHER kind in the - * cluster is a (transitive) ancestor of `T` (`isReachable(closure, T, other)`), - * or is EQUIVALENT to `T` (mutual reachability, from folded `equivalentTo` - * relations). If several mutually-equivalent kinds tie for the minimum, the - * lexicographically-smallest representative is chosen so the outcome is + * cluster is assignable to `T` — equal, a (transitive) subclass, or in `T`'s + * folded equivalence class. If several mutually-equivalent kinds tie for the + * minimum, the code-point-smallest representative is chosen so the outcome is * deterministic. If no single minimum exists, the kinds are incompatible. * * This module is a PURE decision function — no I/O, no store access. The @@ -27,9 +34,8 @@ import { requireDefined } from "../utils/presence"; * repointed edges' `fromKind` / `toKind` annotations while keeping endpoint ids * stable (the cascade described in step 2). `mode: "off"` is a guaranteed no-op. */ -import type { SubClassClosure } from "./closures"; -import { isReachable } from "./closures"; import { compareStrings, idOf, type MergeKey } from "./node-key"; +import { compareCodePoints, type KindRegistry } from "./typegraph-internal"; import type { DroppedItem, ReconcileTypesMode, @@ -81,76 +87,40 @@ export type TypeReconcileResult = Readonly<{ dropped: readonly DroppedItem[]; }>; -/** Distinct, lexicographically-sorted kinds — the canonical kind set. */ +/** Distinct kinds in code-point order — the canonical kind set. */ function distinctKinds(kinds: readonly string[]): readonly string[] { - return [...new Set(kinds)].sort((left, right) => compareStrings(left, right)); -} - -/** - * Reports whether two kinds are EQUIVALENT under the closure — distinct names - * that fold to the same `equivalentTo` class, hence mutually reachable. (A kind is - * not equivalent to itself here; identical names are handled by the caller.) - */ -function areEquivalent( - closure: SubClassClosure, - left: string, - right: string, -): boolean { - return ( - left !== right && - isReachable(closure, left, right) && - isReachable(closure, right, left) - ); -} - -/** - * Reports whether `candidate` is "at or below" `other` in the subclass order: - * either it is a (transitive) subclass of `other`, or the two are equivalent. This - * is the predicate the most-specific kind must satisfy against every OTHER kind in - * the cluster. - */ -function isAtOrBelow( - closure: SubClassClosure, - candidate: string, - other: string, -): boolean { - if (candidate === other) { - return true; - } - return ( - isReachable(closure, candidate, other) || - areEquivalent(closure, candidate, other) + return [...new Set(kinds)].toSorted((left, right) => + compareCodePoints(left, right), ); } /** * Finds the MOST-SPECIFIC common kind among `kinds`, or `undefined` if the kinds - * are incompatible (no single minimum of the subclass order). + * are incompatible (no single minimum under `registry.isAssignableTo`). * - * A kind qualifies as the minimum when every OTHER kind is at-or-above it - * ({@link isAtOrBelow}). Several mutually-equivalent kinds can all qualify; the - * lexicographically-smallest qualifier is returned so the choice is deterministic. - * Siblings (e.g. two leaves under a shared parent) and disjoint trees yield no - * qualifier → `undefined` (incompatible). + * A kind qualifies as the minimum when it is assignable to every OTHER kind + * (`registry.isAssignableTo(candidate, other)` — equal, a transitive subclass + * of `other`, or in `other`'s folded `equivalentTo` class). Several + * mutually-equivalent kinds can all qualify; the code-point-smallest + * qualifier is returned so the choice is deterministic. Siblings (e.g. two + * leaves under a shared parent) and disjoint trees yield no qualifier → + * `undefined` (incompatible). */ export function mostSpecificCommonKind( - closure: SubClassClosure, + registry: KindRegistry, kinds: readonly string[], ): string | undefined { const qualifiers = kinds.filter((candidate) => - kinds.every((other) => isAtOrBelow(closure, candidate, other)), - ); - if (qualifiers.length === 0) { - return undefined; - } - return requireDefined( - [...qualifiers].sort((left, right) => compareStrings(left, right))[0], + kinds.every((other) => registry.isAssignableTo(candidate, other)), ); + return qualifiers.toSorted((left, right) => + compareCodePoints(left, right), + )[0]; } /** - * Reconciles the differing kinds of each resolved cluster against the subClassOf - * closure. + * Reconciles the differing kinds of each resolved cluster against the store's + * validated `KindRegistry`. * * For `mode: "off"` (the default) this is a guaranteed no-op: it returns zero * reconciliations, an empty retype map, and zero dropped items, regardless of the @@ -176,12 +146,12 @@ export function mostSpecificCommonKind( * unordered cluster set. * * @param clusters The resolved clusters with their distinct member kinds. - * @param closure The subClassOf closure from {@link buildSubClassClosure} (T2a). + * @param registry The store's validated `KindRegistry`. * @param mode `"off"` (no-op) or `"ontology"` (reconcile). */ export function reconcileTypes( clusters: readonly ReconcileClusterInput[], - closure: SubClassClosure, + registry: KindRegistry, mode: ReconcileTypesMode, ): TypeReconcileResult { if (mode === "off") { @@ -205,7 +175,7 @@ export function reconcileTypes( // The PUBLIC report fields (`entityId`, dropped `id`) carry the bare node id; // the internal retype keys on the full `(kind, id)` identity. const entityId = idOf(cluster.canonicalId); - const toType = mostSpecificCommonKind(closure, kinds); + const toType = mostSpecificCommonKind(registry, kinds); if (toType === undefined) { dropped.push({ kind: "node", diff --git a/packages/typegraph/src/graph-merge/typegraph-internal.ts b/packages/typegraph/src/graph-merge/typegraph-internal.ts index fb0260411..aac7cc10b 100644 --- a/packages/typegraph/src/graph-merge/typegraph-internal.ts +++ b/packages/typegraph/src/graph-merge/typegraph-internal.ts @@ -1,3 +1,5 @@ +export { bindExtraIfReachable } from "../backend/capabilities/bind"; +export { BATCH_POINT_READ } from "../backend/capabilities/bundle-registry"; export { batchPointReadVerdict, recordedRevisionOriginsVerdict, @@ -17,19 +19,19 @@ export { resolveGraphRelationNames, } from "../backend/graph-relations"; export { REVISION_JOURNAL_ENTITY } from "../backend/revision-journal"; -export { - sharesSerializedTransactionResource, - snapshotExportContention, -} from "../backend/transaction-resource"; +export { sharesSerializedTransactionResource } from "../backend/transaction-resource"; export type { EngineRevision, EntityKey, GraphBackend, + GraphReadBackend, LineageDelta, NodeRow, + SchemaVersionRow, TransactionBackend, TransactionOptions, } from "../backend/types"; +export { isLiveNodeRow } from "../backend/types"; export { computeUniqueKey } from "../constraints"; export { defineInternalGraph, @@ -49,22 +51,32 @@ export { TypeGraphError, type TypeGraphErrorOptions, } from "../errors"; +export { + bulkIsSeparated, + separationClassPairKey, + separationFactsEmpty, +} from "../identity/separation"; export type { IdentityTransferAssertion } from "../identity/service"; +export { loadIdentityWindowHistory } from "../identity/service-interchange-write"; +export { currentClassKey } from "../identity/service-mutation"; +export { + loadCurrentStructuralClasses, + loadSpanningDifferentAssertion, +} from "../identity/service-read"; +export type { PlainNodeRef } from "../identity/sql-target"; +export type { IdentityDecisionProvenance } from "../identity/transition-log"; export type { IdentityAssertionWriteFacade, IdentityFacade, + IdentityRelation, } from "../identity/types"; export { exportGraph, exportGraphStream } from "../interchange/export"; export { importGraph, importGraphStream, + streamedImportRefusal, summarizeImportErrors, } from "../interchange/import"; -export { computeTransitiveClosure, isReachable } from "../ontology/closures"; -export { - META_EDGE_EQUIVALENT_TO, - META_EDGE_SAME_AS, -} from "../ontology/constants"; export { getEdgeRowsByIds } from "../store/edge-fetch"; export { getNodeRowsByIds } from "../store/node-fetch"; // The provenance ownership probe needs graph-scoped raw SQL to look for rows @@ -73,18 +85,34 @@ export { createSqlSchema, type ResolvedSqlTableNames, } from "../query/compiler/schema"; +export { getDialect } from "../query/dialect"; export { sql } from "../query/sql-fragment"; export { asCompiledRowsSql, asCompiledStatementSql } from "../query/sql-intent"; +export { orientCompositionEndpoints } from "../registry/composition-relation"; +export { type KindRegistry } from "../registry/kind-registry"; export { sortedReplacer } from "../schema/canonical"; +export { activeSchemaHash } from "../schema/manager"; export { computeSchemaHash, getGraphDefinitionHash, serializeSchema, } from "../schema/serializer"; export { - type OntologyIntrospection, - type UniqueIntrospection, -} from "../store/introspect"; + acyclicEdgeRelations, + type EdgeAcyclicityViolation, + edgeKindIsInAcyclicRelation, + type ProposedRelationEdge, + readProposedEdgeAcyclicityViolations, +} from "../store/acyclicity"; +export { type UniqueIntrospection } from "../store/introspect"; +export { planCompositionCascade } from "../store/operations/composition-cascade"; +export { + compositionEdgeAttachesPart, + findLiveCompositionAttachment, + findLiveCompositionWhole, + withDeferredCompositionExistence, +} from "../store/operations/composition-create"; +export { type NodeDeletePolicy } from "../store/operations/node-write-pipeline"; export { forceWriteTransactionRevision } from "../store/operations/write-transaction"; export { advanceRevisionClock, @@ -97,7 +125,13 @@ export { readRevisionOrigin, recordedRelationsLineage, resolveLineage, + withRecordedIdentityDecision, } from "../store/recorded-capture"; +export { + type GraphWriteLock, + lockRecordedGraphWrite, + uncapturedGraphWriteLock, +} from "../store/recorded-capture/clock"; export { hasScopedIdentityReads, storeBackend, @@ -105,6 +139,8 @@ export { storeQueryBackend, storeRuntime, transactionBackend, + type TransactionDeleteNodeWithPolicy, + transactionDeleteNodeWithPolicy, } from "../store/runtime-port"; export type { Store } from "../store/store"; export { createStore, createStoreWithSchema } from "../store/store"; diff --git a/packages/typegraph/src/graph-merge/types.ts b/packages/typegraph/src/graph-merge/types.ts index f79cd2363..2e4dfd8ea 100644 --- a/packages/typegraph/src/graph-merge/types.ts +++ b/packages/typegraph/src/graph-merge/types.ts @@ -14,7 +14,12 @@ import type { RecordedInstant } from "../core/temporal"; import type { IngestionImportTarget } from "../interchange/ingestion-import-target"; -import type { CandidateDiagnostics, MatchEvidence } from "./evidence"; +import type { + CandidateDiagnostics, + EntityRef, + MatchEvidence, + MatchSource, +} from "./evidence"; import type { EdgeId, EngineRevision, @@ -480,6 +485,12 @@ export type MergeIncrementalArgs = Readonly<{ export type EntityResolution = Readonly<{ canonicalId: NodeId; memberIds: readonly NodeId[]; + /** + * The kind the canonical survivor is written under: its staged kind, or, + * when `reconcileTypes: "ontology"` retypes the cluster, the reconciled + * kind {@link TypeReconciliation.toType} names — the same kind the committed + * row carries. + */ kind: string; branchOrigins: readonly BranchId[]; /** Deterministic minimal accepted-edge witness for this resolution. */ @@ -550,6 +561,32 @@ export type DroppedItem = | Readonly<{ kind: "edge"; id: EdgeId; reason: string }> | Readonly<{ kind: "identity"; id: string; reason: string }>; +/** + * A candidate match the identity ledger vetoed, recorded on + * {@link MergeReport.identityConflicts} instead of silently dropped. A plan + * carrying one is still APPLICABLE: the vetoed match is simply not made, and + * both entities land as they were staged. + * + * Discriminated on `kind` so an exhaustive `switch` stays exhaustive. + */ +export type IdentityUnresolvedConflict = Readonly<{ + /** + * A SCORED match between two entities whose identity classes a `different` + * assertion holds apart. `assertionIds` name the separating assertion. + */ + kind: "separation"; + a: EntityRef; + b: EntityRef; + assertionIds: readonly string[]; + /** + * The recall path that proposed the vetoed match. Absent only for a + * candidate edge carrying no attribution at all, which no shipped source + * produces — modelled as optional rather than asserted so a future + * source cannot make this a crash. + */ + source?: MatchSource | undefined; +}>; + /** * The {@link ValidityEndResolution.precedence} of an entry the INCREMENTAL TARGET * decided rather than the merge: the destination had already moved this row's end @@ -720,4 +757,9 @@ export type MergeReport = Readonly<{ * persistence was off or failed (a failure adds a {@link MergeReport.warnings}). */ provenancePersisted?: Readonly<{ graphId: string; count: number }>; + /** + * Scored candidate matches the always-on identity separation veto dropped + * because a `different` assertion holds the two entities' classes apart. + */ + identityConflicts: readonly IdentityUnresolvedConflict[]; }>; diff --git a/packages/typegraph/src/graph-merge/union-find.ts b/packages/typegraph/src/graph-merge/union-find.ts index d7f58139d..4808834fe 100644 --- a/packages/typegraph/src/graph-merge/union-find.ts +++ b/packages/typegraph/src/graph-merge/union-find.ts @@ -1,9 +1,8 @@ import { requireDefined } from "../utils/presence"; /** * A deterministic disjoint-set forest (union-find), shared by every merge phase - * that folds a set of items into equivalence classes — ontology equivalence - * (`closures.ts`), candidate clustering (`clustering.ts`), and cross-kind identity - * grouping (`sources.ts`). + * that folds a set of items into equivalence classes — candidate clustering + * (`clustering.ts`) and cross-kind identity grouping (`sources.ts`). * * Determinism is the load-bearing property: path compression keeps `find` flat, * and the union rule makes the chosen representative a pure function of the union diff --git a/packages/typegraph/src/graph-merge/working-copy.ts b/packages/typegraph/src/graph-merge/working-copy.ts index f4ae1f722..6306eb0f7 100644 --- a/packages/typegraph/src/graph-merge/working-copy.ts +++ b/packages/typegraph/src/graph-merge/working-copy.ts @@ -6,7 +6,10 @@ * ({@link cloneWorkingCopyStrategy}): `exportGraphStream` the base, then * `importGraphStream` into a fresh store on a caller-provided backend. IDs are * preserved by interchange, so the diff engine (T3) can key on stable ids across - * base and fork. This leverages public entrypoints only, needs zero schema + * base and fork. The clone falls back to a materialized `exportGraph` / + * `importGraph` when a stream cannot run: when the fresh backend shares the + * base's snapshot connection, or when the graph declares required-existence + * composition parts (a streamed import refuses those). This leverages public entrypoints only, needs zero schema * changes, and behaves identically across SQLite and Postgres. * * INTERCHANGE FIDELITY LIMITATION (verified, design §13.x): the interchange @@ -73,8 +76,8 @@ import { importGraphStream, isBackendDerivedFrom, sharesSerializedTransactionResource, - snapshotExportContention, storeBackend, + streamedImportRefusal, summarizeImportErrors, wrapWithManagedClose, } from "./typegraph-internal"; @@ -241,19 +244,18 @@ function cloneWorkingCopyWithGraphStrategy( // Keep descendants branchable with the same O(1) anchor contract, // but do not copy recorded-time history into the disposable fork. // - // Deliberately narrower than Store.workingCopyOptions (the full - // set a fork inherits, see forkStoreOptions): the clone's backend - // is a FRESH, empty database, not a physical copy of the base's, - // so a `schema` naming the base's tables would misdirect writes - // on an unrelated backend, and an external `recordedRead` - // binding would point at a relation the clone never populates. - // Hooks, `coalesceUnchangedUpserts`, `autoRefreshStatistics` and - // `queryDefaults` carry no such physical assumption, but the - // clone strategy is used for host-agnostic P0 branching where the - // caller's `makeBackend` factory — not the base's own - // configuration — owns the fresh store's behavior; only the - // branchability contract (`revisionTracking`) is load-bearing - // enough to thread through unconditionally. + // Narrower than Store.workingCopyOptions (the full set a fork + // inherits, see forkStoreOptions): the clone's backend is a + // FRESH, empty database, not a physical copy of the base's, so a + // `schema` naming the base's tables would misdirect writes on an + // unrelated backend, and an external `recordedRead` binding would + // point at a relation the clone never populates. + // `queryDefaults` decide which rows a query returns, so they are + // carried: the same query must answer alike on the base and on + // its working copy. Hooks, `coalesceUnchangedUpserts` and + // `autoRefreshStatistics` stay with the caller's `makeBackend` + // factory, which owns the fresh store's write-side behavior. + ...cloneQueryDefaults(baseStore), revisionTracking: baseStore.revisionTrackingEnabled, ...(options.revisionJournal === false ? { revisionJournal: false as const } @@ -275,16 +277,13 @@ function cloneWorkingCopyWithGraphStrategy( { refreshStatistics: false as const } : {}), } as const; - // When the fresh backend writes through the connection the base's - // snapshot export would hold, streaming is exactly what the import - // guard refuses — so ask that guard's own predicate, and materialize - // the export instead of streaming it when it says so. + // Materialize the export instead of streaming it whenever the + // streamed import would refuse, as decided by that import's own + // predicate. const result = ( - snapshotExportContention( - storeBackend(baseStore), - storeBackend(freshStore), - ) === undefined + streamedImportRefusal(freshStore, storeBackend(baseStore)) === + undefined ) ? await importGraphStream( freshStore, @@ -318,6 +317,14 @@ function cloneWorkingCopyWithGraphStrategy( }; } +/** The base's query defaults, as the option a cloned store is opened with. */ +function cloneQueryDefaults( + baseStore: Store, +): Pick { + const { queryDefaults } = baseStore.workingCopyOptions; + return queryDefaults === undefined ? {} : { queryDefaults }; +} + /** * A host-level handle to a forked database, produced by * {@link ForkedWorkingCopyOptions.fork} and released by its own `dispose`. diff --git a/packages/typegraph/src/graph-merge/write-fence.ts b/packages/typegraph/src/graph-merge/write-fence.ts index 7f0f59be2..1fd2a6562 100644 --- a/packages/typegraph/src/graph-merge/write-fence.ts +++ b/packages/typegraph/src/graph-merge/write-fence.ts @@ -1,5 +1,8 @@ import { resolveWriteFencePlan } from "../backend/capabilities/write-fence"; -import { graphCommandCoordinationIsolation } from "../backend/command-contract"; +import { + graphCommandCoordinationAcquisition, + observesEveryEarlierFenceHolder, +} from "../backend/command-contract"; import { type TransactionBackend } from "../backend/types"; import { TypeGraphError } from "../errors"; import { lockSchemaVersionForStoreWrite } from "../store/operations/write-transaction"; @@ -35,24 +38,38 @@ export async function lockMergeTargetWrite( } if (input.graphLock === "required") { const lock = await lockRecordedGraphWrite(txBackend, input.graphId); - // Serialized engines already own the writer slot. An advisory-lock token - // carries the isolation observed by the lock statement on this session. + // Serialized engines already own the writer slot. A keyed acquisition's + // token carries what the fence statement observed on this session; a plan + // that yields no token has no evidence to offer. + // + // The applier's reads span the whole target, not one lock-only decision, + // so it must observe EVERY earlier holder of the fence. The fence row + // tells a session only about the holders that advanced it, so advancing + // it here would not make a snapshot isolation safe. if ( input.requireFreshSnapshot && resolveWriteFencePlan(txBackend).kind !== "engine-serialized" ) { - const isolation = + const acquisition = lock.coordination === undefined ? undefined - : graphCommandCoordinationIsolation( + : graphCommandCoordinationAcquisition( txBackend.commands, - input.graphId, lock.coordination, + input.graphId, ); - if (isolation !== "read_committed") { + if ( + acquisition === undefined || + !observesEveryEarlierFenceHolder(acquisition) + ) { throw new MergePlanCapabilityError( "Composed merge application requires observed read-committed isolation so reads observe the target after acquiring its graph lock.", - { details: { capability: "mergeCallbackIsolation", isolation } }, + { + details: { + capability: "mergeCallbackIsolation", + isolation: acquisition?.isolation, + }, + }, ); } } diff --git a/packages/typegraph/src/identity/index.ts b/packages/typegraph/src/identity/index.ts index 555922c5c..ea1325a9f 100644 --- a/packages/typegraph/src/identity/index.ts +++ b/packages/typegraph/src/identity/index.ts @@ -1,4 +1,25 @@ export { rebuildIdentityClosure } from "./rebuild"; +export { + IDENTITY_REPLAY_DEFAULT_LIMIT, + IDENTITY_REPLAY_MAX_LIMIT, + type IdentityHistoryCompleteness, + type IdentityHistoryIncompleteReason, + type IdentityReplay, + type IdentityReplayOptions, + type IdentityReplayStep, + type IdentityTransition, + type IdentityTransitionHistory, + type RecordedIdentityTransition, + type RestoredIdentityTransition, + type TransitionPageCursor, +} from "./replay"; +export { + type IdentityDecisionProvenance, + type IdentityTransitionCause, + type IdentityTransitionCursor, + type IdentityTransitionTransfer, + pruneIdentityTransitions, +} from "./transition-log"; export type { IdentityAssertion, IdentityAssertionId, diff --git a/packages/typegraph/src/identity/lineage-evidence.ts b/packages/typegraph/src/identity/lineage-evidence.ts new file mode 100644 index 000000000..e79ccb772 --- /dev/null +++ b/packages/typegraph/src/identity/lineage-evidence.ts @@ -0,0 +1,156 @@ +/** + * The recorded evidence that ties a node reference to the identity classes it + * has ever belonged to, read from the recorded relations rather than from the + * current closure. + * + * The current closure can only name the class a reference belongs to NOW. A + * member that left its class (a delete, a retraction, a closed window) is a + * singleton there, and no transition note names a non-canonical member, so the + * closure alone cannot lead a lineage walk back to the class it left. What + * still names it is the evidence that put it in the class: every `same` + * assertion version that ever had it as an endpoint, and every node version + * that ever shared its id under a folding graph. A class canonical is always a + * member of the class, and class members are always connected through that + * evidence, so the references reachable through it are a superset of every + * canonical the reference's classes ever carried. + */ +import { type SqlSchema } from "../query/compiler/schema"; +import { sql } from "../query/sql-fragment"; +import { asCompiledRowsSql } from "../query/sql-intent"; +import { chunk } from "../utils/array"; +import { referenceCondition, refKey } from "./service-read"; +import { + identityChunkSize, + type IdentityTarget, + MAX_REFERENCE_CHUNK_SIZE, + type PlainNodeRef, +} from "./sql-target"; +import { type IdentityRelation } from "./types"; + +const SAME_RELATION = "same" satisfies IdentityRelation; + +type RawAssertionEndpointsRow = Readonly<{ + a_kind: string; + a_id: string; + b_kind: string; + b_id: string; +}>; + +type RawNodeReferenceRow = Readonly<{ kind: string; id: string }>; + +/** + * Both endpoints of every `same` assertion version — current, ended, retracted + * or deleted — that ever named one of `references`. + */ +async function readEverAssertedSameEndpoints( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + references: readonly PlainNodeRef[], +): Promise { + const chunkSize = identityChunkSize(target, { + fixedParameters: 2, + maxItems: MAX_REFERENCE_CHUNK_SIZE, + parametersPerItem: 4, + }); + const endpoints: PlainNodeRef[] = []; + for (const referenceChunk of chunk(references, chunkSize)) { + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT DISTINCT a_kind, a_id, b_kind, b_id + FROM ${schema.recordedIdentityAssertionsTable} + WHERE graph_id = ${graphId} + AND rel = ${SAME_RELATION} + AND ( + ${referenceCondition(sql`a_kind`, sql`a_id`, referenceChunk)} + OR ${referenceCondition(sql`b_kind`, sql`b_id`, referenceChunk)} + ) + `), + ); + for (const row of rows) { + endpoints.push( + { kind: row.a_kind, id: row.a_id }, + { kind: row.b_kind, id: row.b_id }, + ); + } + } + return endpoints; +} + +/** Every node version that ever shared an id with one of `references`, whatever its kind. */ +async function readEverRecordedSameIdReferences( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + references: readonly PlainNodeRef[], +): Promise { + const ids = [...new Set(references.map((ref) => ref.id))]; + const chunkSize = identityChunkSize(target, { + fixedParameters: 1, + maxItems: MAX_REFERENCE_CHUNK_SIZE, + parametersPerItem: 1, + }); + const sameId: PlainNodeRef[] = []; + for (const idChunk of chunk(ids, chunkSize)) { + const idList = sql.join( + idChunk.map((id) => sql`${id}`), + sql`, `, + ); + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT DISTINCT kind, id + FROM ${schema.recordedNodesTable} + WHERE graph_id = ${graphId} AND id IN (${idList}) + `), + ); + for (const row of rows) sameId.push({ kind: row.kind, id: row.id }); + } + return sameId; +} + +/** + * The references one evidence hop away from `references`: the other endpoint of + * every `same` assertion that ever named one, and — under a folding graph — + * every node that ever shared an id with one. The caller iterates this to a + * fixed point. + */ +export async function readIdentityEvidenceNeighbours( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + references: readonly PlainNodeRef[], + sameIdAcrossKinds: "fold" | "ignore", +): Promise { + if (references.length === 0) return []; + const asserted = await readEverAssertedSameEndpoints( + target, + schema, + graphId, + references, + ); + if (sameIdAcrossKinds === "ignore") return asserted; + const folded = await readEverRecordedSameIdReferences( + target, + schema, + graphId, + references, + ); + return [...asserted, ...folded]; +} + +/** The references among `references` that have at least one recorded node version. */ +export async function readEverRecordedReferences( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + references: readonly PlainNodeRef[], +): Promise { + const wanted = new Set(references.map((reference) => refKey(reference))); + const sameId = await readEverRecordedSameIdReferences( + target, + schema, + graphId, + references, + ); + return sameId.filter((reference) => wanted.has(refKey(reference))); +} diff --git a/packages/typegraph/src/identity/reference.ts b/packages/typegraph/src/identity/reference.ts index 0af24d085..0ddbac800 100644 --- a/packages/typegraph/src/identity/reference.ts +++ b/packages/typegraph/src/identity/reference.ts @@ -1,10 +1,10 @@ import { compareCodePoints } from "../utils/compare"; -import { encodeTupleKey } from "../utils/tuple-key"; +import { nodeRefKey } from "../utils/tuple-key"; import { type PlainNodeRef } from "./sql-target"; -/** Canonical, injective map key for a node reference. */ +/** Canonical, injective map key for a node reference ({@link nodeRefKey}). */ export function identityReferenceKey(ref: PlainNodeRef): string { - return encodeTupleKey([ref.kind, ref.id]); + return nodeRefKey(ref); } /** Orders identity references by kind, then id, using code-point order. */ diff --git a/packages/typegraph/src/identity/replay.ts b/packages/typegraph/src/identity/replay.ts new file mode 100644 index 000000000..ed1c0051c --- /dev/null +++ b/packages/typegraph/src/identity/replay.ts @@ -0,0 +1,1305 @@ +/** + * The replay algorithm: pairs every identity transition with the class + * membership before and after it, reconstructed through the SAME historical + * reader `asOfRecorded` reads use, at the SAME coordinate + * (`recordedDiagonalCoordinate`): a step's `after` is what + * `store.asOfRecorded(step.transition.recorded)` answers, under the graph's + * current schema. See `historicalIdentityReconstructionCtes` + * (historical-sql.ts), which every membership answer below is produced by, + * through `loadHistoricalClasses` (service-read.ts). The transition log + * itself supplies boundaries and explanations ONLY; it is never consulted for + * membership. + */ +import { requireTypeGraphRecordedRevision } from "../backend/capabilities/recorded-time-ownership"; +import { type GraphDef } from "../core/define-graph"; +import { + createRecordedInstant, + RECORDED_FIRST_REVISION, + recordedDiagonalCoordinate, + type RecordedInstant, + recordedInstantRevision, + recordedInstantWallTime, + type RestoredRecordedInstant, +} from "../core/temporal"; +import { IdentityReplayError, ValidationError } from "../errors"; +import { hasPendingIdentityTransitionNotes } from "../store/recorded-capture"; +import { groupBy } from "../utils/array"; +import { requireDefined } from "../utils/presence"; +import { + readEverRecordedReferences, + readIdentityEvidenceNeighbours, +} from "./lineage-evidence"; +import { identityActiveKinds } from "./service-components"; +import { + type HistoricalClass, + loadCurrentStructuralClasses, + loadHistoricalClasses, + publicNodeRef, + refKey, + registeredPlainRef, +} from "./service-read"; +import { type IdentityServiceContext } from "./service-types"; +import { type PlainNodeRef } from "./sql-target"; +import { + countRestoredIdentityTransitions, + type IdentityDecisionProvenance, + type IdentityTransitionCause, + type IdentityTransitionCursor, + type IdentityTransitionRow, + isRestoredTransitionRow, + readIdentityTransitions, + readTransitionRetentionDetails, + requireIdentityTransitionLog, + transitionClassRef, + transitionPriorClassRef, +} from "./transition-log"; +import { + type IdentityAssertionId, + type IdentityNodeReference, + type IdentityNodeRefInput, +} from "./types"; + +/** Default and maximum boundary counts a single `replay` PAGE returns. */ +export const IDENTITY_REPLAY_DEFAULT_LIMIT = 200; +export const IDENTITY_REPLAY_MAX_LIMIT = 2000; + +/** A transition this graph's own history capture recorded. */ +export type RecordedIdentityTransition = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recorded: RecordedInstant; + validAt: string; + class: IdentityNodeReference; + priorClass?: IdentityNodeReference | undefined; + assertionIds: readonly IdentityAssertionId[]; + decision?: IdentityDecisionProvenance | undefined; + restored?: undefined; +}>; + +/** What every transition carries whoever recorded it. */ +type IdentityTransitionFields = Omit< + RecordedIdentityTransition, + "recorded" | "restored" +>; + +/** + * A transition an ARCHIVAL RESTORE inserted into the graph being read, rather + * than this graph's own history capture recording it — the public face of the + * internal marker `isRestoredTransitionRow` reads. An audit surface uses it to + * tell an imported explanation from a locally replayable event: `replay` + * deliberately excludes every restored transition from `steps` (a restored + * row's revision was minted by the SOURCE graph's clock and interleaves + * arbitrarily with this graph's, so no before/after could be reconstructed for + * it honestly), while `transitionsOf` returns it like any other. + * + * `recorded` is therefore a {@link RestoredRecordedInstant}, not a + * {@link RecordedInstant}. `restored.at` is the destination graph's WALL + * CLOCK at restore time: a restore records history, it does not relive it, so + * it never advances this graph's recorded revision counter and there is no + * revision on this graph's own axis to pair the timestamp with. The coarser + * retention watermark (the `retention-truncated` completeness reason) is the + * only revision-shaped signal a restore leaves behind. + */ +export type RestoredIdentityTransition = Omit< + RecordedIdentityTransition, + "recorded" | "restored" +> & + Readonly<{ + recorded: RestoredRecordedInstant; + restored: Readonly<{ at: string }>; + }>; + +/** One entry of a `transitionsOf` page; `restored` tells the two apart. */ +export type IdentityTransition = + RecordedIdentityTransition | RestoredIdentityTransition; + +/** + * One paired boundary from {@link identityReplay}. Its transition is always + * one this graph recorded: `identityReplay` builds `steps` only from native + * rows, because a restored row's revision is minted by the source graph's + * clock and cannot be paired with a before/after reconstructed on THIS + * graph's historical reader (see {@link RestoredIdentityTransition}). + */ +export type IdentityReplayStep = Readonly<{ + transition: RecordedIdentityTransition; + before: readonly IdentityNodeReference[]; + after: readonly IdentityNodeReference[]; +}>; + +declare const TRANSITION_PAGE_CURSOR_BRAND: unique symbol; + +/** + * The continuation cursor a paged transition read hands back: it names the + * first BOUNDARY the page did not include, and is `undefined` when the page + * reached the end of the lineage. Pass it back as `options.cursor` to read the + * next page; the boundary it names opens that page exactly once. + * + * A cursor is NOT a recorded instant. It is minted from one, including one a + * restored row's SOURCE graph allocated (see {@link RestoredIdentityTransition}), + * so it must never reach `store.asOfRecorded`, which anchors a historical read + * on THIS graph's own recorded axis. To keep the two apart at runtime as well + * as in the type system, its string form carries a prefix no recorded instant + * has: `fromRecorded` / `toRecorded` refuse it, and `cursor` refuses a bare + * recorded instant. + * + * A cursor is also bound to the reference whose lineage it pages. A position + * in one reference's lineage says nothing about another's, so a cursor handed + * to a read for a different reference is refused rather than silently + * skipping every boundary of that reference below the revision it names. + */ +export type TransitionPageCursor = string & { + readonly [TRANSITION_PAGE_CURSOR_BRAND]: "TransitionPageCursor"; +}; + +const TRANSITION_PAGE_CURSOR_PREFIX = "tpc1:"; +const TRANSITION_PAGE_CURSOR_SEPARATOR = ":"; + +function transitionPageCursor( + reference: PlainNodeRef, + revision: number, + recordedAt: string, +): TransitionPageCursor { + // `encodeURIComponent` escapes the separator, so a kind or id containing it + // can never shift where the instant begins. + return [ + `${TRANSITION_PAGE_CURSOR_PREFIX}${encodeURIComponent(reference.kind)}`, + encodeURIComponent(reference.id), + createRecordedInstant(revision, recordedAt), + ].join(TRANSITION_PAGE_CURSOR_SEPARATOR) as TransitionPageCursor; +} + +function isTransitionPageCursorShaped(value: string): boolean { + return value.startsWith(TRANSITION_PAGE_CURSOR_PREFIX); +} + +function invalidCursorError(message: string): ValidationError { + return new ValidationError( + `cursor must be a page cursor returned as nextCursor by a previous transitionsOf / replay page for the same reference. ${message}`, + { issues: [{ path: "cursor", message }] }, + ); +} + +function decodeCursorReference( + encodedKind: string, + encodedId: string, +): PlainNodeRef { + try { + return { + kind: decodeURIComponent(encodedKind), + id: decodeURIComponent(encodedId), + }; + } catch (error) { + if (error instanceof URIError) + throw invalidCursorError("Malformed transition page cursor."); + throw error; + } +} + +/** + * The revision a cursor names, for a read of `reference`'s lineage. Refuses + * anything that was not minted as a cursor, and a cursor minted for another + * reference. + */ +function requireCursorRevision( + cursor: string, + reference: PlainNodeRef, +): number { + if (!isTransitionPageCursorShaped(cursor)) + throw invalidCursorError( + "Not a transition page cursor. A recorded instant belongs in fromRecorded / toRecorded.", + ); + const body = cursor.slice(TRANSITION_PAGE_CURSOR_PREFIX.length); + const kindEnd = body.indexOf(TRANSITION_PAGE_CURSOR_SEPARATOR); + const idEnd = body.indexOf(TRANSITION_PAGE_CURSOR_SEPARATOR, kindEnd + 1); + if (kindEnd === -1 || idEnd === -1) + throw invalidCursorError("Malformed transition page cursor."); + const cursorReference = decodeCursorReference( + body.slice(0, kindEnd), + body.slice(kindEnd + 1, idEnd), + ); + if (refKey(cursorReference) !== refKey(reference)) + throw invalidCursorError( + `This cursor pages the lineage of ${cursorReference.kind}/${cursorReference.id}, not ${reference.kind}/${reference.id}.`, + ); + return requireTypeGraphRecordedRevision(body.slice(idEnd + 1), "cursor"); +} + +/** A window bound is a recorded instant; a page cursor handed to one is a misuse with a specific remedy. */ +function requireWindowRevision( + value: string, + surface: "fromRecorded" | "toRecorded", +): number { + if (isTransitionPageCursorShaped(value)) + throw new ValidationError( + `${surface} must be a recorded instant, not a page cursor.`, + { + issues: [ + { + path: surface, + message: + "Got a transition page cursor. Pass nextCursor as options.cursor.", + }, + ], + }, + ); + return requireTypeGraphRecordedRevision(value, surface); +} + +/** + * One reason a `transitionsOf` / `replay` page is not the whole answer for + * the reference it was asked about. Several can hold at once. + * + * - `more-pages`: `limit` capped the page; `nextCursor` reads the next one. + * - `retention-truncated`: a prune (or an archival restore's floor) removed + * this graph's history below `before`, and the requested range reaches + * below it. + * - `unattributed-restored`: the graph holds `count` transitions an archival + * restore brought in that lineage discovery could neither tie to the + * reference nor rule out. Discovery follows this graph's own recorded + * evidence, and a restore carries none for what happened on the source + * graph before the archive was taken, so a member that had already left its + * class there cannot be traced to the restored rows that explain it. The + * count is graph-wide, so it is the same on every page. + * - `uncaptured-members`: `count` members of the reference's CURRENT class + * (of more than one member) arrived through writes history capture did not + * see: changes committed before `history: true` was enabled, or writes that + * bypassed capture. Either the member's node has no recorded version or no + * recorded evidence ties it to the reference, so the log cannot explain + * how it joined, and transitions that involved it can be missing from the + * page. The count is about the current class, so it is the same on every + * page. Not reported alongside `pending-transaction-notes`, whose + * uncommitted changes capture has not recorded yet either. + * - `restored-not-reconstructed` (`replay` only): `count` restored + * transitions on this page belong to the lineage but are not `steps`, + * because no before/after can be reconstructed on this graph for a + * revision the source graph allocated. `transitionsOf` returns them. + * - `pending-transaction-notes` (`tx.identity` only): the transaction this + * read runs in has made identity changes to this graph whose transitions + * are buffered until it commits, so the log this read answers from does + * not hold them yet. Graph-wide: it does not say whether those changes + * touched this reference's lineage. + */ +export type IdentityHistoryIncompleteReason = + | Readonly<{ reason: "more-pages"; nextCursor: TransitionPageCursor }> + | Readonly<{ reason: "retention-truncated"; before: RecordedInstant }> + | Readonly<{ reason: "unattributed-restored"; count: number }> + | Readonly<{ reason: "uncaptured-members"; count: number }> + | Readonly<{ reason: "restored-not-reconstructed"; count: number }> + | Readonly<{ reason: "pending-transaction-notes" }>; + +/** + * Whether a `transitionsOf` / `replay` page is the whole answer the + * transition log can give: the one place to check before treating a short or + * empty page as "nothing else happened". The log holds only what history + * capture saw. `uncaptured-members` reports the current members it never + * saw; a change made before capture was enabled that the current class no + * longer shows, or that involved a node before capture first recorded it, + * leaves nothing to detect. Every step is reconstructed under the graph's current schema + * whatever this says; that is a property of replay, not of a page. + */ +export type IdentityHistoryCompleteness = + | Readonly<{ status: "complete" }> + | Readonly<{ + status: "partial"; + reasons: readonly IdentityHistoryIncompleteReason[]; + }>; + +export type IdentityReplay = Readonly<{ + steps: readonly IdentityReplayStep[]; + /** + * Set when `limit` capped this page; pass it as `cursor` for the next one. + * Addresses the TRANSITION LOG only — like + * {@link RestoredIdentityTransition}'s `recorded`, this can name a revision a + * restored row's SOURCE graph allocated, not this graph. Never pass it to + * `store.asOfRecorded`; pass it only as `cursor`. + */ + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +/** One page of {@link identityTransitionsOf}'s answer. */ +export type IdentityTransitionHistory = Readonly<{ + transitions: readonly IdentityTransition[]; + /** + * Set when `limit` capped this page; pass it as `cursor` for the next one. + * Addresses the TRANSITION LOG only — like + * {@link RestoredIdentityTransition}'s `recorded`, this can name a revision a + * restored row's SOURCE graph allocated, not this graph. Never pass it to + * `store.asOfRecorded`; pass it only as `cursor`. + */ + nextCursor?: TransitionPageCursor | undefined; + completeness: IdentityHistoryCompleteness; +}>; + +/** + * `fromRecorded` / `toRecorded` bound the recorded window (inclusive). + * `cursor` continues a paged read from the `nextCursor` a previous page + * returned. The cursor is a position within the windowed lineage, so it + * composes with the window by intersection: the page starts at the later of + * `fromRecorded` and the cursor, and ends at `toRecorded`. A cursor combined + * with a different window than the one it came from is therefore well + * defined, never an error. + */ +export type IdentityReplayOptions = Readonly<{ + fromRecorded?: string | undefined; + toRecorded?: string | undefined; + cursor?: TransitionPageCursor | undefined; + limit?: number | undefined; +}>; + +function transitionFields( + row: IdentityTransitionRow, +): IdentityTransitionFields { + const priorClassRef = transitionPriorClassRef(row); + return { + transitionId: row.transition_id, + cause: row.cause, + validAt: row.valid_at, + class: publicNodeRef(transitionClassRef(row)), + priorClass: + priorClassRef === undefined ? undefined : publicNodeRef(priorClassRef), + assertionIds: row.assertion_ids as readonly IdentityAssertionId[], + decision: row.decision, + }; +} + +function recordedTransition( + row: IdentityTransitionRow, +): RecordedIdentityTransition { + return { + ...transitionFields(row), + recorded: createRecordedInstant(row.recorded_revision, row.recorded_at), + }; +} + +function publicTransition( + row: IdentityTransitionRow, +): IdentityTransition { + if (!isRestoredTransitionRow(row)) return recordedTransition(row); + return { + ...transitionFields(row), + recorded: createRecordedInstant( + row.recorded_revision, + row.recorded_at, + ) as string as RestoredRecordedInstant, + restored: { at: row.restored_at }, + }; +} + +/** + * Resolves `ref`'s CURRENT class (one closure probe), canonical first. The + * canonical is one of the seeds of the lineage WALK (see + * {@link lineageWalkStart}) — never the historical reconstruction. + * Every transition note names the class canonical — never an arbitrary + * member — as its `class` / `priorClass` columns, so `walkClassLineage` must + * be seeded from the canonical, not from `ref` itself: in a k-member class, + * k-1 members are non-canonical and no note ever names them, so seeding the + * walk with one of them finds nothing. + * + * `reconstructAt`, by contrast, keeps reconstructing against the CALLER's + * original `ref` throughout `identityReplay` — never this resolved canonical. + * The canonical is a label that can itself postdate part of the walked + * lineage (the member that ends up canonical need not be the oldest one), so + * reconstructing at an early boundary through it can ask about a node that + * did not exist yet and get back an empty class where the caller's own `ref` + * already had one. `historicalIdentityReconstructionCtes` resolves the same + * class from any live member, canonical or not, so anchoring on the ORIGINAL + * `ref` — which is guaranteed to exist for every boundary the walk from its + * own canonical discovers — is both correct and what the brute-force + * oracle (`store.asOfRecorded(r).identity.membersOf(seed)`, using that same + * original `ref`) is checked against. + */ +async function currentClassMembers( + ctx: IdentityServiceContext, + ref: PlainNodeRef, +): Promise { + const classes = await loadCurrentStructuralClasses( + ctx.backend, + ctx.schema, + ctx.graphId, + [ref], + ); + return requireDefined(classes.get(refKey(ref))); +} + +/** + * Every reference `ref` was ever tied to by recorded identity evidence — the + * fixed point of {@link readIdentityEvidenceNeighbours} from `ref` itself. + * + * This is what makes a DEPARTED member's lineage discoverable. Once a member + * leaves its class its current canonical is itself, and its own departure is + * noted against the surviving class's canonical (`diffClosureTransitions`'s + * "absent from the new state" branch), so {@link currentClassMembers}'s canonical + * alone seeds a walk that matches nothing. The evidence that once put the + * member in the class still names it, and every canonical its classes ever + * carried is reachable through that evidence, so seeding the walk with this + * set reaches the notes the current closure can no longer point at. + * + * Bounded by the same ceiling as the walk it seeds, for the same reason: the + * alternative to refusing is an unbounded read. + */ +async function everConnectedReferences( + ctx: IdentityServiceContext, + ref: PlainNodeRef, +): Promise { + const connected = new Map([[refKey(ref), ref]]); + let frontier: readonly PlainNodeRef[] = [ref]; + while (frontier.length > 0) { + const neighbours = await readIdentityEvidenceNeighbours( + ctx.backend, + ctx.schema, + ctx.graphId, + frontier, + ctx.sameIdAcrossKinds, + ); + const discovered = new Map(); + for (const neighbour of neighbours) { + const key = refKey(neighbour); + if (!connected.has(key)) discovered.set(key, neighbour); + } + for (const [key, neighbour] of discovered) connected.set(key, neighbour); + if (connected.size > IDENTITY_REPLAY_WALK_ROW_CEILING) { + throw identityReplayWalkIncompleteError(IDENTITY_REPLAY_WALK_ROW_CEILING); + } + frontier = [...discovered.values()]; + } + return [...connected.values()]; +} + +type LineageWalkStart = Readonly<{ + seeds: readonly PlainNodeRef[]; + uncapturedMembers: number; +}>; + +/** + * Where the lineage walk begins: `ref`'s current class canonical (see + * {@link currentClassMembers}) together with everything recorded evidence + * ever tied `ref` to (see {@link everConnectedReferences}). The ONE owner of + * "where does a lineage walk begin", so `transitionsOf` and `replay` cannot + * discover different lineages for the same reference. + * + * It also counts the current class members history capture has no record + * of ({@link uncapturedClassMembers}). + */ +async function lineageWalkStart( + ctx: IdentityServiceContext, + ref: PlainNodeRef, +): Promise { + const members = await currentClassMembers(ctx, ref); + const connected = await everConnectedReferences(ctx, ref); + return { + seeds: [requireDefined(members[0]), ...connected], + uncapturedMembers: await uncapturedClassMembers(ctx, members, connected), + }; +} + +/** + * How many members of a class of more than one member history capture has + * no record of (`0` when it has a record of all of them, and for a singleton, + * whose membership needs no explaining). + * + * A member is unrecorded when its node has no recorded version, so the + * historical reader that decides which transitions belong to the reference + * cannot place it in any class, or when no recorded evidence connects it to + * the reference. Every member of a class is tied to every other by `same` + * assertions and same-id peers; capture records each of those it sees, and + * {@link everConnectedReferences} follows every recorded one, so a member it + * does not reach joined through evidence capture never saw. Either way the + * member arrived through a write capture did not see: one committed before + * `history: true` was enabled, or one that bypassed capture. + */ +async function uncapturedClassMembers( + ctx: IdentityServiceContext, + members: readonly PlainNodeRef[], + connected: readonly PlainNodeRef[], +): Promise { + if (members.length < 2) return 0; + const recorded = await readEverRecordedReferences( + ctx.backend, + ctx.schema, + ctx.graphId, + members, + ); + const recordedKeys = new Set(recorded.map((member) => refKey(member))); + const connectedKeys = new Set( + connected.map((reference) => refKey(reference)), + ); + return members.filter( + (member) => + !recordedKeys.has(refKey(member)) || !connectedKeys.has(refKey(member)), + ).length; +} + +/** + * How many restored transitions the walk could neither attribute to the + * lineage nor rule out of it (`0` when there are none). + * + * A restored row names class canonicals on the SOURCE graph's timeline. The + * evidence the walk is seeded from is this graph's own recorded relations, + * which hold nothing about what happened on the source before the restore, so + * a restored row the walk did not reach may still belong to this reference — + * a member that had already left its class when the archive was taken leaves + * no trace here to find it by. That cannot be told apart from a row that is + * simply about another class, so it is reported rather than silently omitted. + */ +async function unattributedRestoredTransitions( + ctx: IdentityServiceContext, + discovered: readonly IdentityTransitionRow[], +): Promise { + const restored = await countRestoredIdentityTransitions( + ctx.backend, + ctx.schema, + ctx.graphId, + ); + const attributed = discovered.filter((row) => + isRestoredTransitionRow(row), + ).length; + return Math.max(restored - attributed, 0); +} + +/** + * Page size for each ROUND-TRIP the seed-lineage walk issues while reading + * one round to exhaustion (see {@link walkClassLineage}): generous enough + * that a typical lineage's round completes in a single page, but never the + * caller's `limit`, which caps the PAGE assembled from the fully-converged + * result below. + */ +const IDENTITY_REPLAY_WALK_PAGE_SIZE = 100_000; + +/** + * Safety backstop on the fixed-point walk's TOTAL accumulated row count, + * across every round and every page within a round combined. This is not a + * bound on any lineage's legitimate size — a real lineage, however large, + * pages through {@link IDENTITY_REPLAY_WALK_PAGE_SIZE}-sized reads via the + * `after` keyset cursor with no destructive remedy required, up to this + * ceiling. Reached only by a pathologically large or corrupted transition + * log, where the alternative is an unbounded read; see + * `identityReplayWalkIncompleteError`. + */ +const IDENTITY_REPLAY_WALK_ROW_CEILING = 2_000_000; + +/** + * The fixed-point class-lineage walk: starting from + * {@link lineageWalkStart}'s seeds, repeatedly reads every transition touching the + * known set of class keys (forward AND reverse — a note's `class` or + * `priorClass`), adding any newly discovered keys, until a round adds + * nothing new. + * + * DISCOVERY IS UNBOUNDED BY THE CALLER'S WINDOW, deliberately: the seed set + * has to reach every class name the node ever carried before any window can + * be applied to the result. A class key enters the set only by appearing on + * some note, and the note that names it can sit anywhere on the recorded + * axis — typically ABOVE the requested window, since the walk starts from + * the node's CURRENT canonical and works backwards through `priorClass` + * hops. Scoping the read by the caller's `fromRecorded`/`toRecorded` cut + * exactly those hops and silently returned an empty lineage for a window + * whose transitions were sitting in the log: B and C merge at revision 1, A + * joins and becomes canonical at revision 2, and a walk bounded at revision + * 1 never sees the revision-2 note that would have taught it B's name. + * `readIdentityTransitions` therefore takes no revision bounds at all + * (transition-log.ts) — the window is applied once, by + * {@link windowedRows}, to the converged result. + * + * DISCOVERY IS ALSO UNBOUNDED BY ROW COUNT, deliberately: each round pages + * to exhaustion through `readIdentityTransitions`'s `after` keyset cursor + * rather than capping at a fixed ceiling, so a class lineage with more rows + * than any one page holds still converges correctly — it costs more round + * trips, never an incomplete or refused read. Only + * {@link IDENTITY_REPLAY_WALK_ROW_CEILING}, a backstop against a + * pathologically large or corrupted log, can still refuse. + */ +async function walkClassLineage( + ctx: IdentityServiceContext, + walkSeeds: readonly PlainNodeRef[], +): Promise { + const seeds = new Map( + walkSeeds.map((seed) => [refKey(seed), seed]), + ); + let totalRowsRead = 0; + for (;;) { + const scopeReferences = [...seeds.values()]; + const roundRows: IdentityTransitionRow[] = []; + let after: IdentityTransitionCursor | undefined; + for (;;) { + const page = await readIdentityTransitions( + ctx.backend, + ctx.schema, + ctx.graphId, + { + classRefs: scopeReferences, + limit: IDENTITY_REPLAY_WALK_PAGE_SIZE, + ...(after === undefined ? {} : { after }), + }, + ); + roundRows.push(...page); + totalRowsRead += page.length; + if (totalRowsRead > IDENTITY_REPLAY_WALK_ROW_CEILING) { + throw identityReplayWalkIncompleteError( + IDENTITY_REPLAY_WALK_ROW_CEILING, + ); + } + if (page.length < IDENTITY_REPLAY_WALK_PAGE_SIZE) break; + const lastOfPage = requireDefined(page.at(-1)); + after = { + recordedRevision: lastOfPage.recorded_revision, + transitionId: lastOfPage.transition_id, + }; + } + let grew = false; + for (const row of roundRows) { + const classRef = transitionClassRef(row); + const priorClassRef = transitionPriorClassRef(row); + if (!seeds.has(refKey(classRef))) { + seeds.set(refKey(classRef), classRef); + grew = true; + } + if (priorClassRef !== undefined && !seeds.has(refKey(priorClassRef))) { + seeds.set(refKey(priorClassRef), priorClassRef); + grew = true; + } + } + if (!grew) return roundRows; + } +} + +/** + * How a reconstruction reads identity: under which same-id profile and over + * which node kinds. The graph's CURRENT reading is what a recorded view uses; + * {@link membershipReadings} adds the readings an earlier schema could have + * used. + */ +type IdentityReading = Readonly<{ + sameIdAcrossKinds: "fold" | "ignore"; + kinds: ReadonlySet; +}>; + +function currentReading( + ctx: IdentityServiceContext, +): IdentityReading { + return { + sameIdAcrossKinds: ctx.sameIdAcrossKinds, + kinds: identityActiveKinds(ctx.registry), + }; +} + +/** + * Reconstructs the visible class membership of `seed` at `recordedAsOf`, on + * the diagonal `store.asOfRecorded` reads: the anchor's revision, seen from + * the valid instant the anchor carries. That shared coordinate + * ({@link recordedDiagonalCoordinate}) is what makes a step's `after` and + * `store.asOfRecorded(step.transition.recorded).identity.membersOf` one read, + * and what keeps a step's answer fixed: a validity window that was still + * open when the step was recorded stays open in the step, however long after + * it lapses the replay is asked for. + * + * Unless a `reading` is given, the class is read under the graph's CURRENT + * identity profile and node kinds, exactly as a recorded view is. A step + * recorded before a `sameIdAcrossKinds` flip or a kind removal is therefore + * shown as the current schema reads that revision, not as the schema then in + * force did. + */ +async function reconstructAt( + ctx: IdentityServiceContext, + seed: PlainNodeRef, + recordedAsOf: RecordedInstant, + reading: IdentityReading = currentReading(ctx), +): Promise { + const classes = await loadHistoricalClasses( + ctx.backend, + ctx.schema, + ctx.graphId, + [seed], + recordedDiagonalCoordinate(recordedAsOf), + reading.sameIdAcrossKinds, + reading.kinds, + ); + return requireDefined(classes.get(refKey(seed))); +} + +const NO_HISTORICAL_CLASS: HistoricalClass = { + structural: [], + visible: [], + foldEligible: [], +}; + +/** + * The membership immediately BEFORE the commit recorded at `boundary`: one + * revision earlier, seen from the same valid instant, so the only thing that + * differs between a step's `before` and `after` is that one commit. Nothing + * was recorded before revision 1. + */ +async function reconstructBefore( + ctx: IdentityServiceContext, + seed: PlainNodeRef, + boundary: RecordedInstant, + reading?: IdentityReading, +): Promise { + const priorRevision = recordedInstantRevision(boundary) - 1; + if (priorRevision < RECORDED_FIRST_REVISION) return NO_HISTORICAL_CLASS; + return reconstructAt( + ctx, + seed, + createRecordedInstant(priorRevision, recordedInstantWallTime(boundary)), + reading, + ); +} + +/** `seed`'s class on either side of one native boundary. */ +type BoundaryClasses = Readonly<{ + before: HistoricalClass; + after: HistoricalClass; +}>; + +/** + * The readings a row's membership is judged under, current reading first. + * + * A note names a class under the schema in force when it was written, and a + * kind drop or a same-id profile change is exactly what makes today's reading + * unable to see that class: the dropped kind's members are not in it, and a + * cross-kind pair is no longer one class. Judging such a note by today's + * reading alone would discard the history that explains how the member got + * where it is. The log itself says which schema changes touched this lineage, + * so the readings are widened by exactly that: every kind a note names (not + * only the registered ones), and, when a schema transition was noted, the + * other same-id profile. + */ +function membershipReadings( + ctx: IdentityServiceContext, + lineage: readonly IdentityTransitionRow[], +): readonly IdentityReading[] { + const current = currentReading(ctx); + const namedKinds = new Set(current.kinds); + for (const row of lineage) { + namedKinds.add(row.class_kind); + if (row.prior_class_kind !== undefined) + namedKinds.add(row.prior_class_kind); + } + const profiles: readonly ("fold" | "ignore")[] = + lineage.some((row) => row.cause === "schema-transition") ? + ["fold", "ignore"] + : [current.sameIdAcrossKinds]; + const widened = profiles + .map((sameIdAcrossKinds) => ({ sameIdAcrossKinds, kinds: namedKinds })) + .filter( + (reading) => + reading.sameIdAcrossKinds !== current.sameIdAcrossKinds || + reading.kinds.size !== current.kinds.size, + ); + return [current, ...widened]; +} + +/** + * Whether a native transition row explains a change to the class `seed` + * belonged to on either side of its boundary: its `class` or `priorClass` + * canonical is a member of that class just before or at the boundary, under + * any of the {@link membershipReadings}. + * + * The lineage walk reaches every class `seed`'s evidence ever touched, + * including classes it has since left, so a row it finds is about `seed` only + * while `seed` was in that class. Membership is the STRUCTURAL class (every + * member the reconstruction reaches, visible or not), because a note names + * its class's closure canonical, which can be a member whose own validity + * window has not opened or has already closed at the boundary. + */ +function namesBoundaryClass( + row: IdentityTransitionRow, + members: ReadonlySet, +): boolean { + const priorClassRef = transitionPriorClassRef(row); + return ( + members.has(refKey(transitionClassRef(row))) || + (priorClassRef !== undefined && members.has(refKey(priorClassRef))) + ); +} + +/** Every reference `seed`'s class held on either side of a boundary under `reading`. */ +async function structuralMembersAround( + ctx: IdentityServiceContext, + seed: PlainNodeRef, + recordedAsOf: RecordedInstant, + reading: IdentityReading, +): Promise { + const [before, after] = [ + await reconstructBefore(ctx, seed, recordedAsOf, reading), + await reconstructAt(ctx, seed, recordedAsOf, reading), + ]; + return [...before.structural, ...after.structural]; +} + +function invalidReplayLimitError( + requirement: string, + resolved: number, +): ValidationError { + return new ValidationError(`replay limit must be ${requirement}.`, { + issues: [{ path: "limit", message: `Got ${String(resolved)}.` }], + }); +} + +/** + * The typed refusal `walkClassLineage` throws when + * {@link IDENTITY_REPLAY_WALK_ROW_CEILING} is exceeded. Exported (module + * path only, not through a package barrel — mirrors how `readIdentityTransitions` + * is imported "by module path directly" in transition-log.ts's own tests) so + * a unit test can assert its `code`, `ceiling` detail, and suggestion string + * directly, without constructing a lineage large enough to trigger it for + * real. + */ +export function identityReplayWalkIncompleteError( + ceiling: number, +): IdentityReplayError { + return new IdentityReplayError( + "Identity replay's lineage walk accumulated more transition rows than its internal safety ceiling allows, so completeness cannot be guaranteed.", + { code: "IDENTITY_REPLAY_WALK_INCOMPLETE", ceiling }, + { + suggestion: + "Prune older transitions with pruneIdentityTransitions. Narrowing fromRecorded/toRecorded does not help: lineage discovery reads the whole log on purpose, so that a window can never hide the notes that name a class.", + }, + ); +} + +/** + * A page size must be a whole number of at least one. The lower bound is not + * decoration: {@link pageBoundaries} cuts the page at `boundaries[limit]`, so + * `limit: 0` would hand back an empty page whose `nextCursor` names the very + * first boundary — re-issuing with that cursor returns the identical empty + * page forever, and the documented `while (cursor !== undefined)` paging loop + * never terminates. A fractional limit indexes past a boundary that is not + * there and escapes as a bare `TypeError` from `requireDefined`. + */ +function resolveLimit(limit: number | undefined): number { + const resolved = limit ?? IDENTITY_REPLAY_DEFAULT_LIMIT; + if (!Number.isInteger(resolved) || resolved < 1) + throw invalidReplayLimitError("an integer of at least 1", resolved); + if (resolved > IDENTITY_REPLAY_MAX_LIMIT) + throw invalidReplayLimitError( + `at most ${String(IDENTITY_REPLAY_MAX_LIMIT)}`, + resolved, + ); + return resolved; +} + +function laterRevision( + left: number | undefined, + right: number | undefined, +): number | undefined { + if (left === undefined || right === undefined) return left ?? right; + return Math.max(left, right); +} + +/** The rows of each boundary (distinct recorded revision), ascending, each group in arrival order. */ +function groupByBoundary( + rows: readonly IdentityTransitionRow[], +): readonly (readonly IdentityTransitionRow[])[] { + return [...groupBy(rows, (row) => row.recorded_revision).entries()] + .toSorted(([left], [right]) => left - right) + .map(([, group]) => group); +} + +/** Applies the caller's recorded window to the fully-discovered lineage — the ONE place `fromRecorded`/`toRecorded` narrow anything, since discovery itself must stay unbounded (see {@link walkClassLineage}). */ +function windowedRows( + rows: readonly IdentityTransitionRow[], + fromRevision: number | undefined, + toRevision: number | undefined, +): readonly IdentityTransitionRow[] { + return rows.filter( + (row) => + (fromRevision === undefined || row.recorded_revision >= fromRevision) && + (toRevision === undefined || row.recorded_revision <= toRevision), + ); +} + +/** + * One boundary of a page: the rows kept at one recorded revision and, when any + * of them is native, the seed's class on either side of it. + */ +type MemberBoundary = Readonly<{ + rows: readonly IdentityTransitionRow[]; + classes?: BoundaryClasses | undefined; +}>; + +type MemberBoundaryPage = Readonly<{ + boundaries: readonly MemberBoundary[]; + nextCursor?: TransitionPageCursor | undefined; +}>; + +/** + * Keeps, of one boundary's rows, the ones that belong to `seed`'s own history: + * a native row only while `seed` was a member of the class it names (see + * {@link namesBoundaryClass}), and every restored row. A restored row names a + * class on the SOURCE graph's timeline, which this graph cannot reconstruct, + * so it stays governed by discovery alone and is reported through + * `completeness` (`unattributed-restored`) when discovery cannot vouch for it. + */ +async function memberBoundary( + ctx: IdentityServiceContext, + seed: PlainNodeRef, + readings: readonly IdentityReading[], + rows: readonly IdentityTransitionRow[], +): Promise { + const firstNative = rows.find((row) => !isRestoredTransitionRow(row)); + if (firstNative === undefined) return { rows }; + // Every native row of one boundary was flushed by one commit and carries + // its recorded instant. + const recordedAsOf = createRecordedInstant( + firstNative.recorded_revision, + firstNative.recorded_at, + ); + const classes: BoundaryClasses = { + before: await reconstructBefore(ctx, seed, recordedAsOf), + after: await reconstructAt(ctx, seed, recordedAsOf), + }; + const members = new Set( + [...classes.before.structural, ...classes.after.structural].map((member) => + refKey(member), + ), + ); + for (const reading of readings.slice(1)) { + for (const member of await structuralMembersAround( + ctx, + seed, + recordedAsOf, + reading, + )) + members.add(refKey(member)); + } + const kept = rows.filter( + (row) => isRestoredTransitionRow(row) || namesBoundaryClass(row, members), + ); + return { + rows: kept, + ...(kept.some((row) => !isRestoredTransitionRow(row)) ? { classes } : {}), + }; +} + +/** + * Cuts the windowed lineage into one page of at most `limit` BOUNDARIES + * (distinct recorded revisions) of `seed`'s own history, not `limit` rows — a + * page counts boundaries, and every note sharing a boundary shares one replay + * step, so a row-counted page could split a single step's rows across two + * pages. A boundary none of whose rows belongs to `seed` (see + * {@link memberBoundary}) is skipped, and does not count. + * + * Boundaries are judged in order and only until the page is full, so a page + * reconstructs the boundaries it returns plus the ones it skipped on the way, + * never the whole lineage. `nextCursor` names the first kept boundary this + * page did NOT include, so a caller pages by re-issuing the identical call + * with `cursor: nextCursor` (the cursor's boundary is inclusive). One owner for + * both `transitionsOf` and `replay`: they page identically, or a caller + * pairing an explanation from one with a membership step from the other would + * see the two disagree about where the page ended. + */ +async function pageMemberBoundaries( + ctx: IdentityServiceContext, + seed: PlainNodeRef, + readings: readonly IdentityReading[], + rows: readonly IdentityTransitionRow[], + limit: number, +): Promise { + const boundaries: MemberBoundary[] = []; + for (const group of groupByBoundary(rows)) { + const boundary = await memberBoundary(ctx, seed, readings, group); + const first = boundary.rows[0]; + if (first === undefined) continue; + if (boundaries.length === limit) { + return { + boundaries, + nextCursor: transitionPageCursor( + seed, + first.recorded_revision, + first.recorded_at, + ), + }; + } + boundaries.push(boundary); + } + return { boundaries }; +} + +function identityReplayHistoryTruncatedError( + requestedFrom: string | undefined, + requestedTo: string, + prunedBefore: string, +): IdentityReplayError { + return new IdentityReplayError( + "The requested replay range lies entirely below the retention watermark: its history has been pruned.", + { + code: "IDENTITY_REPLAY_HISTORY_TRUNCATED", + ...(requestedFrom === undefined ? {} : { requestedFrom }), + requestedTo, + prunedBefore, + }, + { + suggestion: + "Request a range at or after the watermark, or omit fromRecorded to see everything retained.", + }, + ); +} + +/** + * The facts a page's {@link IdentityHistoryCompleteness} is decided from, as + * the read gathered them. A count of `0` and an absent value both mean "does + * not apply". + */ +type HistoryCompletenessFacts = Readonly<{ + nextCursor?: TransitionPageCursor | undefined; + truncatedBefore?: RecordedInstant | undefined; + unattributedRestored: number; + uncapturedMembers: number; + restoredNotReconstructed: number; + pendingTransactionNotes: boolean; +}>; + +/** + * The ONE owner of a page's completeness, so `transitionsOf` and `replay` + * cannot report the same facts differently. Reasons are listed in a fixed + * order: pagination, retention, then what discovery, capture and + * reconstruction could not account for, then the enclosing transaction. + */ +function historyCompleteness( + facts: HistoryCompletenessFacts, +): IdentityHistoryCompleteness { + const reasons: IdentityHistoryIncompleteReason[] = []; + if (facts.nextCursor !== undefined) + reasons.push({ reason: "more-pages", nextCursor: facts.nextCursor }); + if (facts.truncatedBefore !== undefined) + reasons.push({ + reason: "retention-truncated", + before: facts.truncatedBefore, + }); + if (facts.unattributedRestored > 0) + reasons.push({ + reason: "unattributed-restored", + count: facts.unattributedRestored, + }); + // A transaction's own uncommitted changes reach the recorded relations only + // when it commits, so while it holds pending notes a member it joined + // would count as uncaptured; the pending reason already covers that page. + if (facts.uncapturedMembers > 0 && !facts.pendingTransactionNotes) + reasons.push({ + reason: "uncaptured-members", + count: facts.uncapturedMembers, + }); + if (facts.restoredNotReconstructed > 0) + reasons.push({ + reason: "restored-not-reconstructed", + count: facts.restoredNotReconstructed, + }); + if (facts.pendingTransactionNotes) + reasons.push({ reason: "pending-transaction-notes" }); + return reasons.length === 0 ? + { status: "complete" } + : { status: "partial", reasons }; +} + +type TransitionRetention = Readonly<{ + prunedBeforeRevision: number; + prunedAt: string; +}>; + +/** + * The retention watermark as a page reports it: the instant below which this + * graph's history is gone, when the page's range starts below it. A page + * that starts at or above the watermark (a later cursor page, or a + * `fromRecorded` above it) is unaffected by it. + */ +function truncatedBeforeFor( + retention: TransitionRetention, + fromRevision: number | undefined, +): RecordedInstant | undefined { + return retention.prunedBeforeRevision > (fromRevision ?? 0) ? + createRecordedInstant(retention.prunedBeforeRevision, retention.prunedAt) + : undefined; +} + +type WalkedTransitions = Readonly<{ + toRevision: number | undefined; + retention: TransitionRetention; + boundaries: readonly MemberBoundary[]; + completenessFacts: Omit; +}>; + +/** + * The shared setup both `identityTransitionsOf` and `identityReplay` need + * before they diverge: enforce `history: true`, resolve the requested limit + * and revision bounds, seed and run the lineage walk, narrow the converged + * result to the requested window, cut one page of at most `limit` boundaries + * of the reference's own history from it, and gather the facts the page's + * completeness is decided from. Classes are reconstructed against the + * caller's ORIGINAL reference (not the resolved class canonical) — see + * `reconstructAt`'s docblock for why the two must not be conflated. + * + * The three stages are ordered and cannot be reshuffled: discovery must run + * unbounded, the window applies to what discovery found, and the page is cut + * from what the window kept. + */ +async function walkedTransitionsFor( + ctx: IdentityServiceContext, + ref: IdentityNodeRefInput, + options: IdentityReplayOptions | undefined, +): Promise { + requireIdentityTransitionLog(ctx); + const limit = resolveLimit(options?.limit); + const seed = registeredPlainRef(ctx, ref); + const windowFromRevision = + options?.fromRecorded === undefined ? + undefined + : requireWindowRevision(options.fromRecorded, "fromRecorded"); + const toRevision = + options?.toRecorded === undefined ? + undefined + : requireWindowRevision(options.toRecorded, "toRecorded"); + const cursorRevision = + options?.cursor === undefined ? + undefined + : requireCursorRevision(options.cursor, seed); + const fromRevision = laterRevision(windowFromRevision, cursorRevision); + const start = await lineageWalkStart(ctx, seed); + const discovered = await walkClassLineage(ctx, start.seeds); + const unattributedRestored = await unattributedRestoredTransitions( + ctx, + discovered, + ); + const page = await pageMemberBoundaries( + ctx, + seed, + membershipReadings(ctx, discovered), + windowedRows(discovered, fromRevision, toRevision), + limit, + ); + const retention = await readTransitionRetentionDetails( + ctx.backend, + ctx.schema, + ctx.graphId, + ); + return { + toRevision, + retention, + boundaries: page.boundaries, + completenessFacts: { + nextCursor: page.nextCursor, + truncatedBefore: truncatedBeforeFor(retention, fromRevision), + unattributedRestored, + uncapturedMembers: start.uncapturedMembers, + pendingTransactionNotes: hasPendingIdentityTransitionNotes( + ctx.backend, + ctx.graphId, + ), + }, + }; +} + +/** + * Every transition that changed `ref`'s own identity class — the class it was + * a member of on either side of the transition — ascending by recorded + * revision, in pages of at most `limit` boundaries. A class `ref` has left + * keeps a history of its own, and none of it is reported here. A range below + * the retention watermark is reported through `completeness`, never refused. + * `store.identity.transitionsOf` is a thin wrapper over this. + */ +export async function identityTransitionsOf( + ctx: IdentityServiceContext, + ref: IdentityNodeRefInput, + options?: IdentityReplayOptions, +): Promise> { + const { boundaries, completenessFacts } = await walkedTransitionsFor( + ctx, + ref, + options, + ); + const { nextCursor } = completenessFacts; + return { + transitions: boundaries.flatMap((boundary) => + boundary.rows.map((row) => publicTransition(row)), + ), + ...(nextCursor === undefined ? {} : { nextCursor }), + completeness: historyCompleteness({ + ...completenessFacts, + restoredNotReconstructed: 0, + }), + }; +} + +/** + * The full replay algorithm: every transition `identityTransitionsOf` reports, + * each paired with the class membership immediately before and after it. + * Both are reconstructed for every boundary — `after` at the boundary's own + * revision, `before` one revision earlier — and neither is carried over from + * the previous boundary. A class can change between two boundaries without a + * note under the schema then in force (a same-id node created while the graph + * ignored same ids, read back after it folds them), and carrying `after` + * forward would pin that change on the next transition. They are the same + * reconstruction that decided the boundary belongs to `ref` + * ({@link memberBoundary}), so the membership a step shows and the decision to + * show it cannot disagree. `store.identity.replay` is a thin wrapper over this. + * + * Paged by boundary exactly as `transitionsOf` is: `nextCursor` names the first + * boundary this page stopped short of. Because the page is cut BEFORE + * restored rows are excluded, `steps` can be shorter than `limit` boundaries + * on a page whose lineage mixes restored and native rows — the page boundary + * stays identical between the two methods, which is what lets a caller pair + * a `transitionsOf` explanation with a `replay` step page for page. How many + * restored rows a page left out is its `restored-not-reconstructed` + * completeness reason. + */ +export async function identityReplay( + ctx: IdentityServiceContext, + ref: IdentityNodeRefInput, + options?: IdentityReplayOptions, +): Promise> { + const { toRevision, retention, boundaries, completenessFacts } = + await walkedTransitionsFor(ctx, ref, options); + + const watermark = retention.prunedBeforeRevision; + // "Entirely below the watermark" is a property of the REQUESTED range, not + // of what came back: only a range bounded on both ends (`toRecorded` given) + // can lie wholly in pruned territory — an open-ended range always reaches + // up to "now", which the watermark can never exceed. + if (watermark > 0 && toRevision !== undefined && toRevision < watermark) { + throw identityReplayHistoryTruncatedError( + options?.fromRecorded, + requireDefined(options?.toRecorded), + createRecordedInstant(watermark, retention.prunedAt), + ); + } + + // Restored rows carry the SOURCE graph's own revision number, which + // interleaves arbitrarily with this graph's — a numeric watermark + // comparison cannot tell "a foreign row that happens to sit above the + // floor" from "this graph's own later history" apart (see + // `isRestoredTransitionRow`'s docblock, and the retention watermark's own + // "cannot vouch below N" contract, which is coarser and orthogonal). Steps + // are therefore built ONLY from this graph's own (never restored) rows; + // `transitionsOf` (no such filter) remains the complete answer for "what + // changed and why". A boundary holding only restored rows carries no + // reconstruction, so no step is ever built at a revision this graph did not + // allocate. + const steps: IdentityReplayStep[] = []; + for (const { rows, classes } of boundaries) { + if (classes === undefined) continue; + const before = classes.before.visible.map((member) => + publicNodeRef(member), + ); + const after = classes.after.visible.map((member) => + publicNodeRef(member), + ); + for (const row of rows) { + if (isRestoredTransitionRow(row)) continue; + steps.push({ transition: recordedTransition(row), before, after }); + } + } + const pageRows = boundaries.reduce( + (total, boundary) => total + boundary.rows.length, + 0, + ); + const restoredNotReconstructed = pageRows - steps.length; + + const { nextCursor } = completenessFacts; + return { + steps, + ...(nextCursor === undefined ? {} : { nextCursor }), + completeness: historyCompleteness({ + ...completenessFacts, + restoredNotReconstructed, + }), + }; +} diff --git a/packages/typegraph/src/identity/schema-transition.ts b/packages/typegraph/src/identity/schema-transition.ts index 2d45064e2..191b4e74d 100644 --- a/packages/typegraph/src/identity/schema-transition.ts +++ b/packages/typegraph/src/identity/schema-transition.ts @@ -1,3 +1,9 @@ +import { type BATCH_POINT_READ } from "../backend/capabilities/bundle-registry"; +import { resolveRecordedTimeOwnership } from "../backend/capabilities/recorded-time-ownership"; +import { + batchPointReadVerdict, + type BundleVerdictOf, +} from "../backend/capabilities/resolve"; import { requireWriteFence, resolveWriteFencePlan, @@ -15,9 +21,14 @@ import { type SqlSchema } from "../query/compiler/schema"; import { asCompiledRowsSql } from "../query/sql-intent"; import { type KindRegistry } from "../registry/kind-registry"; import { + createRecordedTransactionScope, + graphCapturesRecordedHistory, lockRecordedGraphWrite, + type RecordedTransactionScope, + transactionOwnsSqliteWriteLock, withRecordedIdentityMutationTarget, } from "../store/recorded-capture"; +import { nowIso } from "../utils/date"; import { errorChain, isPostgresConcurrentDdlRaceError, @@ -25,14 +36,18 @@ import { import { separationRebuildRequired } from "./separation"; import { deleteAssertionsTouchingKinds, + diffFullRebuildTransitions, hasAssertionsTouchingKinds, type IdentityRebuildContext, lockIdentityEnablementNodes, lockIdentityGraph, purgeAssertionsWithUnregisteredKinds, rebuildIdentityClosureForContext, + snapshotIdentityClosureClasses, } from "./service"; +import { noteClassTransitions } from "./service-mutation"; import { type IdentityTarget } from "./sql-target"; +import { type IdentityAssertionStorageRow } from "./storage-types"; /** The identity relations a schema transition reads, writes, and locks. */ function identityTableNames(schema: SqlSchema): IdentityTableNames { @@ -41,6 +56,8 @@ function identityTableNames(schema: SqlSchema): IdentityTableNames { recordedIdentityAssertions: schema.tables.recordedIdentityAssertions, identityClosure: schema.tables.identityClosure, identitySeparation: schema.tables.identitySeparation, + identityTransitions: schema.tables.identityTransitions, + identityTransitionRetention: schema.tables.identityTransitionRetention, }; } @@ -566,6 +583,76 @@ function assertIdentityStoragePresent( ); } +/** + * Whether a schema-commit preflight binds its ledger writes and transition + * notes to a recorded-capture session. The preflight's target is the raw + * schema-commit transaction, which no capture session is bound to, so without + * a binding every touch and note it takes is dropped. + * + * `"store"`: a Store that captures history drives the commit, so it always + * binds — the Store's own writes are captured by construction. + * `"database"`: no Store drives the commit (a standalone `initializeSchema` / + * `migrateSchema` / `ensureSchema`), so the graph's own recorded relations, + * read on the commit's session, decide ({@link graphCapturesRecordedHistory}). + * + * `batchPointRead` is resolved against the ROOT backend by whoever builds + * this; the preflight sees only the transaction target, which cannot. + */ +export type SchemaCommitCapture = Readonly<{ + source: "store" | "database"; + batchPointRead: BundleVerdictOf; +}>; + +/** + * The one decision of which {@link SchemaCommitCapture} a schema commit over + * `backend` takes. `storeCapturesHistory` is the driving Store's capture flag, + * or `undefined` when no Store drives the commit. Engine-native recorded + * ownership never takes TypeGraph capture, for either source. + */ +export function resolveSchemaCommitCapture( + backend: GraphBackend, + storeCapturesHistory: boolean | undefined, +): SchemaCommitCapture | undefined { + if (storeCapturesHistory === false) return undefined; + if (resolveRecordedTimeOwnership(backend) === "engine-native") return; + return { + source: storeCapturesHistory === true ? "store" : "database", + batchPointRead: batchPointReadVerdict(backend), + }; +} + +/** + * Binds a capture session to the schema-commit `target` itself when + * `capture` calls for one. Registered against `target` ITSELF, not a wrapped + * overlay: `createRecordedTransactionScope` registers both the overlay it + * returns and the raw target against the same session, and every write a + * preflight issues reaches `target` through `withRecordedIdentityMutationTarget`'s + * own touch/noteTransition callbacks. Routing through the overlay would break + * `lockIdentityEnablementNodes`, which needs `executeStatement` — a raw-SQL + * surface the overlay refuses because it cannot observe what a raw statement + * touched. The caller flushes the returned scope once its writes are done. + */ +async function bindSchemaCommitCapture( + target: TransactionBackend, + schema: SqlSchema, + graphId: string, + capture: SchemaCommitCapture | undefined, +): Promise { + if (capture === undefined) return; + if ( + capture.source === "database" && + !(await graphCapturesRecordedHistory(target, schema, graphId)) + ) { + return; + } + return createRecordedTransactionScope( + target, + capture.batchPointRead, + schema, + transactionOwnsSqliteWriteLock(target), + ); +} + /** * Builds the data preflight a schema commit runs inside its own transaction: * provision any derived relation the transition owes, take the recorded-write @@ -594,6 +681,8 @@ export function identitySchemaCommitPreflight( enablement: boolean; droppedNodeKinds?: readonly string[]; provisionDerivedRelations?: readonly string[]; + /** See {@link SchemaCommitCapture}; absent means the commit captures nothing. */ + capture?: SchemaCommitCapture | undefined; }>, ): (target: SchemaCommitPreflightBackend) => Promise { return async (target: SchemaCommitPreflightBackend) => { @@ -604,6 +693,23 @@ export function identitySchemaCommitPreflight( ); await lockRecordedGraphWrite(target, ctx.graphId); await lockIdentityGraph(target, ctx.graphId); + const captureScope = await bindSchemaCommitCapture( + target, + ctx.schema, + ctx.graphId, + options.capture, + ); + // Snapshotted before either cascade runs, and diffed against the + // rebuild's own result below: `rebuildIdentityClosureForContext` recomputes + // every class at once rather than handing back which ones it touched, so + // this is the only way to attribute a `kind-drop` / `schema-transition` + // note to what actually changed rather than fabricating a boundary on a + // commit that changed nothing. + const before = await snapshotIdentityClosureClasses( + target, + ctx.schema, + ctx.graphId, + ); if (options.enablement) { await lockIdentityEnablementNodes(target, ctx.schema); // Enablement must not ADOPT rows the rebuild below cannot see. A database @@ -628,18 +734,42 @@ export function identitySchemaCommitPreflight( // live-endpoint interchange reads, yet still visible to raw ledger reads // and merge staging, where a later "no-op" merge would end them. const droppedNodeKinds = options.droppedNodeKinds ?? []; - if (droppedNodeKinds.length > 0) { - await withRecordedIdentityMutationTarget(target, (rawTarget, touch) => - deleteAssertionsTouchingKinds( - rawTarget, - ctx.schema, - ctx.graphId, - droppedNodeKinds, - touch, - ), + const removedAssertions: readonly IdentityAssertionStorageRow[] = + droppedNodeKinds.length > 0 ? + await withRecordedIdentityMutationTarget(target, (rawTarget, touch) => + deleteAssertionsTouchingKinds( + rawTarget, + ctx.schema, + ctx.graphId, + droppedNodeKinds, + touch, + ), + ) + : []; + await rebuildIdentityClosureForContext({ ...ctx, backend: target }); + const transitions = await diffFullRebuildTransitions( + target, + ctx, + before, + true, + ); + if (transitions.length > 0) { + await withRecordedIdentityMutationTarget( + target, + (_rawTarget, _touch, noteTransition) => { + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause: + droppedNodeKinds.length > 0 ? "kind-drop" : "schema-transition", + assertions: removedAssertions, + validAt: nowIso(), + }); + return Promise.resolve(); + }, ); } - await rebuildIdentityClosureForContext({ ...ctx, backend: target }); + if (captureScope !== undefined) { + await captureScope.flush(); + } }; } @@ -687,14 +817,26 @@ async function provisionDerivedRelationsInCommit( * * No closure rebuild: without a profile there is no closure contract to * restore, and the enablement preflight rebuilds it from scratch anyway. + * + * No transition note either, deliberately: a profile-less graph has no + * materialized class for a dropped kind's members to leave, so deleting their + * assertions changes no class membership and there is nothing for a note to + * describe. */ export function identityKindCascadePreflight( ctx: Readonly<{ graphId: string; schema: SqlSchema }>, droppedNodeKinds: readonly string[], + capture: SchemaCommitCapture | undefined, ): (target: TransactionBackend) => Promise { return async (target: TransactionBackend) => { await lockRecordedGraphWrite(target, ctx.graphId); await lockIdentityGraph(target, ctx.graphId); + const captureScope = await bindSchemaCommitCapture( + target, + ctx.schema, + ctx.graphId, + capture, + ); await withRecordedIdentityMutationTarget(target, (rawTarget, touch) => deleteAssertionsTouchingKinds( rawTarget, @@ -704,5 +846,6 @@ export function identityKindCascadePreflight( touch, ), ); + await captureScope?.flush(); }; } diff --git a/packages/typegraph/src/identity/separation.ts b/packages/typegraph/src/identity/separation.ts index 48c295524..39b01378a 100644 --- a/packages/typegraph/src/identity/separation.ts +++ b/packages/typegraph/src/identity/separation.ts @@ -66,6 +66,13 @@ import { type IdentityAssertionStorageRow } from "./storage-types"; const MAX_SEPARATION_INSERT_CHUNK_SIZE = 100; +/** + * The one code every "this graph's derived identity storage is not readable" + * refusal carries — an absent relation and a never-filled one are the same fact + * for an operator, with the same remedy. + */ +const IDENTITY_STORAGE_MISSING_CODE = "IDENTITY_STORAGE_MISSING"; + /** * The PERSISTED encoding of an identity class key. * @@ -111,6 +118,16 @@ function orderedPair(first: string, second: string): SeparationPair { : { low: second, high: first }; } +/** + * The single spelling of an ordered class-key pair, for a caller that keys its + * own map or set of separations. Same encoding and same ordering the relation's + * `low`/`high` columns are written in, so a key minted outside this module can + * never disagree with one minted inside it. + */ +export function separationClassPairKey(first: string, second: string): string { + return pairKey(orderedPair(first, second)); +} + /** * Projects `different` assertions onto their endpoints' identity classes. * @@ -198,13 +215,17 @@ type RawSeparationRow = Readonly<{ }>; /** - * Whether the relation records two CURRENT classes as separated. + * Whether the relation records each of `pairs` as CURRENT classes separated — + * the batch generalization of the single-pair probe. * - * One primary-key probe against `(graph_id, class_key_low, class_key_high)`, - * standing in for resolving both classes' `different` assertions out of the - * ledger and scanning them for one that spans the pair. Callers pass the class - * keys in any order; putting them in the relation's `low < high` order is this - * module's business, since it is the same ordering the writer applies. + * ONE round trip per bind-budget chunk of DISTINCT non-trivial pairs, standing + * in for resolving every pair's two identity classes' `different` assertions + * out of the ledger and scanning them for one that spans the pair. Callers + * pass each pair's class keys in any order; putting them in the relation's + * `low < high` order is this module's business, since it is the same ordering + * the writer applies. A pair naming one class twice is never separated from + * itself — `low = high` is what the relation's CHECK exists to reject — and is + * answered `false` without a round trip. * * Valid only for a current-mode read: the relation projects CURRENT assertions * onto CURRENT classes, so a valid-time or recorded coordinate has to @@ -221,19 +242,86 @@ type RawSeparationRow = Readonly<{ * "not separated" — {@link separationRebuildRequired} is what keeps it loud * until the handle is reopened (the reopen runs the fill). * - * WHAT IT COSTS. The pair lookup and "does this graph have ANY row" travel in - * ONE statement — a second seek on the same primary key, in the same round - * trip — so a graph that uses separations pays nothing beyond that seek and the - * ledger is not read at all. The ledger probe runs only when the graph has NO - * separation rows AND the pair missed, which is also the only state where a - * "false" could be an unfilled relation rather than an absent separation. Both - * callers reach here having already resolved two identity classes through the - * closure (a recursive CTE each), so the bounded `LIMIT 1` this adds in that - * state is small against what the answer already cost. + * WHAT IT COSTS. The batch lookup and "does this graph have ANY row" travel in + * ONE statement per chunk — a `LEFT JOIN` onto the same primary-key seek, in + * the same round trip — so a graph that uses separations pays nothing beyond + * that seek, chunked to the bind budget, and the ledger is not read at all. + * Arity one costs exactly the statement the single-pair probe always has: one + * chunk, one round trip. The ledger probe runs at most ONCE PER CALL — it is a + * fact about the graph, not about any one pair — and only when every chunk's + * graph-rows flag came back empty, which is also the only state where a + * "false" could be an unfilled relation rather than an absent separation. * * @throws {ConfigurationError} `IDENTITY_STORAGE_MISSING` when the relation the * probe reads does not exist, or exists without this graph's fill. */ +export async function bulkIsSeparated( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + pairs: readonly Readonly<{ first: string; second: string }>[], + registry: KindRegistry, +): Promise { + if (pairs.length === 0) return []; + // A class is never separated from itself: `low = high` is what the + // relation's CHECK exists to reject, so a trivial pair is answered without a + // round trip and never joins the batch below. + const ordered = pairs.map((pair) => orderedPair(pair.first, pair.second)); + const uniqueNonTrivial = new Map(); + for (const pair of ordered) { + if (pair.low === pair.high) continue; + uniqueNonTrivial.set(pairKey(pair), pair); + } + if (uniqueNonTrivial.size === 0) { + return ordered.map(() => false); + } + + const chunkSize = identityChunkSize(target, { + fixedParameters: SEPARATION_PROBE_FIXED_PARAMETERS, + maxItems: MAX_REFERENCE_CHUNK_SIZE, + parametersPerItem: 2, + }); + const separated = new Set(); + let graphHasRows = false; + for (const pairChunk of chunk([...uniqueNonTrivial.values()], chunkSize)) { + const probe = await probeSeparationPairs( + target, + schema, + graphId, + pairChunk, + ); + graphHasRows ||= probe.graphHasRows; + for (const key of probe.separatedPairs) separated.add(key); + } + // The fast path, unchanged: rows exist for this graph, so the relation is + // not in the unfilled state and the ledger is not read at all. + const anyUnresolved = [...uniqueNonTrivial.keys()].some( + (key) => !separated.has(key), + ); + // Zero rows is not an exceptional state: it is the STEADY state of every + // graph that holds only `same` assertions, so this is the path whose cost + // decides whether the guard is affordable. The proof runs at most once per + // Store handle, and once per call here regardless of how many pairs it + // covers — the fact it settles is about the graph, not any one pair. + if ( + !graphHasRows && + anyUnresolved && + !(await emptySeparationRelationIsCorrect(target, schema, graphId, registry)) + ) { + throw separationUnfilledError(graphId, schema); + } + return ordered.map( + (pair) => pair.low !== pair.high && separated.has(pairKey(pair)), + ); +} + +/** + * Whether the relation records two CURRENT classes as separated. + * + * @throws {ConfigurationError} `IDENTITY_STORAGE_MISSING` when the relation the + * probe reads does not exist, or exists without this graph's fill. + * @see {@link bulkIsSeparated}, the arity-N owner this reduces to. + */ export async function isSeparated( target: IdentityTarget, schema: SqlSchema, @@ -242,31 +330,14 @@ export async function isSeparated( secondClassKey: string, registry: KindRegistry, ): Promise { - // A class is never separated from itself: `low = high` is what the relation's - // CHECK exists to reject, so the probe is a guaranteed miss and the round - // trip buys nothing. - if (firstClassKey === secondClassKey) return false; - const probe = await probeSeparationPair( + const [result] = await bulkIsSeparated( target, schema, graphId, - orderedPair(firstClassKey, secondClassKey), + [{ first: firstClassKey, second: secondClassKey }], + registry, ); - if (probe.pairSeparated) return true; - // The fast path, unchanged: rows exist for this graph, so the relation is not - // in the unfilled state and the ledger is not read at all. This statement is - // exactly the one the guard inherited — no proof rides along, no kind binds. - if (probe.graphHasRows) return false; - // Zero rows is not an exceptional state: it is the STEADY state of every - // graph that holds only `same` assertions, so this is the path whose cost - // decides whether the guard is affordable. The proof runs at most once per - // Store handle. - if (separationReadinessProven(registry, graphId)) return false; - if (await hasLiveDifferentAssertions(target, schema, graphId, registry)) { - throw separationUnfilledError(graphId, schema); - } - proveSeparationReadiness(registry, graphId); - return false; + return result ?? false; } /** @@ -320,6 +391,55 @@ function separationReadinessProven( return provenSeparationReadiness.get(registry)?.has(graphId) === true; } +/** + * Whether this graph separates NOTHING right now — the ledger's own answer, + * probed on every call. + * + * `true` means no live `different` assertion exists, so nothing in the graph + * can be held apart and a caller whose only question is "can anything here be + * separated" may stop. It is never answered from the readiness memo: that + * memo records that an empty relation was once CORRECT, which stays true + * after a later `assertDifferent` fills the relation, while "separates + * nothing" stops being true at exactly that write. The merge's plan-time + * separation capture reads this. + * + * An empty ledger is also the proof {@link emptySeparationRelationIsCorrect} + * memoizes, so finding one here records it. + */ +export async function separationFactsEmpty( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + registry: KindRegistry, +): Promise { + if (await hasLiveDifferentAssertions(target, schema, graphId, registry)) { + return false; + } + proveSeparationReadiness(registry, graphId); + return true; +} + +/** + * Whether a separation relation holding NO row for this graph is correct + * rather than unfilled — the memo read and the ledger probe as one decision. + * + * `false` means a live `different` assertion exists while the relation holds + * no row for the graph: proof that this graph's fill never ran. The proof is + * memoized per (registry, graphId), so a graph that uses only `assertSame` + * pays the ledger probe once per Store handle. Only a reader that has just + * seen the relation empty may ask this; it says nothing about whether the + * graph separates anything now. + */ +async function emptySeparationRelationIsCorrect( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + registry: KindRegistry, +): Promise { + if (separationReadinessProven(registry, graphId)) return true; + return separationFactsEmpty(target, schema, graphId, registry); +} + function proveSeparationReadiness( registry: KindRegistry, graphId: string, @@ -329,14 +449,21 @@ function proveSeparationReadiness( provenSeparationReadiness.set(registry, proven); } -/** What one probe of the relation establishes about a pair AND its graph. */ -type SeparationProbe = Readonly<{ - pairSeparated: boolean; +/** What one probe of the relation establishes about a chunk of pairs AND its graph. */ +type BulkSeparationProbe = Readonly<{ + /** `pairKey`-encoded pairs the relation records as separated. */ + separatedPairs: ReadonlySet; graphHasRows: boolean; }>; /** - * The pair lookup and "does this graph hold ANY row", in one statement. + * Fixed binds in {@link probeSeparationPairs}'s statement: `graph_id`, bound + * once in the graph-rows subquery and once in the matched-pairs subquery. + */ +const SEPARATION_PROBE_FIXED_PARAMETERS = 2; + +/** + * The batch pair lookup and "does this graph hold ANY row", in one statement. * * The readiness proof deliberately does NOT ride along here. Folding it in as a * `CASE`-guarded scalar subquery was measured and rejected: it made the @@ -346,37 +473,55 @@ type SeparationProbe = Readonly<{ * plan to prepare — while the graphs that DO hold rows would have paid the same * binds for a subquery whose arm is never taken. Cheap on paper, not on the * engine. + * + * The two subqueries are joined rather than left as independent scalars so + * arity N stays ONE round trip: the graph-rows scalar is always exactly one + * row, `LEFT JOIN`ed to however many of the chunk's pairs the relation + * actually holds (zero or more), which at arity one degenerates to exactly the + * single combined row the original probe returned. */ -async function probeSeparationPair( +async function probeSeparationPairs( target: IdentityTarget, schema: SqlSchema, graphId: string, - pair: SeparationPair, -): Promise { + pairChunk: readonly SeparationPair[], +): Promise { try { + const conditions = pairChunk.map( + (pair) => + sql`(class_key_low = ${pair.low} AND class_key_high = ${pair.high})`, + ); const rows = await target.execute>>( asCompiledRowsSql(sql` - SELECT - ( - SELECT COUNT(*) FROM ${schema.identitySeparationTable} + SELECT graph_probe.graph_rows AS graph_rows, + matched.class_key_low AS class_key_low, + matched.class_key_high AS class_key_high + FROM ( + SELECT COUNT(*) AS graph_rows FROM ( + SELECT 1 AS present FROM ${schema.identitySeparationTable} WHERE graph_id = ${graphId} - AND class_key_low = ${pair.low} - AND class_key_high = ${pair.high} - ) AS pair_rows, - ( - SELECT COUNT(*) FROM ( - SELECT 1 AS present FROM ${schema.identitySeparationTable} - WHERE graph_id = ${graphId} - LIMIT 1 - ) graph_probe - ) AS graph_rows + LIMIT 1 + ) graph_present + ) graph_probe + LEFT JOIN ( + SELECT class_key_low, class_key_high + FROM ${schema.identitySeparationTable} + WHERE graph_id = ${graphId} + AND (${sql.join(conditions, sql` OR `)}) + ) matched ON 1 = 1 `), ); - const row = rows[0]; - return { - graphHasRows: countOf(row, "graph_rows") > 0, - pairSeparated: countOf(row, "pair_rows") > 0, - }; + const separatedPairs = new Set(); + let graphHasRows = false; + for (const row of rows) { + if (countOf(row, "graph_rows") > 0) graphHasRows = true; + const low = row["class_key_low"]; + const high = row["class_key_high"]; + if (typeof low === "string" && typeof high === "string") { + separatedPairs.add(pairKey({ low, high })); + } + } + return { graphHasRows, separatedPairs }; } catch (error) { // Never degrade to "not separated": a caller that cannot read the relation // has no basis for an answer, and the wrong answer here is the one that @@ -417,7 +562,7 @@ function separationUnreadableError( return new ConfigurationError( "Operational Identity could not read the materialized separation relation.", { - code: "IDENTITY_STORAGE_MISSING", + code: IDENTITY_STORAGE_MISSING_CODE, graphId, tables: [schema.tables.identitySeparation], }, @@ -446,7 +591,7 @@ function separationUnfilledError( return new ConfigurationError( "Operational Identity found the separation relation present but never filled for this graph.", { - code: "IDENTITY_STORAGE_MISSING", + code: IDENTITY_STORAGE_MISSING_CODE, graphId, reason: "unfilled", tables: [schema.tables.identitySeparation], diff --git a/packages/typegraph/src/identity/service-components.ts b/packages/typegraph/src/identity/service-components.ts index fbc9966f8..cec4f96b8 100644 --- a/packages/typegraph/src/identity/service-components.ts +++ b/packages/typegraph/src/identity/service-components.ts @@ -96,12 +96,7 @@ export class UnionFind { } components(): ReadonlyMap { - const distinct = this.distinctComponents(); - const byMember = new Map(); - for (const group of distinct.values()) { - for (const member of group) byMember.set(refKey(member), group); - } - return byMember; + return indexComponentsByMember(this.distinctComponents()); } /** @@ -138,11 +133,22 @@ export function buildComponents( >[], sameIdAcrossKinds: "fold" | "ignore", ): ReadonlyMap { - const distinct = buildDistinctComponents( - structuralNodes, - assertions, - sameIdAcrossKinds, + return indexComponentsByMember( + buildDistinctComponents(structuralNodes, assertions, sameIdAcrossKinds), ); +} + +/** + * Re-keys distinct components by every member, sharing each component's one + * array across its members — linear in the member count, never a per-member + * copy. Any consumer that looks a class up BY MEMBER (a before/after closure + * diff) must go through this index: a distinct map is keyed by an arbitrary + * union-find root, so a member lookup against it silently misses every + * non-root member. + */ +export function indexComponentsByMember( + distinct: ReadonlyMap, +): ReadonlyMap { const byMember = new Map(); for (const group of distinct.values()) { for (const member of group) byMember.set(refKey(member), group); @@ -411,9 +417,31 @@ export function validateSnapshotIntegrity( } } -type RawClosureRow = RawClosureClassRow & +/** One materialized closure row: a member and the class it is labeled with. */ +type RawClosureMemberClassRow = RawClosureClassRow & Readonly<{ class_kind: string; class_id: string }>; +/** + * The graph-wide read of the materialized closure. ONE owner for the + * verification path ({@link assertClosureMatchesComponents}) and the + * transition-diff snapshot (`snapshotIdentityClosureClasses`), so a column + * rename or an added closure column cannot leave one of them reading a stale + * projection while the other is updated. + */ +export async function readClosureRowsForGraph( + target: Backend, + schema: SqlSchema, + graphId: string, +): Promise { + return target.execute( + asCompiledRowsSql(sql` + SELECT member_kind, member_id, class_kind, class_id + FROM ${schema.identityClosureTable} + WHERE graph_id = ${graphId} + `), + ); +} + export function closureMismatchError( graphId: string, detail: Record, @@ -466,13 +494,7 @@ export async function assertClosureMatchesComponents( } } - const rows = await target.execute( - asCompiledRowsSql(sql` - SELECT member_kind, member_id, class_kind, class_id - FROM ${schema.identityClosureTable} - WHERE graph_id = ${graphId} - `), - ); + const rows = await readClosureRowsForGraph(target, schema, graphId); const seen = new Set(); for (const row of rows) { const member = { kind: row.member_kind, id: row.member_id }; diff --git a/packages/typegraph/src/identity/service-facade.ts b/packages/typegraph/src/identity/service-facade.ts index 2e9dabba4..9fc2f9666 100644 --- a/packages/typegraph/src/identity/service-facade.ts +++ b/packages/typegraph/src/identity/service-facade.ts @@ -9,7 +9,10 @@ import { getDialect } from "../query/dialect"; import { sql } from "../query/sql-fragment"; import { asCompiledRowsSql } from "../query/sql-intent"; import { runInWriteTransaction } from "../store/operations/write-transaction"; -import { withRecordedIdentityMutationTarget } from "../store/recorded-capture"; +import { + type IdentityTransitionNoteFunction, + withRecordedIdentityMutationTarget, +} from "../store/recorded-capture"; import { chunk } from "../utils/array"; import { compareCodePoints } from "../utils/compare"; import { nowIso } from "../utils/date"; @@ -25,6 +28,7 @@ import { identityNodeVisibilitySql, identitySqlCoordinate, } from "./historical-sql"; +import { identityReplay, identityTransitionsOf } from "./replay"; import { normalizeIdentityAssertionRow, type RawIdentityAssertionRow, @@ -45,10 +49,12 @@ import { import { assertPair, buildAssertionRow, + type ClassTransitionNoteContext, createIdentityWindowValidator, currentAssertionForPair, currentClassKey, insertAssertionRows, + noteClassTransitions, replaceAffectedClosure, replaceSeparationForReferences, } from "./service-mutation"; @@ -123,6 +129,7 @@ async function bulkAssertPairs( b: IdentityNodeRefInput; }>[], touch: IdentityTouch, + noteTransition: IdentityTransitionNoteFunction, ): Promise[]> { if (pairs.length === 0) return []; const normalizedPairs = pairs.map((pair) => { @@ -298,13 +305,18 @@ async function bulkAssertPairs( await insertAssertionRows(target, ctx.schema, createdRows); for (const row of createdRows) touch(ctx.graphId, row.id, row); if (closureReferences.length > 0) { - await replaceAffectedClosure( + const transitions = await replaceAffectedClosure( target, ctx.schema, ctx.graphId, closureReferences, ctx.sameIdAcrossKinds, ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause: "assert", + assertions: createdRows, + validAt: timestamp, + }); } else { await replaceSeparationForReferences( target, @@ -325,6 +337,7 @@ async function bulkAssertWindowedPairs( relation: IdentityRelation, pairs: readonly WindowedIdentityPair[], touch: IdentityTouch, + noteTransition: IdentityTransitionNoteFunction, operationInstant: string, ): Promise[]> { const windowRequests = pairs.map((pair) => { @@ -352,6 +365,7 @@ async function bulkAssertWindowedPairs( pair.a, pair.b, touch, + noteTransition, window, operationInstant, windowValidator, @@ -565,6 +579,7 @@ export async function runIdentityMutation( target: Backend, touch: IdentityTouch, markWritten: () => void, + noteTransition: IdentityTransitionNoteFunction, ) => Promise, ): Promise { // Track whether the mutation actually touched a row: a successful no-op @@ -595,17 +610,23 @@ export async function runIdentityMutation( async (target) => { touchedBox.touched = false; await lockIdentityGraph(target, ctx.graphId); - return withRecordedIdentityMutationTarget(target, (rawTarget, touch) => - fn( - rawTarget, - (graphId, id, afterImage) => { - touchedBox.touched = true; - touch(graphId, id, afterImage); - }, - () => { - touchedBox.touched = true; - }, - ), + return withRecordedIdentityMutationTarget( + target, + (rawTarget, touch, noteTransition) => + fn( + rawTarget, + (graphId, id, afterImage) => { + touchedBox.touched = true; + touch(graphId, id, afterImage); + }, + () => { + touchedBox.touched = true; + }, + (graphId, note) => { + touchedBox.touched = true; + noteTransition(graphId, note); + }, + ), ); }, { didWrite: () => touchedBox.touched }, @@ -1229,7 +1250,7 @@ export function createIdentityReadFacade( * `same` retraction splits identity classes (closure repair, which carries the * separation repair with it), a `different` retraction removes a separation. */ -export function partitionRetractedEndpoints( +function partitionRetractedEndpoints( retracted: readonly IdentityAssertionStorageRow[], ): Readonly<{ closureReferences: readonly PlainNodeRef[]; @@ -1251,6 +1272,56 @@ export function partitionRetractedEndpoints( return { closureReferences, separationReferences }; } +/** + * What a retraction's closure notes carry beyond the ended rows' own ids: the + * cause vocabulary stays owned by {@link noteClassTransitions}, never + * re-spelled here (this module must not reach the transition log itself). + */ +type RetractionAftermathNote = Omit; + +/** + * The AFTERMATH of a retraction, for every path that ends identity assertions: + * repair the derived relations the ended rows governed — the closure for a + * `same`, the separation for a `different` — and note the closure transitions + * the repair produced under one cause. + * + * One owner, so a new repair, a different `validAt` source, or a note on the + * separation side cannot be added to some retraction paths and missed by the + * rest. Every caller hands the rows it actually ended, and each note names + * the ended `same` rows that held its own class together — never the whole + * batch, and never a `different` row, which splits nothing. + */ +export async function applyRetractionAftermath( + ctx: IdentityServiceContext, + target: Backend, + retracted: readonly IdentityAssertionStorageRow[], + noteTransition: IdentityTransitionNoteFunction, + common: RetractionAftermathNote, +): Promise { + const { closureReferences, separationReferences } = + partitionRetractedEndpoints(retracted); + if (closureReferences.length > 0) { + const transitions = await replaceAffectedClosure( + target, + ctx.schema, + ctx.graphId, + closureReferences, + ctx.sameIdAcrossKinds, + ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause: common.cause, + assertions: retracted, + validAt: common.validAt, + }); + } + await replaceSeparationForReferences( + target, + ctx.schema, + ctx.graphId, + separationReferences, + ); +} + export function createIdentityFacade( ctx: IdentityServiceContext, ): IdentityFacade { @@ -1258,171 +1329,204 @@ export function createIdentityFacade( ...createIdentityReadFacade(ctx), assertSame(a, b, window) { - return runIdentityMutation(ctx, (target, touch) => { - const operationInstant = nowIso(); - return assertPair( - ctx, - target, - "same", - a, - b, - touch, - hasExplicitIdentityValidityWindow(window) ? window : undefined, - operationInstant, - ); - }); + return runIdentityMutation( + ctx, + (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + return assertPair( + ctx, + target, + "same", + a, + b, + touch, + noteTransition, + hasExplicitIdentityValidityWindow(window) ? window : undefined, + operationInstant, + ); + }, + ); }, assertDifferent(a, b, window) { - return runIdentityMutation(ctx, (target, touch) => { - const operationInstant = nowIso(); - return assertPair( - ctx, - target, - "different", - a, - b, - touch, - hasExplicitIdentityValidityWindow(window) ? window : undefined, - operationInstant, - ); - }); + return runIdentityMutation( + ctx, + (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + return assertPair( + ctx, + target, + "different", + a, + b, + touch, + noteTransition, + hasExplicitIdentityValidityWindow(window) ? window : undefined, + operationInstant, + ); + }, + ); }, bulkAssertSame(pairs) { - return runIdentityMutation(ctx, (target, touch) => { - if (!pairs.some((pair) => hasExplicitIdentityValidityWindow(pair))) { - return bulkAssertPairs(ctx, target, "same", pairs, touch); - } - return bulkAssertWindowedPairs( - ctx, - target, - "same", - pairs, - touch, - nowIso(), - ); - }); + return runIdentityMutation( + ctx, + (target, touch, _markWritten, noteTransition) => { + if (!pairs.some((pair) => hasExplicitIdentityValidityWindow(pair))) { + return bulkAssertPairs( + ctx, + target, + "same", + pairs, + touch, + noteTransition, + ); + } + return bulkAssertWindowedPairs( + ctx, + target, + "same", + pairs, + touch, + noteTransition, + nowIso(), + ); + }, + ); }, bulkAssertDifferent(pairs) { - return runIdentityMutation(ctx, (target, touch) => { - if (!pairs.some((pair) => hasExplicitIdentityValidityWindow(pair))) { - return bulkAssertPairs(ctx, target, "different", pairs, touch); - } - return bulkAssertWindowedPairs( - ctx, - target, - "different", - pairs, - touch, - nowIso(), - ); - }); - }, - - retractAssertion(id) { - return runIdentityMutation(ctx, async (target, touch) => { - const ended = await retractById(ctx, target, id, touch); - if (ended !== undefined) { - const endpoints = [ - { kind: ended.a_kind, id: ended.a_id }, - { kind: ended.b_kind, id: ended.b_id }, - ]; - if (ended.rel === "same") { - await replaceAffectedClosure( - target, - ctx.schema, - ctx.graphId, - endpoints, - ctx.sameIdAcrossKinds, - ); - } else { - await replaceSeparationForReferences( + return runIdentityMutation( + ctx, + (target, touch, _markWritten, noteTransition) => { + if (!pairs.some((pair) => hasExplicitIdentityValidityWindow(pair))) { + return bulkAssertPairs( + ctx, target, - ctx.schema, - ctx.graphId, - endpoints, + "different", + pairs, + touch, + noteTransition, ); } - } - return ended === undefined ? undefined : publicAssertion(ended); - }); + return bulkAssertWindowedPairs( + ctx, + target, + "different", + pairs, + touch, + noteTransition, + nowIso(), + ); + }, + ); + }, + + retractAssertion(id) { + return runIdentityMutation( + ctx, + async (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + const ended = await retractById(ctx, target, id, touch); + if (ended === undefined) return; + await applyRetractionAftermath(ctx, target, [ended], noteTransition, { + cause: "retract", + validAt: operationInstant, + }); + return publicAssertion(ended); + }, + ); }, retractSameAssertion(firstInput, secondInput) { - return runIdentityMutation(ctx, async (target, touch) => { - const [a, b] = normalizePair( - registeredPlainRef(ctx, firstInput), - registeredPlainRef(ctx, secondInput), - ); - const existing = await currentAssertionForPair( - target, - ctx.schema, - ctx.graphId, - "same", - a, - b, - ); - if (existing === undefined) return; - const ended = await retractById(ctx, target, existing.id, touch); - await replaceAffectedClosure( - target, - ctx.schema, - ctx.graphId, - [a, b], - ctx.sameIdAcrossKinds, - ); - return ended === undefined ? undefined : publicAssertion(ended); - }); + return runIdentityMutation( + ctx, + async (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + const [a, b] = normalizePair( + registeredPlainRef(ctx, firstInput), + registeredPlainRef(ctx, secondInput), + ); + const existing = await currentAssertionForPair( + target, + ctx.schema, + ctx.graphId, + "same", + a, + b, + ); + if (existing === undefined) return; + const ended = await retractById(ctx, target, existing.id, touch); + // The row the lookup found, never `ended`: the closure must be + // repaired even for an id another writer ended first, and both carry + // the same endpoints. + await applyRetractionAftermath( + ctx, + target, + [existing], + noteTransition, + { cause: "retract", validAt: operationInstant }, + ); + return ended === undefined ? undefined : publicAssertion(ended); + }, + ); }, retractDifferentAssertion(firstInput, secondInput) { - return runIdentityMutation(ctx, async (target, touch) => { - const [a, b] = normalizePair( - registeredPlainRef(ctx, firstInput), - registeredPlainRef(ctx, secondInput), - ); - const existing = await currentAssertionForPair( - target, - ctx.schema, - ctx.graphId, - "different", - a, - b, - ); - if (existing === undefined) return; - const ended = await retractById(ctx, target, existing.id, touch); - await replaceSeparationForReferences(target, ctx.schema, ctx.graphId, [ - a, - b, - ]); - return ended === undefined ? undefined : publicAssertion(ended); - }); + return runIdentityMutation( + ctx, + async (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + const [a, b] = normalizePair( + registeredPlainRef(ctx, firstInput), + registeredPlainRef(ctx, secondInput), + ); + const existing = await currentAssertionForPair( + target, + ctx.schema, + ctx.graphId, + "different", + a, + b, + ); + if (existing === undefined) return; + const ended = await retractById(ctx, target, existing.id, touch); + await applyRetractionAftermath( + ctx, + target, + [existing], + noteTransition, + { cause: "retract", validAt: operationInstant }, + ); + return ended === undefined ? undefined : publicAssertion(ended); + }, + ); }, bulkRetractAssertions(ids) { - return runIdentityMutation(ctx, async (target, touch) => { - const retracted = await retractByIds(ctx, target, ids, touch); - const { closureReferences, separationReferences } = - partitionRetractedEndpoints(retracted); - if (closureReferences.length > 0) { - await replaceAffectedClosure( + return runIdentityMutation( + ctx, + async (target, touch, _markWritten, noteTransition) => { + const operationInstant = nowIso(); + const retracted = await retractByIds(ctx, target, ids, touch); + await applyRetractionAftermath( + ctx, target, - ctx.schema, - ctx.graphId, - closureReferences, - ctx.sameIdAcrossKinds, + retracted, + noteTransition, + { cause: "retract", validAt: operationInstant }, ); - } - await replaceSeparationForReferences( - target, - ctx.schema, - ctx.graphId, - separationReferences, - ); - return retracted.map((assertion) => publicAssertion(assertion)); - }); + return retracted.map((assertion) => publicAssertion(assertion)); + }, + ); + }, + + transitionsOf(ref, options) { + return identityTransitionsOf(ctx, ref, options); + }, + + replay(ref, options) { + return identityReplay(ctx, ref, options); }, }; } diff --git a/packages/typegraph/src/identity/service-interchange-write.ts b/packages/typegraph/src/identity/service-interchange-write.ts index 76a401f64..e47b7a6c6 100644 --- a/packages/typegraph/src/identity/service-interchange-write.ts +++ b/packages/typegraph/src/identity/service-interchange-write.ts @@ -5,27 +5,32 @@ import { NodeNotFoundError, ValidationError, } from "../errors"; -import { withRecordedIdentityMutationTarget } from "../store/recorded-capture"; +import { type SqlSchema } from "../query/compiler/schema"; +import { + readNextRecordedRevision, + withRecordedIdentityDecision, + withRecordedIdentityMutationTarget, +} from "../store/recorded-capture"; import { nowIso } from "../utils/date"; +import { toTransferAssertion } from "./row-codec"; import { requireLiveEndpoints, requireStructuralEndpoints, } from "./service-components"; import { - partitionRetractedEndpoints, + applyRetractionAftermath, retractPlannedAssertions, runIdentityMutation, } from "./service-facade"; import { + applyPairRelationEffect, assertionForExactWindow, createIdentityWindowValidator, currentAssertionForPair, insertAssertion, insertAssertionRows, loadAssertionsByIds, - mergeCurrentClasses, - replaceAffectedClosure, - replaceSeparationForReferences, + readIdentityWindowLedger, requireEndpointsCoverIdentityWindow, validateCurrentRelation, } from "./service-mutation"; @@ -36,8 +41,17 @@ import { type IdentityServiceContext, type IdentityTransferAssertion, } from "./service-types"; -import { type PlainNodeRef } from "./sql-target"; +import { type IdentityTarget, type PlainNodeRef } from "./sql-target"; import { type IdentityAssertionStorageRow } from "./storage-types"; +import { + encodeIdentityTransitionRow, + hasNativeIdentityTransitionsBefore, + type IdentityDecisionProvenance, + type IdentityRestoreBaseline, + type IdentityTransitionTransfer, + insertIdentityTransitionValues, + writeIdentityTransitionRetentionWatermark, +} from "./transition-log"; import { type ResolvedIdentityValidityWindow, resolveIdentityValidityWindow, @@ -240,6 +254,41 @@ function rethrowTaggedWithAssertion( throw error; } +/** + * The target's live assertion rows — current and ended — that this importer's + * window validator would read for applying `assertions` as a merge does + * (`"archival"`), as transfer rows: the history a merge plan's contradiction + * simulation must judge its claims against. Each assertion is validated and + * its window resolved exactly as {@link importIdentityAssertionsIntoTarget} + * does, so a claim the importer would refuse for its shape is refused here + * with the same typed error, and the rows come from the same + * `readIdentityWindowLedger` read. + */ +export async function loadIdentityWindowHistory( + ctx: IdentityAssertionImportContext, + target: Backend, + assertions: readonly IdentityTransferAssertion[], + operationInstant: string, +): Promise { + const ledger = await readIdentityWindowLedger( + ctx, + target, + assertions.map((assertion) => { + const { endpoints, window } = validateTransferShape( + ctx, + assertion, + "archival", + operationInstant, + ); + return { references: endpoints, window }; + }), + operationInstant, + ); + return ledger.assertions + .filter((row) => row.deleted_at === undefined) + .map((row) => toTransferAssertion(row)); +} + /** * Applies interchange identity rows inside the caller-owned write transaction. * The caller owns import conflict policy and acquires the graph identity lock; @@ -251,145 +300,206 @@ export async function importIdentityAssertionsIntoTarget( assertions: readonly IdentityTransferAssertion[], mode: "state" | "archival", ignoredAssertionIds: ReadonlySet = new Set(), + // `"assert"` for a plain interchange import (union, matching what + // `mergeCurrentClasses` always does): the caller that instead applies a + // reviewed merge decision (`applyIdentityChangesForContext`) states + // `"reconcile"` explicitly — the cause is never inferred from context here. + cause: "assert" | "reconcile" = "assert", ): Promise { let created = 0; let skipped = 0; - await withRecordedIdentityMutationTarget(target, async (rawTarget, touch) => { - const operationInstant = nowIso(); - // Pre-pass: validate every shape in input order and normalize endpoints, - // then batch the two reads the loop would otherwise issue per item — the - // existing-row-by-id lookup and the current-endpoint liveness check. - const normalized = assertions.map((assertion) => ({ - assertion, - ...validateTransferShape(ctx, assertion, mode, operationInstant), - })); - const existingById = await loadAssertionsByIds( - rawTarget, - ctx.schema, - ctx.graphId, - assertions.map((assertion) => assertion.id), - ); - const currentEndpoints: PlainNodeRef[] = []; - const endedEndpoints: PlainNodeRef[] = []; - for (const { assertion, endpoints } of normalized) { - const [a, b] = endpoints; - if (assertion.validTo === undefined) { - currentEndpoints.push(a, b); - } else { - endedEndpoints.push(a, b); - } - } - const attributeMissingEndpoint = ( - error: unknown, - ended: boolean, - ): never => { - // The batch checks lose per-assertion context; the first assertion of - // the checked kind touching the missing ref is the failing candidate. - if (error instanceof NodeNotFoundError) { - const missing = { kind: error.details.kind, id: error.details.id }; - const failing = normalized.find( - ({ assertion, endpoints }) => - (assertion.validTo !== undefined) === ended && - endpoints.some( - (endpoint) => - endpoint.kind === missing.kind && endpoint.id === missing.id, - ), - ); - if (failing !== undefined) { - rethrowTaggedWithAssertion(error, failing.assertion.id, { - created, - skipped, - }); - } - } - throw error; - }; - try { - await requireLiveEndpoints( - rawTarget, - ctx.schema, - ctx.graphId, - currentEndpoints, - ); - } catch (error) { - attributeMissingEndpoint(error, false); - } - try { - await requireStructuralEndpoints( + await withRecordedIdentityMutationTarget( + target, + async (rawTarget, touch, noteTransition) => { + const operationInstant = nowIso(); + // Pre-pass: validate every shape in input order and normalize endpoints, + // then batch the two reads the loop would otherwise issue per item — the + // existing-row-by-id lookup and the current-endpoint liveness check. + const normalized = assertions.map((assertion) => ({ + assertion, + ...validateTransferShape(ctx, assertion, mode, operationInstant), + })); + const existingById = await loadAssertionsByIds( rawTarget, ctx.schema, ctx.graphId, - endedEndpoints, + assertions.map((assertion) => assertion.id), ); - } catch (error) { - attributeMissingEndpoint(error, true); - } - const windowValidator = await createIdentityWindowValidator( - ctx, - rawTarget, - normalized.map(({ endpoints, window }) => ({ - references: endpoints, - window, - })), - operationInstant, - ignoredAssertionIds, - ); - - for (const { assertion, endpoints, window } of normalized) { - const [a, b] = endpoints; - try { - const sameId = existingById.get(assertion.id); - if (sameId !== undefined) { - const exact = - sameId.rel === assertion.relation && - sameId.a_kind === a.kind && - sameId.a_id === a.id && - sameId.b_kind === b.kind && - sameId.b_id === b.id && - sameId.valid_from === assertion.validFrom && - sameId.valid_to === assertion.validTo && - sameId.ended_by_kind === assertion.endedBy?.kind && - sameId.ended_by_id === assertion.endedBy?.id; - if (exact) { - skipped += 1; - continue; - } - throw new ConfigurationError( - `Identity assertion id ${assertion.id} already identifies different truth.`, - { - code: "IDENTITY_IMPORT_ID_CONFLICT", - graphId: ctx.graphId, - assertionId: assertion.id, - }, + const currentEndpoints: PlainNodeRef[] = []; + const endedEndpoints: PlainNodeRef[] = []; + for (const { assertion, endpoints } of normalized) { + const [a, b] = endpoints; + if (assertion.validTo === undefined) { + currentEndpoints.push(a, b); + } else { + endedEndpoints.push(a, b); + } + } + const attributeMissingEndpoint = ( + error: unknown, + ended: boolean, + ): never => { + // The batch checks lose per-assertion context; the first assertion of + // the checked kind touching the missing ref is the failing candidate. + if (error instanceof NodeNotFoundError) { + const missing = { kind: error.details.kind, id: error.details.id }; + const failing = normalized.find( + ({ assertion, endpoints }) => + (assertion.validTo !== undefined) === ended && + endpoints.some( + (endpoint) => + endpoint.kind === missing.kind && endpoint.id === missing.id, + ), ); + if (failing !== undefined) { + rethrowTaggedWithAssertion(error, failing.assertion.id, { + created, + skipped, + }); + } } - - const exactWindow = await assertionForExactWindow( + throw error; + }; + try { + await requireLiveEndpoints( rawTarget, ctx.schema, ctx.graphId, - assertion.relation, - a, - b, - window, + currentEndpoints, ); - if (exactWindow !== undefined) { - skipped += 1; - continue; - } - if (window.effective === "current") { - const current = await currentAssertionForPair( + } catch (error) { + attributeMissingEndpoint(error, false); + } + try { + await requireStructuralEndpoints( + rawTarget, + ctx.schema, + ctx.graphId, + endedEndpoints, + ); + } catch (error) { + attributeMissingEndpoint(error, true); + } + const windowValidator = await createIdentityWindowValidator( + ctx, + rawTarget, + normalized.map(({ endpoints, window }) => ({ + references: endpoints, + window, + })), + operationInstant, + ignoredAssertionIds, + ); + + for (const { assertion, endpoints, window } of normalized) { + const [a, b] = endpoints; + try { + const sameId = existingById.get(assertion.id); + if (sameId !== undefined) { + const exact = + sameId.rel === assertion.relation && + sameId.a_kind === a.kind && + sameId.a_id === a.id && + sameId.b_kind === b.kind && + sameId.b_id === b.id && + sameId.valid_from === assertion.validFrom && + sameId.valid_to === assertion.validTo && + sameId.ended_by_kind === assertion.endedBy?.kind && + sameId.ended_by_id === assertion.endedBy?.id; + if (exact) { + skipped += 1; + continue; + } + throw new ConfigurationError( + `Identity assertion id ${assertion.id} already identifies different truth.`, + { + code: "IDENTITY_IMPORT_ID_CONFLICT", + graphId: ctx.graphId, + assertionId: assertion.id, + }, + ); + } + + const exactWindow = await assertionForExactWindow( rawTarget, ctx.schema, ctx.graphId, assertion.relation, a, b, + window, ); - if (current !== undefined) { + if (exactWindow !== undefined) { skipped += 1; continue; } + if (window.effective === "current") { + const current = await currentAssertionForPair( + rawTarget, + ctx.schema, + ctx.graphId, + assertion.relation, + a, + b, + ); + if (current !== undefined) { + skipped += 1; + continue; + } + await requireEndpointsCoverIdentityWindow( + rawTarget, + ctx.graphId, + [a, b], + window, + ); + windowValidator.validate( + assertion.relation, + "import", + a, + b, + window, + ); + // The temporal check owns historical correctness. The current check + // also exercises the materialized separation backstop/readiness guard + // before this row changes current derived state. + await validateCurrentRelation( + ctx, + rawTarget, + assertion.relation, + "import", + a, + b, + ); + const inserted = await insertAssertion( + rawTarget, + ctx.schema, + ctx.graphId, + assertion.relation, + a, + b, + operationInstant, + touch, + { id: assertion.id, validFrom: window.validFrom }, + ); + existingById.set(inserted.id, inserted); + windowValidator.record(inserted); + created += 1; + await applyPairRelationEffect( + ctx, + rawTarget, + assertion.relation, + a, + b, + noteTransition, + { + cause, + assertions: [inserted], + validAt: operationInstant, + }, + ); + continue; + } + await requireEndpointsCoverIdentityWindow( rawTarget, ctx.graphId, @@ -397,133 +507,296 @@ export async function importIdentityAssertionsIntoTarget( window, ); windowValidator.validate(assertion.relation, "import", a, b, window); - // The temporal check owns historical correctness. The current check - // also exercises the materialized separation backstop/readiness guard - // before this row changes current derived state. - await validateCurrentRelation( - ctx, - rawTarget, - assertion.relation, - "import", - a, - b, - ); - const inserted = await insertAssertion( - rawTarget, - ctx.schema, - ctx.graphId, - assertion.relation, - a, - b, - operationInstant, - touch, - { id: assertion.id, validFrom: window.validFrom }, - ); - existingById.set(inserted.id, inserted); - windowValidator.record(inserted); + + const timestamp = window.validFrom; + const row: IdentityAssertionStorageRow = { + graph_id: ctx.graphId, + id: assertion.id, + rel: assertion.relation, + a_kind: a.kind, + a_id: a.id, + b_kind: b.kind, + b_id: b.id, + valid_from: window.validFrom, + valid_to: window.validTo, + created_at: timestamp, + updated_at: window.validTo ?? window.validFrom, + deleted_at: undefined, + ended_by_kind: assertion.endedBy?.kind, + ended_by_id: assertion.endedBy?.id, + }; + await insertAssertionRows(rawTarget, ctx.schema, [row]); + touch(ctx.graphId, row.id, row); + existingById.set(row.id, row); + windowValidator.record(row); created += 1; - if (assertion.relation === "same") { - await mergeCurrentClasses(rawTarget, ctx.schema, ctx.graphId, a, b); - } else { - await replaceSeparationForReferences( - rawTarget, - ctx.schema, - ctx.graphId, - [a, b], - ); - } - continue; + } catch (error) { + rethrowTaggedWithAssertion(error, assertion.id, { created, skipped }); } + } + }, + ); + return { created, skipped }; +} - await requireEndpointsCoverIdentityWindow( - rawTarget, - ctx.graphId, - [a, b], - window, - ); - windowValidator.validate(assertion.relation, "import", a, b, window); +/** What `importIdentityTransitionsIntoTarget` reads off the service context. */ +type IdentityTransitionImportContext = Readonly<{ + graphId: string; + schema: SqlSchema; +}>; - const timestamp = window.validFrom; - const row: IdentityAssertionStorageRow = { - graph_id: ctx.graphId, - id: assertion.id, - rel: assertion.relation, - a_kind: a.kind, - a_id: a.id, - b_kind: b.kind, - b_id: b.id, - valid_from: window.validFrom, - valid_to: window.validTo, - created_at: timestamp, - updated_at: window.validTo ?? window.validFrom, - deleted_at: undefined, - ended_by_kind: assertion.endedBy?.kind, - ended_by_id: assertion.endedBy?.id, - }; - await insertAssertionRows(rawTarget, ctx.schema, [row]); - touch(ctx.graphId, row.id, row); - existingById.set(row.id, row); - windowValidator.record(row); - created += 1; - } catch (error) { - rethrowTaggedWithAssertion(error, assertion.id, { created, skipped }); - } - } +function transitionShapeError(message: string, code: string): ValidationError { + return new ValidationError(message, { + issues: [{ path: "identity.transitions", message, code }], }); - return { created, skipped }; } -export async function applyIdentityChangesForContext( - ctx: IdentityServiceContext, - retractions: readonly IdentityTransferAssertion[], - assertions: readonly IdentityTransferAssertion[], -): Promise> { - if (retractions.length === 0 && assertions.length === 0) { - return { created: 0, retracted: 0 }; +/** + * Whether an import restores retained identity explanation: transition rows, + * or the watermark of a source that pruned. A zero watermark records that + * nothing was pruned, which restores nothing. THE one decision every import + * site takes on it — the up-front refusal on a target that keeps no + * transition log, the read of the destination baseline before the first + * write, and the restore itself — so no site restores what another refused, + * or refuses what another would skip. + */ +export function isIdentityTransitionRestore( + hasTransitions: boolean, + prunedBeforeRevision: number | undefined, +): boolean { + return hasTransitions || (prunedBeforeRevision ?? 0) > 0; +} + +/** The refusal for an archival restore whose rows are out of recorded-revision order. */ +function transitionsNotMonotoneError( + transitionId: string, + revision: number, + previousRevision: number, +): ValidationError { + return transitionShapeError( + `Archival identity transitions must be ordered by non-decreasing recorded revision; ${transitionId} carries ${String(revision)} after ${String(previousRevision)}.`, + "IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE", + ); +} + +/** + * Refuses archival transitions out of non-decreasing `recordedRevision` order + * and returns the last revision seen. `afterRevision` is the last revision a + * previous part of the same restore carried, so a streamed restore whose + * transitions arrive over several chunks is held to the order a single + * document is, across chunk boundaries. + */ +export function assertIdentityTransitionsMonotone( + transitions: readonly Readonly<{ + transitionId: string; + recordedRevision: number; + }>[], + afterRevision = Number.NEGATIVE_INFINITY, +): number { + let previousRevision = afterRevision; + for (const row of transitions) { + if (row.recordedRevision < previousRevision) { + throw transitionsNotMonotoneError( + row.transitionId, + row.recordedRevision, + previousRevision, + ); + } + previousRevision = row.recordedRevision; } - return runIdentityMutation(ctx, async (target, touch, markWritten) => { - const retracted = await retractPlannedAssertions( - ctx, - target, - retractions, - touch, + return previousRevision; +} + +/** + * Reads {@link IdentityRestoreBaseline} off `target`. THE one decision of + * whether a destination is fresh, taken once per import — at the start of + * `importGraph`'s transaction, and before the first chunk of + * `importGraphStream` — so both restore the same archive to the same + * watermark. + */ +export async function readIdentityRestoreBaseline( + ctx: IdentityTransitionImportContext, + target: IdentityTarget, +): Promise { + return withRecordedIdentityMutationTarget(target, async (rawTarget) => { + const floorRevision = await readNextRecordedRevision( + rawTarget, + ctx.schema, + ctx.graphId, ); - const { closureReferences, separationReferences } = - partitionRetractedEndpoints(retracted); - // Repair the closure from the retractions BEFORE importing: a batch that - // retracts same(a,b) and then asserts different(a,b) must validate the new - // assertion against a closure that already reflects the split, not the - // stale merged class the import validation would otherwise reject against. - if (closureReferences.length > 0) { - await replaceAffectedClosure( - target, + return { + floorRevision, + hasOwnHistory: await hasNativeIdentityTransitionsBefore( + rawTarget, ctx.schema, ctx.graphId, - closureReferences, - ctx.sameIdAcrossKinds, + floorRevision, + ), + }; + }); +} + +/** + * Restores archival transition-log rows verbatim — no closure repair, no + * re-derived membership, no renumbering onto the destination graph's live + * revision sequence, because a restore records history, it does not relive + * it (see "Archival transitions and the retention watermark" in the identity + * documentation). Its rows arrive already validated for SHAPE only, before + * the import's first write: the interchange schema enforces a known cause and + * a well-formed `{kind, id}` ref, and {@link assertIdentityTransitionsMonotone} + * a non-decreasing `recordedRevision` across the whole restore. + * + * Every inserted row is marked `restored_at` (the restore's own wall time), + * regardless of what the wire payload carried — see + * `encodeIdentityTransitionRow`'s docblock. That marker, not a revision + * comparison, is what keeps `identityReplay` from ever pairing one of these + * rows with a fabricated before/after: a restored row's `recordedRevision` + * is minted by the SOURCE graph's own clock and interleaves arbitrarily with + * this graph's, so no floor on this graph's axis could separate "restored" + * from "native" by number alone. + * + * The retention watermark is a SEPARATE, coarser signal — "this graph cannot + * vouch for a complete history below revision N on its own axis" — and is + * only ever advanced here when the destination's {@link IdentityRestoreBaseline} + * says it had recorded no identity transitions of its own when the import + * began. That baseline is a fact about the destination BEFORE the import, so + * it is read once, ahead of the import's first write, and handed in + * ({@link readIdentityRestoreBaseline}): probing here instead would answer + * about a graph the import itself has already written to, and a streamed + * import — whose node and assertion chunks commit their own transitions + * before this one runs — would never find its destination fresh. Advancing it + * unconditionally (the original design here) would, for a graph that already + * has its own retained history, stamp a + * destination-clock-derived floor over transitions the restore never + * touched — misreporting the `retention-truncated` completeness reason, and + * `IDENTITY_REPLAY_HISTORY_TRUNCATED`, + * for classes the restore had nothing to do with. A graph with no native + * rows yet has nothing of its own for that floor to misclassify, so setting + * it there stays sound: reading THIS graph's clock at restore time and + * adding one gives an honest floor on this graph's own timeline (there is no + * earlier revision on it yet), and every later one the destination goes on + * to record for real is, by the clock's own monotonicity, always at or + * above it. The watermark write goes through the same monotonic + * `writeIdentityTransitionRetentionWatermark` `pruneIdentityTransitionsForContext` + * uses, so a later restore can only raise the floor, never lower it. + */ +export async function importIdentityTransitionsIntoTarget( + ctx: IdentityTransitionImportContext, + target: IdentityTarget, + transitions: readonly IdentityTransitionTransfer[], + carriedWatermark: number | undefined, + baseline: IdentityRestoreBaseline, +): Promise> { + // Raw identity statements run through the capture-approved handle + // `withRecordedIdentityMutationTarget` resolves — under `history: true` the + // target this function was HANDED refuses `executeStatement` outright (raw + // SQL bypasses recorded-time capture), exactly as every other identity + // relation writer already goes through this seam. Neither `touch` nor + // `noteTransition` is used: a restore inserts historical rows verbatim, it + // does not touch live entities or note a NEW transition. + return withRecordedIdentityMutationTarget(target, async (rawTarget) => { + const { floorRevision: destinationFloor, hasOwnHistory } = baseline; + const restoredAt = nowIso(); + if (transitions.length > 0) { + const values = transitions.map((row) => + encodeIdentityTransitionRow( + { + graphId: ctx.graphId, + cause: row.cause, + classRef: row.class, + priorClassRef: row.priorClass, + assertionIds: row.assertionIds, + decision: row.decision, + validAt: row.validAt, + }, + row.recordedRevision, + row.recordedAt, + row.transitionId, + restoredAt, + ), ); + await insertIdentityTransitionValues(rawTarget, ctx.schema, values); + } + if ( + hasOwnHistory || + !isIdentityTransitionRestore(transitions.length > 0, carriedWatermark) + ) { + return { created: transitions.length, watermark: undefined }; } - await replaceSeparationForReferences( - target, + await writeIdentityTransitionRetentionWatermark( + rawTarget, ctx.schema, ctx.graphId, - separationReferences, - ); - const summary = await importIdentityAssertionsIntoTarget( - ctx, - target, - assertions, - "archival", - new Set(retracted.map((assertion) => assertion.id)), + destinationFloor, + restoredAt, ); - // The import records capture touches through its OWN recorded binding, so - // the mutation's wrapped touch never fires for created rows — an - // identity-only merge would otherwise leave the durable revision clock - // unmoved and every base@V token stale. - if (summary.created > 0) markWritten(); - // ACTUAL ledger effects, not planned intents: rows the import created - // (idempotent exact/pair matches excluded) and rows the retraction ended - // (already-ended or unknown ids excluded). - return { created: summary.created, retracted: retracted.length }; + return { created: transitions.length, watermark: destinationFloor }; }); } + +export async function applyIdentityChangesForContext( + ctx: IdentityServiceContext, + retractions: readonly IdentityTransferAssertion[], + assertions: readonly IdentityTransferAssertion[], + // The governing merge decision, when this apply runs under a reviewed + // graph-merge plan. `undefined` for an ordinary interchange apply with no + // decision to attach — every note this call takes then carries + // `decision: undefined`, matching an unreviewed API write. + decision?: IdentityDecisionProvenance, +): Promise> { + if (retractions.length === 0 && assertions.length === 0) { + return { created: 0, retracted: 0 }; + } + return runIdentityMutation( + ctx, + async (target, touch, markWritten, noteTransition) => { + // A `reconcile` cause always carries a decision. + // Without a governing decision this is an ordinary interchange apply, and its closure repair / import carry the cause + // that already describes them elsewhere (`retract` for the split a + // batch of retractions can cause, `assert` for the union an import + // performs) — never a hardcoded `reconcile` that would misrepresent an + // undecided write as governed. + const closureRepairCause = + decision === undefined ? "retract" : "reconcile"; + const importCause = decision === undefined ? "assert" : "reconcile"; + const applyBody = async (): Promise< + Readonly<{ created: number; retracted: number }> + > => { + const operationInstant = nowIso(); + const retracted = await retractPlannedAssertions( + ctx, + target, + retractions, + touch, + ); + // Repair the closure from the retractions BEFORE importing: a batch that + // retracts same(a,b) and then asserts different(a,b) must validate the new + // assertion against a closure that already reflects the split, not the + // stale merged class the import validation would otherwise reject against. + await applyRetractionAftermath(ctx, target, retracted, noteTransition, { + cause: closureRepairCause, + validAt: operationInstant, + }); + const summary = await importIdentityAssertionsIntoTarget( + ctx, + target, + assertions, + "archival", + new Set(retracted.map((assertion) => assertion.id)), + importCause, + ); + // The import records capture touches through its OWN recorded binding, so + // the mutation's wrapped touch never fires for created rows — an + // identity-only merge would otherwise leave the durable revision clock + // unmoved and every base@V token stale. + if (summary.created > 0) markWritten(); + // ACTUAL ledger effects, not planned intents: rows the import created + // (idempotent exact/pair matches excluded) and rows the retraction ended + // (already-ended or unknown ids excluded). + return { created: summary.created, retracted: retracted.length }; + }; + if (decision === undefined) return applyBody(); + return withRecordedIdentityDecision(target, decision, applyBody); + }, + ); +} diff --git a/packages/typegraph/src/identity/service-maintenance.ts b/packages/typegraph/src/identity/service-maintenance.ts index 1b5a50e84..3ce345e91 100644 --- a/packages/typegraph/src/identity/service-maintenance.ts +++ b/packages/typegraph/src/identity/service-maintenance.ts @@ -8,13 +8,14 @@ import { import { type SqlSchema } from "../query/compiler/schema"; import { sql } from "../query/sql-fragment"; import { asCompiledRowsSql } from "../query/sql-intent"; +import { type KindRegistry } from "../registry/kind-registry"; import { batchRefusalDetails, batchRefusalSuffix, resolveBatchWriteVerdict, } from "../store/operations/write-transaction"; import { withRecordedIdentityMutationTarget } from "../store/recorded-capture"; -import { chunk } from "../utils/array"; +import { chunk, groupBy } from "../utils/array"; import { nowIso } from "../utils/date"; import { requireDefined } from "../utils/presence"; import { IDENTITY_ASSERTION_COLUMNS } from "./historical-sql"; @@ -35,10 +36,12 @@ import { identityActiveKinds, loadLiveReferences, loadSnapshot, + readClosureRowsForGraph, validateSnapshotIntegrity, } from "./service-components"; import { runIdentityMutation } from "./service-facade"; import { + noteClassTransitions, replaceAffectedClosure, replaceClosure, snapshotClassKey, @@ -47,8 +50,10 @@ import { import type { Backend, RawClosureClassRow } from "./service-read"; import { clampValidTo, + compareReferences, loadAssertionsTouching, loadCurrentStructuralClassComponents, + loadCurrentStructuralClasses, lockIdentityGraph, refKey, } from "./service-read"; @@ -60,6 +65,10 @@ import { type PlainNodeRef, } from "./sql-target"; import { type IdentityAssertionStorageRow } from "./storage-types"; +import { + type ClosureTransitionRecord, + diffClosureTransitions, +} from "./transition-log"; /** * The context slice a closure rebuild reads. Narrower than the full @@ -462,6 +471,9 @@ export async function assertAffectedIdentityClassesConsistent< * orphans: invisible to closure and live-endpoint interchange reads, yet * still present to raw ledger reads and merge staging. * + * Returns the ids of the assertions it removed, so a kind-drop cause note can + * name exactly which ledger rows the cascade attributes its class changes to. + * * @internal */ export async function deleteAssertionsTouchingKinds( @@ -470,9 +482,9 @@ export async function deleteAssertionsTouchingKinds( graphId: string, kinds: readonly string[], touch: (graphId: string, id: string) => void, -): Promise { +): Promise { const removedKinds = [...new Set(kinds)]; - if (removedKinds.length === 0) return; + if (removedKinds.length === 0) return []; const matched = new Map(); const kindChunkSize = identityChunkSize(target, { fixedParameters: 1, @@ -517,7 +529,9 @@ export async function deleteAssertionsTouchingKinds( ); } } - for (const row of matched.values()) touch(graphId, row.id); + const removed = [...matched.values()]; + for (const row of removed) touch(graphId, row.id); + return removed; } /** @@ -600,6 +614,125 @@ export async function purgeAssertionsWithUnregisteredKinds( ); } +/** + * A full graph-wide snapshot of the materialized closure, keyed by member — + * the "before" and "after" `diffClosureTransitions` needs around a FULL + * rebuild (`replaceClosure`), which — unlike `replaceAffectedClosure` — + * recomputes every class at once rather than handing back which ones it + * touched. Used only around the kind-drop / schema-transition rebuilds, never + * on a hot write path. + */ +export async function snapshotIdentityClosureClasses( + target: Backend, + schema: SqlSchema, + graphId: string, +): Promise> { + const rows = await readClosureRowsForGraph(target, schema, graphId); + const rowsByClass = groupBy(rows, (row) => + refKey({ kind: row.class_kind, id: row.class_id }), + ); + const groups = [...rowsByClass.values()].map((classRows) => ({ + classRef: { + kind: requireDefined(classRows[0]).class_kind, + id: requireDefined(classRows[0]).class_id, + }, + members: classRows.map((row) => ({ + kind: row.member_kind, + id: row.member_id, + })), + })); + const byMember = new Map(); + for (const { classRef, members } of groups) { + // The class's canonical is the row's OWN `class_kind`/`class_id` — what + // the closure table already recorded — never re-derived by sorting the + // member set. `insertClosureComponents` and `mergeCurrentClasses` both + // happen to label a class by its code-point-least member today, but a + // consumer of this snapshot (the kind-drop / schema-transition diff) must + // agree with whatever the table actually stored, including a partially + // repaired closure or a future relabelling rule that breaks that + // coincidence. + const canonicalKey = refKey(classRef); + const rest = members + .filter((member) => refKey(member) !== canonicalKey) + .toSorted((left, right) => compareReferences(left, right)); + const ordered = [classRef, ...rest]; + for (const member of ordered) byMember.set(refKey(member), ordered); + } + return byMember; +} + +/** The union of two closure snapshots' members, for `diffClosureTransitions`'s `affected` argument. */ +function combineSnapshotMembers( + ...snapshots: readonly ReadonlyMap[] +): readonly PlainNodeRef[] { + const byKey = new Map(); + for (const snapshot of snapshots) { + for (const members of snapshot.values()) { + for (const member of members) byKey.set(refKey(member), member); + } + } + return [...byKey.values()]; +} + +/** + * `snapshotIdentityClosureClasses` reads the closure TABLE, which — unlike + * `buildComponents`' UnionFind — carries no row for a singleton, so it cannot + * tell "this member is alive but now a class of one" apart from "this member + * no longer exists". `diffClosureTransitions` treats absence as the latter + * (a hard-deleted or kind-dropped member, whose departure another surviving + * member's record explains) and would otherwise silently skip a LIVE + * singleton's own genuine membership change. This fills every `affected` + * member missing from `snapshot` with its own trivial `[member]` class when + * the registry still has its kind, leaving a truly dead member + * absent (unchanged behavior). + */ +function fillLiveSingletons( + snapshot: ReadonlyMap, + affected: readonly PlainNodeRef[], + registry: KindRegistry, +): ReadonlyMap { + const filled = new Map(snapshot); + for (const member of affected) { + const key = refKey(member); + if (filled.has(key)) continue; + if (registry.nodeKinds.has(member.kind)) filled.set(key, [member]); + } + return filled; +} + +/** + * Diffs the closure around a FULL rebuild: reads the post-rebuild snapshot and + * returns the class transitions between it and the `before` snapshot taken + * ahead of the rebuild. + * + * `fillBefore` gives `before` the same singleton fill `after` gets. A member + * that was a live singleton (no closure ROW — `fillLiveSingletons`' own doc + * explains why that is not "gone") before the rebuild would otherwise read as + * "no longer exists" to `diffClosureTransitions`, silently skipping first + * enablement folding two previously-untouched singletons. + */ +export async function diffFullRebuildTransitions( + target: Backend, + ctx: Pick< + IdentityServiceContext, + "graphId" | "registry" | "schema" + >, + before: ReadonlyMap, + fillBefore: boolean, +): Promise { + const afterRows = await snapshotIdentityClosureClasses( + target, + ctx.schema, + ctx.graphId, + ); + const affected = combineSnapshotMembers(before, afterRows); + return diffClosureTransitions( + affected, + fillBefore ? fillLiveSingletons(before, affected, ctx.registry) : before, + fillLiveSingletons(afterRows, affected, ctx.registry), + ); +} + /** * Cascades removed node kinds through the assertion ledger. * @@ -616,23 +749,52 @@ export async function removeIdentityKindsForContext( ): Promise { if (kinds.length === 0) return; const removedKinds = [...new Set(kinds)]; - await runIdentityMutation(ctx, async (target, touch) => { - await deleteAssertionsTouchingKinds( - target, - ctx.schema, - ctx.graphId, - removedKinds, - touch, - ); - if (options?.repairClosure === false) return; - await replaceClosure( - target, - ctx.schema, - ctx.graphId, - identityActiveKinds(ctx.registry), - ctx.sameIdAcrossKinds, - ); - }); + await runIdentityMutation( + ctx, + async (target, touch, _markWritten, noteTransition) => { + const repairClosure = options?.repairClosure !== false; + const before = + repairClosure ? + await snapshotIdentityClosureClasses(target, ctx.schema, ctx.graphId) + : undefined; + const removedAssertions = await deleteAssertionsTouchingKinds( + target, + ctx.schema, + ctx.graphId, + removedKinds, + touch, + ); + if (before === undefined) return; + await replaceClosure( + target, + ctx.schema, + ctx.graphId, + identityActiveKinds(ctx.registry), + ctx.sameIdAcrossKinds, + ); + // `before` does NOT need the singleton fill the schema-commit preflight + // gives its own pre-commit snapshot: that fill exists to catch a live + // singleton folding into a class for the FIRST time, which can only + // happen when the closure is built from a state where identity had not + // yet run at all (first enablement). On an already-enabled graph, every + // node creation folds in the write frame that creates it, so no live + // member can reach this cascade as an untouched singleton that is about + // to be folded for the first time — `replaceClosure`'s active-kinds + // rebuild can only ever shrink or remove existing classes, never mint a + // genuinely new one. + const transitions = await diffFullRebuildTransitions( + target, + ctx, + before, + false, + ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause: "kind-drop", + assertions: removedAssertions, + validAt: nowIso(), + }); + }, + ); } /** @@ -682,57 +844,159 @@ export async function liveNodeKindsSharingIds( return liveKindsById; } +type FoldContext = Pick< + IdentityServiceContext, + "graphId" | "registry" | "sameIdAcrossKinds" | "schema" +>; + +/** + * The READ half of folding created nodes into identity: for each reference, the + * live nodes of other kinds sharing its id, each validated as `same` against + * it ({@link validateCurrentRelation}) — the contradictions a fold can raise. + * Returns the references the fold materializes a closure over (each reference + * with its peers); nothing is written. + * + * One owner of that verdict, read by the fold itself and by + * {@link assertIdentityFoldable}, so the answer a batch reaches before its + * first write is the answer the fold gives when the write lands. + */ +async function validateFoldPeers( + ctx: FoldContext, + rawTarget: Backend, + references: readonly PlainNodeRef[], +): Promise { + const liveKindsById = await liveNodeKindsSharingIds( + ctx, + rawTarget, + references.map((ref) => ref.id), + ); + const closureReferences: PlainNodeRef[] = []; + for (const ref of references) { + // Registry order, not row-arrival order: which conflicting peer + // `validateCurrentRelation` reports first must not depend on how the + // engine happened to return rows. Iterating the registry also drops + // rows whose kind is outside it — those never participate in identity. + const liveKinds = liveKindsById.get(ref.id); + const peers: PlainNodeRef[] = []; + if (liveKinds !== undefined) { + for (const kind of ctx.registry.nodeKinds.keys()) { + if (kind === ref.kind || !liveKinds.has(kind)) continue; + peers.push({ kind, id: ref.id }); + } + } + if (peers.length === 0) continue; + for (const peer of peers) { + await validateCurrentRelation(ctx, rawTarget, "same", "fold", ref, peer); + } + closureReferences.push(ref, ...peers); + } + return closureReferences; +} + +/** + * The fold a set of created or restored node references owes, as + * {@link assertIdentityFoldable} decided it: the references the closure is + * materialized over, each folding reference with its live same-id peers. + * Empty when nothing folds. + */ +export type IdentityFoldPlan = Readonly<{ + closureReferences: readonly PlainNodeRef[]; +}>; + +const NO_IDENTITY_FOLD: IdentityFoldPlan = { closureReferences: [] }; + +/** + * Whether folding `references` into identity would be refused, answered by + * reading alone: raises the `IdentityContradictionError` the fold would, and + * writes nothing. A frame that creates or restores rows calls it before its + * first statement, so a refusal the fold can only reach after a row is written + * is not left to the write. The references are judged against the identity + * state as stored, with the frame's own earlier restores not yet folded. + * + * Returns the fold it decided. {@link applyIdentityFold} writes that plan once + * the rows have landed, without reading the verdict a second time: the + * created row's own write changes neither its live same-id peers nor any + * class, so the plan still holds while the frame keeps the identity lock it + * was decided under. + */ +export async function assertIdentityFoldable( + ctx: FoldContext, + target: Backend, + references: readonly PlainNodeRef[], +): Promise { + if (references.length === 0 || ctx.sameIdAcrossKinds === "ignore") { + return NO_IDENTITY_FOLD; + } + return withRecordedIdentityMutationTarget(target, async (rawTarget) => ({ + closureReferences: await validateFoldPeers(ctx, rawTarget, references), + })); +} + +/** + * Writes the fold {@link assertIdentityFoldable} decided, after the rows it + * folds have been written. + */ +export async function applyIdentityFold( + ctx: FoldContext, + target: Backend, + plan: IdentityFoldPlan, + // See {@link foldIdentityForCreatedNodes} for how the call site decides it. + cause: "fold" | "restore", +): Promise { + if (plan.closureReferences.length === 0) return; + await lockIdentityGraph(target, ctx.graphId); + await writeIdentityFold(ctx, target, plan, cause); +} + export async function foldIdentityForCreatedNodes( - ctx: Pick< - IdentityServiceContext, - "graphId" | "registry" | "sameIdAcrossKinds" | "schema" - >, + ctx: FoldContext, target: Backend, references: readonly PlainNodeRef[], + // `restore` (a node resurrection re-running the fold) vs. `fold` (an + // ordinary create) is decided by NODE STATE, at the call site, which + // already knows the answer: every write path that can resurrect a + // soft-deleted id (the tombstone-resurrection leg of `create`, and + // `update({ clearDeleted: true })`) routes through this parameter, rather + // than through the same write path a fresh create takes. The transition + // log cannot answer this reliably instead — a departing member's OWN + // `detach` record names only the SURVIVING class-mate (see + // `diffClosureTransitions`'s "absent from the new state" branch), so a + // probe for `references` themselves as `class` or `priorClass` finds + // nothing for exactly the member being resurrected, and would silently + // misclassify a restore as a fold once its history aged past any bounded + // probe window. + cause: "fold" | "restore", ): Promise { if (references.length === 0 || ctx.sameIdAcrossKinds === "ignore") return; await lockIdentityGraph(target, ctx.graphId); - await withRecordedIdentityMutationTarget(target, async (rawTarget) => { - const liveKindsById = await liveNodeKindsSharingIds( - ctx, - rawTarget, - references.map((ref) => ref.id), - ); - const closureReferences: PlainNodeRef[] = []; - for (const ref of references) { - // Registry order, not row-arrival order: which conflicting peer - // `validateCurrentRelation` reports first must not depend on how the - // engine happened to return rows. Iterating the registry also drops - // rows whose kind is outside it — those never participate in identity. - const liveKinds = liveKindsById.get(ref.id); - const peers: PlainNodeRef[] = []; - if (liveKinds !== undefined) { - for (const kind of ctx.registry.nodeKinds.keys()) { - if (kind === ref.kind || !liveKinds.has(kind)) continue; - peers.push({ kind, id: ref.id }); - } - } - if (peers.length === 0) continue; - for (const peer of peers) { - await validateCurrentRelation( - ctx, - rawTarget, - "same", - "fold", - ref, - peer, - ); - } - closureReferences.push(ref, ...peers); - } - await replaceAffectedClosure( - rawTarget, - ctx.schema, - ctx.graphId, - closureReferences, - ctx.sameIdAcrossKinds, - ); - }); + const plan = await assertIdentityFoldable(ctx, target, references); + await writeIdentityFold(ctx, target, plan, cause); +} + +async function writeIdentityFold( + ctx: FoldContext, + target: Backend, + plan: IdentityFoldPlan, + cause: "fold" | "restore", +): Promise { + if (plan.closureReferences.length === 0) return; + await withRecordedIdentityMutationTarget( + target, + async (rawTarget, _touch, noteTransition) => { + const transitions = await replaceAffectedClosure( + rawTarget, + ctx.schema, + ctx.graphId, + plan.closureReferences, + ctx.sameIdAcrossKinds, + ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause, + assertions: [], + validAt: nowIso(), + }); + }, + ); } /** @@ -743,7 +1007,7 @@ export async function foldIdentityForCreatedNodes( * ({@link loadCurrentStructuralClasses} anchors on it and coalesces a missing * row to the node itself), so no row means the node is a singleton under every * source of identity — assertions AND the same-id structural fold, which - * `foldIdentityForCreatedNodes` materializes at create time. + * a created node's own fold materializes at create time. */ async function hasMaterializedIdentityClass( target: Backend, @@ -799,14 +1063,37 @@ async function readNodeDeletionInstant( ); } -/** Refuses a finite node window that would strand identity assertion history. */ -export async function requireNodeValidityEndCompatible( - ctx: Pick, "graphId" | "schema">, +/** Reads a node's currently stored `valid_to`, as it stands BEFORE the write in progress applies it. */ +async function readNodeValidTo( target: Backend, + schema: SqlSchema, + graphId: string, + ref: PlainNodeRef, +): Promise { + const rows = await target.execute>( + asCompiledRowsSql(sql` + SELECT valid_to + FROM ${schema.nodesTable} + WHERE graph_id = ${graphId} + AND kind = ${ref.kind} + AND id = ${ref.id} + LIMIT 1 + `), + ); + const row = rows.at(0); + return row === undefined ? undefined : ( + optionalIdentityTimestamp(row.valid_to) + ); +} + +/** Refuses a finite node window end that an open or later-ending identity assertion touching `ref` would outlive. */ +async function refuseStrandedAssertionHistory( + ctx: Pick, "graphId" | "schema">, + rawTarget: Backend, ref: PlainNodeRef, validTo: string, ): Promise { - const rows = await target.execute( + const rows = await rawTarget.execute( asCompiledRowsSql(sql` SELECT ${IDENTITY_ASSERTION_COLUMNS} FROM ${ctx.schema.identityAssertionsTable} @@ -822,18 +1109,96 @@ export async function requireNodeValidityEndCompatible( `), ); const row = rows.at(0); - if (row === undefined) return; - const assertion = normalizeIdentityAssertionRow(row); - throw new IdentityEndpointValidityError({ - endpoint: ref, - assertionWindow: { - validFrom: assertion.valid_from, - ...(assertion.valid_to === undefined ? - {} - : { validTo: assertion.valid_to }), + if (row !== undefined) { + const assertion = normalizeIdentityAssertionRow(row); + throw new IdentityEndpointValidityError({ + endpoint: ref, + assertionWindow: { + validFrom: assertion.valid_from, + ...(assertion.valid_to === undefined ? + {} + : { validTo: assertion.valid_to }), + }, + endpointWindow: { validTo }, + }); + } +} + +/** + * Refuses a finite node window that would strand identity assertion history, + * then — once the move is confirmed safe AND confirmed to actually MOVE the + * window — notes the `window-end` transition when `ref` currently belongs to + * a real (>=2 member) identity class: moving its own valid-time window end + * changes what a valid-time `membersOf` read returns after the old or new end + * even though nothing in the ledger or the closure table is written. + * Self-referential (`classRef === priorClassRef`) because the class's LABEL + * does not change, only its coordinate-visible membership. + * + * `validTo: undefined` is a CLEARED end (`clearValidTo`): the window reopens, + * which strands no assertion — there is nothing to refuse — but it moves the + * boundary back exactly as a narrowing moved it, so it is noted under the same + * cause whenever the stored end was finite. + * + * A repeat update that restates the SAME `validTo` moves nothing — a + * transition boundary is only recorded where membership actually changed, so + * this compares against the node's own stored `valid_to` and takes no note + * when they already agree. `validAt` is the canonical operation instant, + * never `validTo` itself, which can be a future-scheduled window + * boundary rather than "when this happened". + */ +export async function requireNodeValidityEndCompatible( + ctx: Pick, "graphId" | "schema">, + target: Backend, + ref: PlainNodeRef, + validTo: string | undefined, +): Promise { + await withRecordedIdentityMutationTarget( + target, + async (rawTarget, _touch, noteTransition) => { + if (validTo !== undefined) { + await refuseStrandedAssertionHistory(ctx, rawTarget, ref, validTo); + } + // A node in no real (>= 2 member) class can produce no `window-end` + // transition at all: no closure row means a singleton under every source + // of identity, which is exactly the `currentClass.length < 2` return + // below. One indexed seek on the closure primary key therefore replaces + // the node seek AND the closure join in the steady state — the same gate + // `detachIdentityForNode` takes, for the same reason. + if ( + !(await hasMaterializedIdentityClass( + rawTarget, + ctx.schema, + ctx.graphId, + ref, + )) + ) { + return; + } + const currentValidTo = await readNodeValidTo( + rawTarget, + ctx.schema, + ctx.graphId, + ref, + ); + if (currentValidTo === validTo) return; + const classes = await loadCurrentStructuralClasses( + rawTarget, + ctx.schema, + ctx.graphId, + [ref], + ); + const currentClass = requireDefined(classes.get(refKey(ref))); + if (currentClass.length < 2) return; + const canonical = requireDefined(currentClass[0]); + noteTransition(ctx.graphId, { + cause: "window-end", + classRef: canonical, + priorClassRef: canonical, + assertionIds: [], + validAt: nowIso(), + }); }, - endpointWindow: { validTo }, - }); + ); } export async function detachIdentityForNode( @@ -846,107 +1211,117 @@ export async function detachIdentityForNode( mode: "soft" | "hard", ): Promise { await lockIdentityGraph(target, ctx.graphId); - await withRecordedIdentityMutationTarget(target, async (rawTarget, touch) => { - const touchesNode = sql` - ( - (a_kind = ${ref.kind} AND a_id = ${ref.id}) - OR (b_kind = ${ref.kind} AND b_id = ${ref.id}) - ) - `; - // Hard delete physically removes the node, so EVERY assertion touching it — - // including already-ended and previously soft-deleted rows — must be - // removed, or a node soft-deleted before its hard delete would leave - // archival assertions referencing a row that no longer exists. Soft delete - // only ends the currently-open rows. - const scope = - mode === "hard" ? - sql`` - : sql`AND valid_to IS NULL AND deleted_at IS NULL`; - const rows = await rawTarget.execute( - asCompiledRowsSql(sql` - SELECT ${IDENTITY_ASSERTION_COLUMNS} - FROM ${ctx.schema.identityAssertionsTable} - WHERE graph_id = ${ctx.graphId} - ${scope} - AND ${touchesNode} - `), - ); - // Most deletes are of nodes that never participated in identity. Such a - // node has no assertion rows in scope and no materialized class row, so its - // component is itself and the closure repair below would delete and - // reinsert nothing — one indexed lookup replaces its five statements. - if ( - rows.length === 0 && - !(await hasMaterializedIdentityClass( - rawTarget, - ctx.schema, - ctx.graphId, - ref, - )) - ) { - return; - } - const now = nowIso(); - // A soft delete ends its node's open assertions at the node's OWN deletion - // instant (see readNodeDeletionInstant): the caller has already written - // `deleted_at` inside this transaction, and reusing it keeps the node and - // its assertions agreeing at every valid-time instant. The read is skipped - // when there is nothing to end. - const cascadeInstant = - mode === "hard" || rows.length === 0 ? - now - : ((await readNodeDeletionInstant( + await withRecordedIdentityMutationTarget( + target, + async (rawTarget, touch, noteTransition) => { + const touchesNode = sql` + ( + (a_kind = ${ref.kind} AND a_id = ${ref.id}) + OR (b_kind = ${ref.kind} AND b_id = ${ref.id}) + ) + `; + // Hard delete physically removes the node, so EVERY assertion touching it — + // including already-ended and previously soft-deleted rows — must be + // removed, or a node soft-deleted before its hard delete would leave + // archival assertions referencing a row that no longer exists. Soft delete + // only ends the currently-open rows. + const scope = + mode === "hard" ? + sql`` + : sql`AND valid_to IS NULL AND deleted_at IS NULL`; + const rows = await rawTarget.execute( + asCompiledRowsSql(sql` + SELECT ${IDENTITY_ASSERTION_COLUMNS} + FROM ${ctx.schema.identityAssertionsTable} + WHERE graph_id = ${ctx.graphId} + ${scope} + AND ${touchesNode} + `), + ); + // Most deletes are of nodes that never participated in identity. Such a + // node has no assertion rows in scope and no materialized class row, so its + // component is itself and the closure repair below would delete and + // reinsert nothing — one indexed lookup replaces its five statements. + if ( + rows.length === 0 && + !(await hasMaterializedIdentityClass( rawTarget, ctx.schema, ctx.graphId, ref, - )) ?? now); - for (const rawRow of rows) { - const row = normalizeIdentityAssertionRow(rawRow); - if (mode === "hard") { - await executeIdentityStatement( - rawTarget, - sql` - DELETE FROM ${ctx.schema.identityAssertionsTable} - WHERE graph_id = ${ctx.graphId} AND id = ${row.id} - `, - ); - touch(ctx.graphId, row.id); - } else { - const validTo = clampValidTo(cascadeInstant, row.valid_from); - // Stamp the CAUSE of the ending alongside it, in the same statement: - // this row stopped holding because `ref` was deleted, not because - // anyone retracted it. Downstream (graph-merge's state-diff) reads the - // stamp instead of trying to infer the cause from timestamps, which - // cannot separate a retraction issued in the delete's own millisecond - // from the cascade itself. - const ended = { - ...row, - valid_to: validTo, - updated_at: now, - ended_by_kind: ref.kind, - ended_by_id: ref.id, - }; - await executeIdentityStatement( - rawTarget, - sql` - UPDATE ${ctx.schema.identityAssertionsTable} - SET valid_to = ${validTo}, - updated_at = ${now}, - ended_by_kind = ${ref.kind}, - ended_by_id = ${ref.id} - WHERE graph_id = ${ctx.graphId} AND id = ${row.id} - `, - ); - touch(ctx.graphId, row.id, ended); + )) + ) { + return; } - } - await replaceAffectedClosure( - rawTarget, - ctx.schema, - ctx.graphId, - [ref], - ctx.sameIdAcrossKinds, - ); - }); + const now = nowIso(); + // A soft delete ends its node's open assertions at the node's OWN deletion + // instant (see readNodeDeletionInstant): the caller has already written + // `deleted_at` inside this transaction, and reusing it keeps the node and + // its assertions agreeing at every valid-time instant. The read is skipped + // when there is nothing to end. + const cascadeInstant = + mode === "hard" || rows.length === 0 ? + now + : ((await readNodeDeletionInstant( + rawTarget, + ctx.schema, + ctx.graphId, + ref, + )) ?? now); + const endedAssertions: IdentityAssertionStorageRow[] = []; + for (const rawRow of rows) { + const row = normalizeIdentityAssertionRow(rawRow); + endedAssertions.push(row); + if (mode === "hard") { + await executeIdentityStatement( + rawTarget, + sql` + DELETE FROM ${ctx.schema.identityAssertionsTable} + WHERE graph_id = ${ctx.graphId} AND id = ${row.id} + `, + ); + touch(ctx.graphId, row.id); + } else { + const validTo = clampValidTo(cascadeInstant, row.valid_from); + // Stamp the CAUSE of the ending alongside it, in the same statement: + // this row stopped holding because `ref` was deleted, not because + // anyone retracted it. Downstream (graph-merge's state-diff) reads the + // stamp instead of trying to infer the cause from timestamps, which + // cannot separate a retraction issued in the delete's own millisecond + // from the cascade itself. + const ended = { + ...row, + valid_to: validTo, + updated_at: now, + ended_by_kind: ref.kind, + ended_by_id: ref.id, + }; + await executeIdentityStatement( + rawTarget, + sql` + UPDATE ${ctx.schema.identityAssertionsTable} + SET valid_to = ${validTo}, + updated_at = ${now}, + ended_by_kind = ${ref.kind}, + ended_by_id = ${ref.id} + WHERE graph_id = ${ctx.graphId} AND id = ${row.id} + `, + ); + touch(ctx.graphId, row.id, ended); + } + } + const transitions = await replaceAffectedClosure( + rawTarget, + ctx.schema, + ctx.graphId, + [ref], + ctx.sameIdAcrossKinds, + ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, { + cause: "detach", + assertions: endedAssertions, + validAt: now, + }); + }, + ); } diff --git a/packages/typegraph/src/identity/service-mutation.ts b/packages/typegraph/src/identity/service-mutation.ts index c1eaf442d..69c7ba1ca 100644 --- a/packages/typegraph/src/identity/service-mutation.ts +++ b/packages/typegraph/src/identity/service-mutation.ts @@ -14,6 +14,7 @@ import { import { type SqlSchema } from "../query/compiler/schema"; import { sql, type SqlFragment } from "../query/sql-fragment"; import { asCompiledRowsSql } from "../query/sql-intent"; +import { type IdentityTransitionNoteFunction } from "../store/recorded-capture"; import { chunk } from "../utils/array"; import { canonicalizeDatabaseTimestamp } from "../utils/date"; import { generateId } from "../utils/id"; @@ -38,6 +39,7 @@ import { buildDistinctComponents, classHasDisjointKinds, componentFor, + indexComponentsByMember, loadLiveReferences, loadSnapshot, requireLiveEndpoint, @@ -73,6 +75,13 @@ import { type PlainNodeRef, } from "./sql-target"; import { type IdentityAssertionStorageRow } from "./storage-types"; +import { + type ClosureTransitionRecord, + diffClosureTransitions, + type IdentityTransitionCause, + transitionAssertionIds, + type TransitionAssertionRow, +} from "./transition-log"; import { type IdentityAssertionResult, type IdentityNodeRefInput, @@ -84,6 +93,44 @@ import { resolveIdentityValidityWindow, } from "./validity-window"; +/** What every note of one write shares; {@link noteClassTransitions} adds the per-class ids. */ +export type ClassTransitionNoteContext = Readonly<{ + cause: IdentityTransitionCause; + /** + * Every assertion the write created, ended or removed. Each transition is + * noted with only those that touched its own class + * ({@link transitionAssertionIds}). + */ + assertions: readonly TransitionAssertionRow[]; + validAt: string; +}>; + +/** + * Notes every closure transition a structural mutation (`mergeCurrentClasses`, + * `replaceAffectedClosure`) returned, under one shared cause and validAt. The + * single call every note site routes through, rather than re-spelling the + * `noteTransition({...})` object literal — and the one place a transition's + * `assertionIds` are decided, so no site can hand a class the ids of a whole + * batch. + */ +export function noteClassTransitions( + graphId: string, + noteTransition: IdentityTransitionNoteFunction, + transitions: readonly ClosureTransitionRecord[], + common: ClassTransitionNoteContext, +): void { + const assertionIds = transitionAssertionIds(transitions, common.assertions); + for (const [index, transition] of transitions.entries()) { + noteTransition(graphId, { + cause: common.cause, + classRef: transition.classRef, + priorClassRef: transition.priorClassRef, + assertionIds: requireDefined(assertionIds[index]), + validAt: common.validAt, + }); + } +} + export async function validateCurrentRelation( ctx: Pick< IdentityServiceContext, @@ -675,6 +722,37 @@ function validateRelationAgainstLedger( } } +type IdentityWindowLedgerContext = Pick< + IdentityServiceContext, + "graphId" | "sameIdAcrossKinds" | "schema" +>; + +/** + * THE window ledger a temporal identity check of `requests` judges against: + * every assertion — ended ones included — and every node the requests' + * classes reach over their windows. Both the commit's per-assertion validator + * ({@link createIdentityWindowValidator}) and a graph merge's plan-time + * contradiction simulation (`loadIdentityWindowHistory`) read it here, + * so a plan can never pass a claim the commit then refuses against history + * the plan did not see. + */ +export async function readIdentityWindowLedger( + ctx: IdentityWindowLedgerContext, + target: Backend, + requests: readonly IdentityWindowValidationRequest[], + operationInstant: string, +): ReturnType { + return loadIdentityWindowLedger({ + target, + schema: ctx.schema, + graphId: ctx.graphId, + requests, + operationInstant, + sameIdAcrossKinds: ctx.sameIdAcrossKinds, + recursiveTraversal: resolveRecursiveTraversal(target.capabilities), + }); +} + export async function createIdentityWindowValidator( ctx: Pick< IdentityServiceContext, @@ -685,15 +763,12 @@ export async function createIdentityWindowValidator( operationInstant: string, ignoredAssertionIds: ReadonlySet = new Set(), ): Promise { - const ledger = await loadIdentityWindowLedger({ + const ledger = await readIdentityWindowLedger( + ctx, target, - schema: ctx.schema, - graphId: ctx.graphId, requests, operationInstant, - sameIdAcrossKinds: ctx.sameIdAcrossKinds, - recursiveTraversal: resolveRecursiveTraversal(target.capabilities), - }); + ); if (ignoredAssertionIds.size > 0) { const retained = ledger.assertions.filter( (assertion) => !ignoredAssertionIds.has(assertion.id), @@ -1043,15 +1118,27 @@ async function insertClosureComponents( } } +/** + * Recomputes the current structural closure for every class touched by + * `references`, returning the resulting closure transitions — the diff + * between each affected member's OLD class (captured here, before the + * DELETE) and its NEW class (`components`, computed below). Shared by every + * caller that can split, shrink, or grow a class through this recompute: + * retraction, detach, fold, and the retraction half of a reconciliation + * apply. The caller — never this function — decides the `cause` a returned + * record notes through, because the same recompute serves causes with + * opposite membership effects. + */ export async function replaceAffectedClosure( target: Backend, schema: SqlSchema, graphId: string, references: readonly PlainNodeRef[], sameIdAcrossKinds: "fold" | "ignore" = "fold", -): Promise { - if (references.length === 0) return; +): Promise { + if (references.length === 0) return []; const affectedByKey = new Map(); + const oldClassOf = new Map(); const classes = await loadCurrentStructuralClassComponents( target, schema, @@ -1059,7 +1146,12 @@ export async function replaceAffectedClosure( references, ); for (const component of classes.values()) { - for (const member of component) affectedByKey.set(refKey(member), member); + for (const member of component) { + affectedByKey.set(refKey(member), member); + if (!oldClassOf.has(refKey(member))) { + oldClassOf.set(refKey(member), component); + } + } } const affected = [...affectedByKey.values()]; const structuralNodes = await loadLiveReferences( @@ -1107,22 +1199,39 @@ export async function replaceAffectedClosure( // singleton class is gone, so its rows must be rewritten too. const separationMembers = [...affected, ...[...components.values()].flat()]; await replaceSeparationForMembers(target, schema, graphId, separationMembers); + return diffClosureTransitions( + affected, + oldClassOf, + indexComponentsByMember(components), + ); } -export async function mergeCurrentClasses( +/** + * Fuses `a`'s and `b`'s current structural classes into one, returning the + * resulting closure transition — the "assert" cause's whole `class`/`priorClass` + * derivation, so `assertPair` and every other caller note through this return + * value rather than re-deriving which side was absorbed. + * + * Exactly one record, or none when the pair was already fused: the fused + * canonical is always one of `aClass[0]` / `bClass[0]` (the minimum of the + * two, since each is already sorted), so the OTHER side is the one absorbed — + * `priorClassRef` names it (`undefined` when that side was a singleton with + * no real closure row to absorb). + */ +async function mergeCurrentClasses( target: Backend, schema: SqlSchema, graphId: string, a: PlainNodeRef, b: PlainNodeRef, -): Promise { +): Promise { const classes = await loadCurrentStructuralClasses(target, schema, graphId, [ a, b, ]); const aClass = requireDefined(classes.get(refKey(a))); const bClass = requireDefined(classes.get(refKey(b))); - if (containsRef(aClass, b)) return; + if (containsRef(aClass, b)) return []; const fusedMembers = [...aClass, ...bClass]; const [smaller, larger] = @@ -1174,6 +1283,51 @@ export async function mergeCurrentClasses( // classes were separated, both sides of their row become `canonical` and the // relation's CHECK aborts the transaction. await replaceSeparationForMembers(target, schema, graphId, fusedMembers); + const survivorIsA = refKey(canonical) === refKey(requireDefined(aClass[0])); + const priorAClass = + aClass.length >= 2 ? requireDefined(aClass[0]) : undefined; + const priorBClass = + bClass.length >= 2 ? requireDefined(bClass[0]) : undefined; + return [ + { + classRef: canonical, + priorClassRef: survivorIsA ? priorBClass : priorAClass, + members: fusedMembers, + }, + ]; +} + +/** + * The derived-state effect of a CURRENT pair assertion: a `same` FUSES the two + * classes and notes the resulting class transitions, a `different` re-projects + * the separation relation for the pair. One owner of the relation → effect + * mapping, and of what a fusion notes, for every path that writes a current + * assertion row. + */ +export async function applyPairRelationEffect( + ctx: Pick, "graphId" | "schema">, + target: Backend, + relation: IdentityRelation, + a: PlainNodeRef, + b: PlainNodeRef, + noteTransition: IdentityTransitionNoteFunction, + common: ClassTransitionNoteContext, +): Promise { + if (relation !== "same") { + await replaceSeparationForReferences(target, ctx.schema, ctx.graphId, [ + a, + b, + ]); + return; + } + const transitions = await mergeCurrentClasses( + target, + ctx.schema, + ctx.graphId, + a, + b, + ); + noteClassTransitions(ctx.graphId, noteTransition, transitions, common); } export async function assertPair( @@ -1183,6 +1337,7 @@ export async function assertPair( firstInput: IdentityNodeRefInput, secondInput: IdentityNodeRefInput, touch: IdentityTouch, + noteTransition: IdentityTransitionNoteFunction, windowInput: IdentityValidityWindow | undefined, operationInstant: string, windowValidator?: IdentityWindowValidator, @@ -1226,14 +1381,11 @@ export async function assertPair( touch, ); windowValidator?.record(row); - if (relation === "same") { - await mergeCurrentClasses(target, ctx.schema, ctx.graphId, a, b); - } else { - await replaceSeparationForReferences(target, ctx.schema, ctx.graphId, [ - a, - b, - ]); - } + await applyPairRelationEffect(ctx, target, relation, a, b, noteTransition, { + cause: "assert", + assertions: [row], + validAt: operationInstant, + }); return assertionResult(publicAssertion(row), "created"); } const window = resolveIdentityValidityWindow(windowInput, operationInstant); @@ -1306,13 +1458,10 @@ export async function assertPair( if (window.effective !== "current") { return assertionResult(publicAssertion(row), "created"); } - if (relation === "same") { - await mergeCurrentClasses(target, ctx.schema, ctx.graphId, a, b); - } else { - await replaceSeparationForReferences(target, ctx.schema, ctx.graphId, [ - a, - b, - ]); - } + await applyPairRelationEffect(ctx, target, relation, a, b, noteTransition, { + cause: "assert", + assertions: [row], + validAt: operationInstant, + }); return assertionResult(publicAssertion(row), "created"); } diff --git a/packages/typegraph/src/identity/service-read.ts b/packages/typegraph/src/identity/service-read.ts index 1095c6117..c791aa3d4 100644 --- a/packages/typegraph/src/identity/service-read.ts +++ b/packages/typegraph/src/identity/service-read.ts @@ -250,7 +250,7 @@ const IDENTITY_ADVISORY_LOCK_NAMESPACE = "typegraph:identity"; * `executeIdentityStatement` (#447). * * The dialect check above is now the `resolveWriteFencePlan`/ - * `requireWriteFence` pair (§5.3): the `lock` arm takes the advisory lock, + * `requireWriteFence` pair: the `lock` arm takes the advisory lock, * and the `engine-serialized` arm is the SQLite writer-slot case this doc * already describes. */ diff --git a/packages/typegraph/src/identity/service-types.ts b/packages/typegraph/src/identity/service-types.ts index 2d8b598f0..b53d1c437 100644 --- a/packages/typegraph/src/identity/service-types.ts +++ b/packages/typegraph/src/identity/service-types.ts @@ -1,3 +1,4 @@ +import { type RecordedTimeOwnership } from "../backend/capabilities/recorded-time-ownership"; import { type GraphBackend, type TransactionBackend } from "../backend/types"; import { type GraphDef } from "../core/define-graph"; import { type ReadCoordinate } from "../core/temporal"; @@ -21,7 +22,10 @@ export type IdentityServiceContext = Readonly<{ */ backend: GraphBackend | TransactionBackend; schema: SqlSchema; + /** Whether TypeGraph itself captures recorded history — and so keeps the identity transition log. */ historyEnabled: boolean; + /** Who owns recorded time; names the reason a replay-family refusal gives when {@link historyEnabled} is false. */ + recordedTimeOwnership: RecordedTimeOwnership; revisionTrackingEnabled: boolean; sameIdAcrossKinds: "fold" | "ignore"; coordinate?: ReadCoordinate; diff --git a/packages/typegraph/src/identity/service.ts b/packages/typegraph/src/identity/service.ts index f511b78ee..036003bbd 100644 --- a/packages/typegraph/src/identity/service.ts +++ b/packages/typegraph/src/identity/service.ts @@ -18,15 +18,25 @@ export { } from "./service-facade"; export { applyIdentityChangesForContext, + assertIdentityTransitionsMonotone, IDENTITY_IMPORT_FAILED_ASSERTION, IDENTITY_IMPORT_PROGRESS, importIdentityAssertionsIntoTarget, + importIdentityTransitionsIntoTarget, + isIdentityTransitionRestore, + readIdentityRestoreBaseline, } from "./service-interchange-write"; -export type { IdentityRebuildContext } from "./service-maintenance"; +export type { + IdentityFoldPlan, + IdentityRebuildContext, +} from "./service-maintenance"; export { + applyIdentityFold, assertAffectedIdentityClassesConsistent, + assertIdentityFoldable, deleteAssertionsTouchingKinds, detachIdentityForNode, + diffFullRebuildTransitions, foldIdentityForCreatedNodes, hasAssertionsTouchingKinds, liveNodeKindsSharingIds, @@ -34,6 +44,7 @@ export { rebuildIdentityClosureForContext, removeIdentityKindsForContext, requireNodeValidityEndCompatible, + snapshotIdentityClosureClasses, validateIdentityForContext, } from "./service-maintenance"; export { loadAssertionsByIds } from "./service-mutation"; @@ -49,3 +60,12 @@ export type { IdentityServiceContext, IdentityTransferAssertion, } from "./service-types"; +export type { + IdentityTransitionCursor, + IdentityTransitionTransfer, +} from "./transition-log"; +export { + readIdentityTransitionPageForInterchange, + readTransitionRetentionDetails, + toTransitionTransfer, +} from "./transition-log"; diff --git a/packages/typegraph/src/identity/transition-log.ts b/packages/typegraph/src/identity/transition-log.ts new file mode 100644 index 000000000..a0fc9c3a1 --- /dev/null +++ b/packages/typegraph/src/identity/transition-log.ts @@ -0,0 +1,1090 @@ +/** + * The identity transition log: an append-only annotation on the recorded + * axis naming every event that changed an identity class's membership. + * + * This module owns the shape, the encoder, the readers, and the diffing + * predicate that turns "before" and "after" structural-class maps into the + * minimal exhaustive set of transition rows — the single owner every note + * site (assert, retract, fold, detach, window-end, kind-drop, + * schema-transition, reconcile) calls into rather than re-deriving. + * + * It does NOT own membership. A transition row carries no members before, no + * members after, and no delta — see `historicalIdentityReconstructionCtes` + * (historical-sql.ts) for the one and only owner of "who is in this class at + * coordinate c". This module is an explanation layer over that truth, never + * a second copy of it. + */ +import { + type RecordedTimeOwnership, + requireTypeGraphRecordedRevision, +} from "../backend/capabilities/recorded-time-ownership"; +import { type GraphDef } from "../core/define-graph"; +import { ConfigurationError, IdentityReplayError } from "../errors"; +import { type SqlSchema } from "../query/compiler/schema"; +import { getDialect } from "../query/dialect"; +import { sql, type SqlFragment } from "../query/sql-fragment"; +import { asCompiledRowsSql } from "../query/sql-intent"; +import { readNextRecordedRevision } from "../store/recorded-capture"; +import { storeRuntime } from "../store/runtime-port"; +import { type Store } from "../store/store"; +import { chunk } from "../utils/array"; +import { compareCodePoints } from "../utils/compare"; +import { nowIso } from "../utils/date"; +import { requireDefined } from "../utils/presence"; +import { decodeRevisionColumn } from "../utils/revision-column"; +import { + optionalIdentityTimestamp, + toCanonicalIdentityTimestamp, +} from "./row-codec"; +import { runIdentityMutation } from "./service-facade"; +import { refKey } from "./service-read"; +import { type IdentityServiceContext } from "./service-types"; +import { + executeIdentityStatement, + identityChunkSize, + type IdentityTarget, + MAX_REFERENCE_CHUNK_SIZE, + type PlainNodeRef, +} from "./sql-target"; +import { type IdentityAssertionStorageRow } from "./storage-types"; + +/** + * The nine exhaustive causes a materialized identity class can change under. + * Every writer that can move a member in or out of a class, or in or + * out of coordinate visibility, notes through exactly one of these. + */ +export type IdentityTransitionCause = + | "assert" + | "retract" + | "fold" + | "detach" + | "restore" + | "window-end" + | "kind-drop" + | "schema-transition" + | "reconcile"; + +/** + * Decision provenance for a transition written under a governed merge apply. + * Every field is optional and is evidence already in hand at the apply site; + * nothing here is invented or recomputed at read time. `undefined` (the + * default) means an ordinary API write with no governing decision. + */ +export type IdentityDecisionProvenance = Readonly<{ + branchId?: string | undefined; + branchAncestry?: readonly string[] | undefined; + mergePlanDigest?: string | undefined; + reviewDigest?: string | undefined; + sourceId?: string | undefined; +}>; + +/** One buffered note, before it is assigned a transition id and flushed. */ +export type IdentityTransitionNote = Readonly<{ + graphId: string; + cause: IdentityTransitionCause; + classRef: PlainNodeRef; + priorClassRef?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; + validAt: string; +}>; + +/** A note as a call site hands it to `noteTransition`: the session fills `graphId` and the ambient `decision`. */ +export type IdentityTransitionDraft = Omit< + IdentityTransitionNote, + "graphId" | "decision" +>; + +/** + * What an archival transition restore needs to know about its destination, + * as the destination stood before the import wrote anything. Read once per + * import by `readIdentityRestoreBaseline` (`service-interchange-write.ts`). + */ +export type IdentityRestoreBaseline = Readonly<{ + /** Whether the graph had recorded identity transitions of its own. */ + hasOwnHistory: boolean; + /** The first recorded revision the import itself could write at. */ + floorRevision: number; +}>; + +/** + * One archived transition row as the interchange boundary hands it to + * `importIdentityTransitionsIntoTarget` (`service-interchange-write.ts`) — + * structurally identical to `InterchangeIdentityTransition` + * (`interchange/types.ts`), the wire schema, exactly as `IdentityTransferAssertion` + * mirrors `InterchangeIdentityAssertion`: two modules, two owners, one shape, + * so a caller can pass a parsed wire row straight through with no adapter. + */ +export type IdentityTransitionTransfer = Readonly<{ + transitionId: string; + cause: IdentityTransitionCause; + recordedRevision: number; + recordedAt: string; + validAt: string; + class: PlainNodeRef; + priorClass?: PlainNodeRef | undefined; + assertionIds: readonly string[]; + decision?: IdentityDecisionProvenance | undefined; +}>; + +/** A persisted transition row, as read back from storage. */ +export type IdentityTransitionRow = Readonly<{ + graph_id: string; + transition_id: string; + recorded_revision: number; + recorded_at: string; + valid_at: string; + cause: IdentityTransitionCause; + class_kind: string; + class_id: string; + prior_class_kind: string | undefined; + prior_class_id: string | undefined; + assertion_ids: readonly string[]; + decision: IdentityDecisionProvenance | undefined; + tx_id: string | undefined; + /** Set when an archival restore inserted this row; `undefined` for a row this graph's own live capture flush recorded. See {@link isRestoredTransitionRow}. */ + restored_at: string | undefined; +}>; + +/** The class-change record `replaceAffectedClosure` / `mergeCurrentClasses` return to their caller. */ +export type ClosureTransitionRecord = Readonly<{ + classRef: PlainNodeRef; + priorClassRef?: PlainNodeRef | undefined; + /** + * Every member the record's class holds AFTER the change — which is every + * member of a class it was fused from, and each piece of one it was split + * into. Not persisted: it is what decides which assertions a note of this + * record names ({@link transitionAssertionIds}). + */ + members: readonly PlainNodeRef[]; +}>; + +/** What a transition note needs of an assertion row a write created, ended or removed. */ +export type TransitionAssertionRow = Pick< + IdentityAssertionStorageRow, + "id" | "rel" | "a_kind" | "a_id" | "b_kind" | "b_id" +>; + +/** + * THE attribution of assertions to class changes: for each record, the ids + * of the `same` assertions with an endpoint among that record's members, in + * the order the assertions were given. + * + * A write that changes many classes at once (a bulk assert, a bulk + * retraction, a kind drop) hands every assertion it touched; a record names + * only those that touched ITS class. Naming the whole batch on every record + * would make each one claim assertions about unrelated nodes, and would grow + * the log with the square of the batch. A `different` assertion shapes the + * separation relation, never a class, so no record names one. + * + * Indexed by endpoint once, so the cost is linear in members plus ids. + */ +export function transitionAssertionIds( + records: readonly ClosureTransitionRecord[], + assertions: readonly TransitionAssertionRow[], +): readonly (readonly string[])[] { + const positionsByEndpoint = new Map(); + for (const [position, assertion] of assertions.entries()) { + if (assertion.rel !== "same") continue; + for (const endpoint of [ + { kind: assertion.a_kind, id: assertion.a_id }, + { kind: assertion.b_kind, id: assertion.b_id }, + ]) { + const key = refKey(endpoint); + const positions = positionsByEndpoint.get(key) ?? []; + positions.push(position); + positionsByEndpoint.set(key, positions); + } + } + return records.map((record) => { + const positions = new Set(); + for (const member of record.members) { + for (const position of positionsByEndpoint.get(refKey(member)) ?? []) { + positions.add(position); + } + } + return [...positions] + .toSorted((left, right) => left - right) + .map((position) => requireDefined(assertions[position]).id); + }); +} + +function sameMemberSet( + left: readonly PlainNodeRef[], + right: readonly PlainNodeRef[], +): boolean { + if (left.length !== right.length) return false; + const rightKeys = new Set(right.map((member) => refKey(member))); + return left.every((member) => rightKeys.has(refKey(member))); +} + +/** + * The exhaustive-diff predicate: given a member's OLD class (before a + * structural mutation) and its NEW class (after), the one owner of "does this + * count as a class-identity transition, and if so what does it name". retract, + * fold, detach, kind-drop, and schema-transition all call this directly — + * never a second inline spelling of the comparison. + * + * `assert`'s own site, `mergeCurrentClasses`, is the one sanctioned second + * owner of this decision, not an oversight: fusing two classes has both sides in hand + * up front (never a snapshot-then-diff), so it derives its ONE resulting + * record directly from `aClass[0]` / `bClass[0]` rather than calling this + * predicate. It is a NARROWER decision than this function's general + * before/after diff — "which one canonical survives a two-class fuse" — and + * the two must not drift on that narrower question: this predicate's OWN + * merge branch (`sameMemberSet` false, one canonical replacing another) + * agrees today. A future change to either must keep them agreeing, since the + * replay walk's before/after equivalence depends on the record + * `mergeCurrentClasses` emits carrying the same meaning this predicate's + * would for the same fuse. + * + * A member is skipped ONLY when nothing about its class changed at all — same + * canonical AND same member set. A canonical that survives unchanged while + * the member SET shrank (e.g. the only other member of a two-member class was + * hard-deleted, so nothing "became" a new label to carry the reverse hop) is + * NOT redundant: proven by the exhaustiveness property test, which found a + * live counterexample where skipping a same-canonical-but-shrunk member + * dropped the only witness of a real membership change. The resulting record + * is self-referential (`classRef === priorClassRef`) in that case — this + * still lets a seed tracking that label discover the boundary via the + * forward match, even with no reverse hop to lean on. A member whose old + * class was never a real (>=2 member) closure row reports `priorClassRef: + * undefined` ("the class did not exist"). + */ +export function diffClosureTransitions( + affected: readonly PlainNodeRef[], + oldClassOf: ReadonlyMap, + newClassOf: ReadonlyMap, +): readonly ClosureTransitionRecord[] { + const recordsByKey = new Map(); + for (const member of affected) { + const key = refKey(member); + const oldClass = oldClassOf.get(key); + if (oldClass === undefined) continue; + const newClass = newClassOf.get(key); + // Absent from the new state: the member no longer exists (e.g. a hard + // delete removed it from the live population). Its own departure has no + // "became" to name; a SURVIVING class-mate's own record (self-referential + // when its canonical is unchanged, per the rule above) is what carries + // this event, not a record keyed on the departed member itself. + if (newClass === undefined) continue; + const priorCanonical = requireDefined(oldClass[0]); + const canonical = requireDefined(newClass[0]); + if ( + refKey(priorCanonical) === refKey(canonical) && + sameMemberSet(oldClass, newClass) + ) { + continue; + } + const emittedPriorClassRef = + oldClass.length >= 2 ? priorCanonical : undefined; + // The dedupe key must be built from what is actually EMITTED (the + // collapsed `priorClassRef`), not from `priorCanonical` before that + // collapse: two members whose pre-collapse labels differ can both + // collapse to the same `undefined` (both were singletons), and keying on + // the raw label gave them distinct keys, emitting the same record twice. + const dedupeKey = `${refKey(canonical)} ${ + emittedPriorClassRef === undefined ? "" : refKey(emittedPriorClassRef) + }`; + // Several members can report one record, and every member of a class + // reports the same class: the record keeps that class once, rather than + // folding it in per member, which would cost the square of its size. + if (recordsByKey.has(dedupeKey)) continue; + recordsByKey.set(dedupeKey, { + classRef: canonical, + priorClassRef: emittedPriorClassRef, + members: newClass, + }); + } + return [...recordsByKey.values()]; +} + +/** Column names, in storage/INSERT/projection order — the single source both the column-list `SqlFragment` and the flush chunk-size math derive from. */ +export const IDENTITY_TRANSITION_COLUMN_NAMES = [ + "graph_id", + "transition_id", + "recorded_revision", + "recorded_at", + "valid_at", + "cause", + "class_kind", + "class_id", + "prior_class_kind", + "prior_class_id", + "assertion_ids", + "decision", + "tx_id", + "restored_at", +] as const; + +/** Column list shared by the INSERT and every read projection. */ +export const IDENTITY_TRANSITION_COLUMNS: SqlFragment = sql.raw( + IDENTITY_TRANSITION_COLUMN_NAMES.join(", "), +); + +function encodeJsonColumn(value: unknown): SqlFragment { + return sql`${JSON.stringify(value)}`; +} + +/** Parses a JSON column back into a value: PostgreSQL's jsonb driver hands back an already-parsed value; SQLite hands back the stored text. */ +function decodeJsonColumn(value: unknown): unknown { + return typeof value === "string" ? JSON.parse(value) : value; +} + +function decodeOptionalRef( + kind: string | undefined, + id: string | undefined, +): PlainNodeRef | undefined { + if (kind === undefined || id === undefined) return undefined; + return { kind, id }; +} + +/** + * Builds one transition row's INSERT value tuple, in + * {@link IDENTITY_TRANSITION_COLUMNS} order. + * + * `restoredAt` is `undefined` for every row the live capture flush writes + * (`flush.ts`'s ONE call site) — a note this graph is recording about + * itself. Archival restore (`importIdentityTransitionsIntoTarget`, + * `service-interchange-write.ts`) is the only caller that ever passes a + * value: every row it inserts is, by construction, foreign to this graph's + * own timeline, regardless of what the wire payload carried. + */ +export function encodeIdentityTransitionRow( + note: IdentityTransitionNote, + revision: number, + recordedAt: string, + transitionId: string, + restoredAt?: string, +): SqlFragment { + return sql` + ( + ${note.graphId}, + ${transitionId}, + ${revision}, + ${recordedAt}, + ${note.validAt}, + ${note.cause}, + ${note.classRef.kind}, + ${note.classRef.id}, + ${ + note.priorClassRef === undefined ? + sql.raw("NULL") + : sql`${note.priorClassRef.kind}` + }, + ${ + note.priorClassRef === undefined ? + sql.raw("NULL") + : sql`${note.priorClassRef.id}` + }, + ${encodeJsonColumn(note.assertionIds)}, + ${note.decision === undefined ? sql.raw("NULL") : encodeJsonColumn(note.decision)}, + ${sql.raw("NULL")}, + ${restoredAt === undefined ? sql.raw("NULL") : sql`${restoredAt}`} + ) + `; +} + +/** + * Inserts pre-encoded transition-row value tuples (see + * {@link encodeIdentityTransitionRow}) in bind-budget-sized batches through + * the identity mutation write path. + * + * Archival restore's ONE writer (`importIdentityTransitionsIntoTarget`, + * `service-interchange-write.ts`): unlike the recorded-capture flush path + * (`flushIdentityTransitions`, `store/recorded-capture/flush.ts`, which + * encodes fresh notes against a NEWLY allocated revision inside a + * `TransactionBackend`), a restore carries rows whose revision, timestamp and + * id are the SOURCE graph's own — already fully encoded — and runs through + * `IdentityTarget`, the identity module's own write facet. + * + * `ON CONFLICT (graph_id, transition_id) DO NOTHING`: a restore is verbatim + * (this function never renumbers), so a `transition_id` collision on the + * same graph can only mean the archival document is being imported again — + * `importGraph(..., { onConflict: "skip" })` re-run over the same archive, + * or two archives sharing history. The colliding row is by construction the + * same row, so silently keeping the one already there is correct; without + * this clause the second import throws a raw driver UNIQUE-constraint error + * that never reaches `ImportResult.errors`. + */ +export async function insertIdentityTransitionValues( + target: IdentityTarget, + schema: SqlSchema, + values: readonly SqlFragment[], +): Promise { + if (values.length === 0) return; + const chunkSize = identityChunkSize(target, { + fixedParameters: 0, + maxItems: Number.MAX_SAFE_INTEGER, + parametersPerItem: IDENTITY_TRANSITION_COLUMN_NAMES.length, + }); + for (const valueChunk of chunk(values, chunkSize)) { + await executeIdentityStatement( + target, + sql` + INSERT INTO ${schema.identityTransitionsTable} (${IDENTITY_TRANSITION_COLUMNS}) + VALUES ${sql.join(valueChunk, sql`, `)} + ON CONFLICT (graph_id, transition_id) DO NOTHING + `, + ); + } +} + +type RawIdentityTransitionRow = Readonly<{ + graph_id: unknown; + transition_id: unknown; + recorded_revision: unknown; + recorded_at: unknown; + valid_at: unknown; + cause: unknown; + class_kind: unknown; + class_id: unknown; + prior_class_kind: unknown; + prior_class_id: unknown; + assertion_ids: unknown; + decision: unknown; + tx_id: unknown; + restored_at: unknown; +}>; + +function toRevisionNumber(value: unknown): number { + const revision = decodeRevisionColumn(value); + if (typeof revision === "number") return revision; + throw new ConfigurationError( + "Identity transition row carried a non-numeric recorded_revision.", + { value }, + ); +} + +function asRowString(value: unknown, column: string): string { + if (typeof value === "string") return value; + throw new ConfigurationError( + `Identity transition row column "${column}" was not a string.`, + { column, typeofValue: typeof value }, + ); +} + +function asOptionalRowString( + value: unknown, + column: string, +): string | undefined { + if (value === null || value === undefined) return undefined; + return asRowString(value, column); +} + +function normalizeIdentityTransitionRow( + row: RawIdentityTransitionRow, +): IdentityTransitionRow { + return { + graph_id: asRowString(row.graph_id, "graph_id"), + transition_id: asRowString(row.transition_id, "transition_id"), + recorded_revision: toRevisionNumber(row.recorded_revision), + // Both columns are `timestamp(..., { withTimezone: true }).notNull()` on + // PostgreSQL (schema/postgres.ts), which node-postgres decodes to a JS + // `Date` — exactly the case `toCanonicalIdentityTimestamp` exists to + // handle (row-codec.ts), and the one every other identity relation + // already decodes through. `asRowString` would throw on that Date and, + // on a text-returning driver, would pass a non-ISO string through + // uncanonicalized. + recorded_at: toCanonicalIdentityTimestamp(row.recorded_at), + valid_at: toCanonicalIdentityTimestamp(row.valid_at), + cause: asRowString(row.cause, "cause") as IdentityTransitionCause, + class_kind: asRowString(row.class_kind, "class_kind"), + class_id: asRowString(row.class_id, "class_id"), + prior_class_kind: asOptionalRowString( + row.prior_class_kind, + "prior_class_kind", + ), + prior_class_id: asOptionalRowString(row.prior_class_id, "prior_class_id"), + assertion_ids: decodeJsonColumn(row.assertion_ids) as readonly string[], + decision: + row.decision === null || row.decision === undefined ? + undefined + : (decodeJsonColumn(row.decision) as IdentityDecisionProvenance), + tx_id: asOptionalRowString(row.tx_id, "tx_id"), + // `timestamp(..., { withTimezone: true })` on PostgreSQL, same as + // `recorded_at`/`valid_at` above — decoded through the identity module's + // one optional-timestamp owner rather than `asOptionalRowString`, which + // would throw on the `Date` node-postgres hands back. + restored_at: optionalIdentityTimestamp(row.restored_at), + }; +} + +/** + * Whether `row` was written by an archival restore rather than this graph's + * own live capture flush. `identityReplay` uses this — never a comparison + * against `recorded_revision` — to decide whether a row may be paired with a + * membership snapshot reconstructed on THIS graph's historical reader: a + * restored row's revision is minted by the SOURCE graph's own clock, which + * interleaves arbitrarily with this graph's, so no numeric floor can + * separate "restored" from "native" the way this per-row marker does. + */ +export function isRestoredTransitionRow( + row: IdentityTransitionRow, +): row is IdentityTransitionRow & Readonly<{ restored_at: string }> { + return row.restored_at !== undefined; +} + +/** `IdentityTransitionRow` decoded to its `classRef`, for the replay seed-lineage walk. */ +export function transitionClassRef(row: IdentityTransitionRow): PlainNodeRef { + return { kind: row.class_kind, id: row.class_id }; +} + +/** `IdentityTransitionRow` decoded to its `priorClassRef`, or `undefined` when the row names none. */ +export function transitionPriorClassRef( + row: IdentityTransitionRow, +): PlainNodeRef | undefined { + return decodeOptionalRef(row.prior_class_kind, row.prior_class_id); +} + +/** + * Converts a stored transition row to the camelCase, plain-ref shape + * archival interchange transfers — {@link IdentityTransitionTransfer} — built + * from the same `transitionClassRef` / `transitionPriorClassRef` decoders + * `replay.ts`'s `publicTransition` uses, so the two conversions can never + * disagree about which columns name the class and prior class. + */ +export function toTransitionTransfer( + row: IdentityTransitionRow, +): IdentityTransitionTransfer { + const priorClass = transitionPriorClassRef(row); + return { + transitionId: row.transition_id, + cause: row.cause, + recordedRevision: row.recorded_revision, + recordedAt: row.recorded_at, + validAt: row.valid_at, + class: transitionClassRef(row), + ...(priorClass === undefined ? {} : { priorClass }), + assertionIds: row.assertion_ids, + ...(row.decision === undefined ? {} : { decision: row.decision }), + }; +} + +/** A (recorded revision, transition id) keyset cursor, ordering ties by the transition id. */ +export type IdentityTransitionCursor = Readonly<{ + recordedRevision: number; + transitionId: string; +}>; + +/** + * One owner for the `(recorded_revision, transition_id)` keyset predicate + * both transition-log readers page by: `readIdentityTransitions` (replay's + * fixed-point walk) and `readIdentityTransitionPageForInterchange` (archival + * export). `transitionIdKey` is the tie-break column wrapped in the same + * `binaryText` collation-safety seam both callers' `ORDER BY` uses — left + * bare, `>`/`ORDER BY` on that column would compare under the column's + * collation, which is locale-dependent on PostgreSQL and could disagree with + * each other on some inputs. `cursorFilter` is the empty fragment when + * `after` is `undefined`. + */ +function identityTransitionCursorSeam( + target: IdentityTarget, + after: IdentityTransitionCursor | undefined, +): Readonly<{ transitionIdKey: SqlFragment; cursorFilter: SqlFragment }> { + const transitionIdKey = getDialect(target.dialect).binaryText( + sql`transition_id`, + ); + const cursorFilter = + after === undefined ? + sql`` + : sql` + AND ( + recorded_revision > ${after.recordedRevision} + OR ( + recorded_revision = ${after.recordedRevision} + AND ${transitionIdKey} > ${after.transitionId} + ) + ) + `; + return { transitionIdKey, cursorFilter }; +} + +export type IdentityTransitionReadScope = Readonly<{ + classRefs: readonly PlainNodeRef[]; + limit: number; + /** + * Keyset-pages the read strictly past this cursor, ordered the same way + * the result is (`recorded_revision` then `transition_id`) — pass the + * cursor built from the previous page's LAST row to read the next `limit` + * rows. `undefined` (the default) reads from the start. This is how + * `walkClassLineage`'s fixed-point walk (replay.ts) reads one round to + * exhaustion without a fixed per-round ceiling: it keeps requesting pages + * until a page comes back shorter than `limit`. + */ + after?: IdentityTransitionCursor | undefined; +}>; + +/** + * Reads every transition row whose `class` OR `prior class` names one of + * `scope.classRefs` — the forward AND reverse lineage hop the replay + * fixed-point walk needs in one query — ordered by recorded revision then by + * transition id, so the result is DETERMINISTIC and stable across repeated + * reads of the same committed rows. + * + * NOT insertion order: `transition_id` is a random `nanoid` (`generateId()`, + * `flush.ts`), so two notes buffered at the SAME recorded revision can sort + * either way relative to each other here, regardless of which was written + * first. Nothing depends on their relative order today — every note sharing + * one boundary shares the same `before`/`after` step in replay — but a + * future reader that does must not assume this ordering reflects buffering + * order. + * + * TAKES NO RECORDED-REVISION BOUNDS, on purpose. Its one caller is replay's + * fixed-point lineage walk, whose seed set has to reach every class name the + * node ever carried — and the note that teaches the walk a name routinely + * sits ABOVE the window the caller asked about, because the walk starts at + * the node's CURRENT canonical and hops backwards through `priorClass`. A + * bounded read cut exactly those hops. The caller's `fromRecorded` / + * `toRecorded` are applied once, to the converged lineage, by `replay.ts`. + * + * `scope.classRefs` is chunked through the shared bind-budget helper (each + * reference costs four bind parameters: kind+id in the forward match, kind+id + * in the reverse match) exactly as every other identity OR-list is + * (`deleteAssertionsTouchingKinds`, `loadCurrentStructuralClasses`) — a wide + * lineage must hit a typed refusal, never the driver's own opaque + * bind-variable-limit error. Each chunk is read with the full `scope.limit` + * (past `scope.after`, when given) and the merged, deduplicated rows are + * re-sorted and re-truncated to that same limit, so chunking never changes + * the result a single unchunked query would have returned. + * + * `scope.after` keyset-pages the result past a prior page's last row: the + * caller (`walkClassLineage`, replay.ts) reads one fixed-point round to + * exhaustion by re-issuing this call with `after` set to the previous page's + * last row until a page comes back shorter than `scope.limit`, rather than + * capping the round at a fixed row ceiling. Keyset-pages and orders through + * {@link identityTransitionCursorSeam}. + */ +export async function readIdentityTransitions( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + scope: IdentityTransitionReadScope, +): Promise { + if (scope.classRefs.length === 0) return []; + const chunkSize = identityChunkSize(target, { + fixedParameters: scope.after === undefined ? 4 : 6, + maxItems: MAX_REFERENCE_CHUNK_SIZE, + parametersPerItem: 4, + }); + if (scope.classRefs.length > chunkSize) { + const matched = new Map(); + for (const refChunk of chunk(scope.classRefs, chunkSize)) { + const rows = await readIdentityTransitions(target, schema, graphId, { + ...scope, + classRefs: refChunk, + }); + for (const row of rows) matched.set(row.transition_id, row); + } + return [...matched.values()] + .toSorted((left, right) => + left.recorded_revision === right.recorded_revision ? + compareCodePoints(left.transition_id, right.transition_id) + : left.recorded_revision - right.recorded_revision, + ) + .slice(0, scope.limit); + } + // Exact-pair OR lists rather than two independent IN-lists: an IN-list per + // column would match the cross product of unrelated kind/id pairs. + const classMatches = sql.join( + scope.classRefs.map( + (ref) => sql`(class_kind = ${ref.kind} AND class_id = ${ref.id})`, + ), + sql` OR `, + ); + const priorMatches = sql.join( + scope.classRefs.map( + (ref) => + sql`(prior_class_kind = ${ref.kind} AND prior_class_id = ${ref.id})`, + ), + sql` OR `, + ); + const { transitionIdKey, cursorFilter } = identityTransitionCursorSeam( + target, + scope.after, + ); + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT ${IDENTITY_TRANSITION_COLUMNS} + FROM ${schema.identityTransitionsTable} + WHERE graph_id = ${graphId} + AND (${classMatches} OR ${priorMatches}) + ${cursorFilter} + ORDER BY recorded_revision ASC, ${transitionIdKey} ASC + LIMIT ${scope.limit} + `), + ); + return rows.map((row) => normalizeIdentityTransitionRow(row)); +} + +export type IdentityTransitionPage = Readonly<{ + transitions: readonly IdentityTransitionRow[]; + nextAfter?: IdentityTransitionCursor | undefined; + done: boolean; +}>; + +/** + * Pages every RETAINED transition row for `graphId`, ordered by + * `(recorded_revision, transition_id)` ascending — the archival interchange + * export's sole reader. + * + * Unlike {@link readIdentityTransitions} (scoped by class-key lineage, for + * replay's fixed-point walk), this reader takes no `classRefs` scope AND no + * `nodeKinds` scope: it always walks the whole graph's log, in export order. + * `readIdentityAssertionPageAtTarget` (`interchange-read.ts`) is similarly + * unscoped by class-key lineage, but — unlike this reader — DOES honor a + * `nodeKinds`-filtered archival export; a `nodeKinds`-filtered archival + * export therefore still carries transitions naming excluded kinds (see + * `export.ts`'s call site and identity.md's "Archival transitions and the + * retention watermark"). Keyset-pages and orders through + * {@link identityTransitionCursorSeam}. + */ +export async function readIdentityTransitionPageForInterchange( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + options: Readonly<{ + after?: IdentityTransitionCursor | undefined; + limit: number; + }>, +): Promise { + const { transitionIdKey, cursorFilter } = identityTransitionCursorSeam( + target, + options.after, + ); + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT ${IDENTITY_TRANSITION_COLUMNS} + FROM ${schema.identityTransitionsTable} + WHERE graph_id = ${graphId} + ${cursorFilter} + ORDER BY recorded_revision ASC, ${transitionIdKey} ASC + LIMIT ${options.limit} + `), + ); + const transitions = rows.map((row) => normalizeIdentityTransitionRow(row)); + const last = transitions.at(-1); + return { + transitions, + ...(last === undefined ? + {} + : { + nextAfter: { + recordedRevision: last.recorded_revision, + transitionId: last.transition_id, + }, + }), + done: rows.length < options.limit, + }; +} + +type RawNativeTransitionExistsRow = Readonly<{ transition_id: unknown }>; + +/** + * Whether `graphId` holds a NATIVE (non-restored) identity transition row + * recorded below `revision` — history this graph recorded itself, through + * the live capture flush, before the restore that is asking began. + * + * Archival restore (`importIdentityTransitionsIntoTarget`, + * `service-interchange-write.ts`) consults this BEFORE deciding whether to + * set the retention watermark, passing the revision its own first commit + * takes: a graph with earlier history of its own has honest boundaries the + * restore never touched, and a restore-derived floor over them would + * misreport the `retention-truncated` completeness reason (or the + * `IDENTITY_REPLAY_HISTORY_TRUNCATED` refusal) for classes the restore had + * nothing to do with. + * + * The bound is what keeps the restore's OWN notes out of the answer. Importing + * an archive's nodes and assertions notes ordinary native transitions (a + * fold, the union an assertion makes); a streamed restore has committed them + * by the time its transitions arrive, and counting them as "history of its + * own" would leave every streamed restore without a watermark. + */ +export async function hasNativeIdentityTransitionsBefore( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + revision: number, +): Promise { + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT transition_id + FROM ${schema.identityTransitionsTable} + WHERE graph_id = ${graphId} + AND restored_at IS NULL + AND recorded_revision < ${revision} + LIMIT 1 + `), + ); + return rows.length > 0; +} + +type RawRestoredTransitionCountRow = Readonly<{ restored: unknown }>; + +/** How many of `graphId`'s retained transition rows an archival restore inserted. */ +export async function countRestoredIdentityTransitions( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, +): Promise { + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT COUNT(*) AS restored + FROM ${schema.identityTransitionsTable} + WHERE graph_id = ${graphId} AND restored_at IS NOT NULL + `), + ); + return Number(requireDefined(rows[0]).restored); +} + +/** Reads a graph's transition-retention watermark; `0` when nothing has been pruned. */ +async function readTransitionRetention( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, +): Promise { + const details = await readTransitionRetentionDetails(target, schema, graphId); + return details.prunedBeforeRevision; +} + +type RawRetentionDetailsRow = Readonly<{ + pruned_before_revision: unknown; + pruned_at: unknown; +}>; + +/** + * The retention watermark AND the wall time the prune that set it ran at — + * the latter is the prune operation's own instant, not the original commit + * time of the pruned revision (which pruning destroys), but is the only + * wall-time evidence retained once that history is gone. Used only to name a + * `retention-truncated` / `IDENTITY_REPLAY_HISTORY_TRUNCATED` boundary. + */ +export async function readTransitionRetentionDetails( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, +): Promise> { + const rows = await target.execute( + asCompiledRowsSql(sql` + SELECT pruned_before_revision, pruned_at + FROM ${schema.identityTransitionRetentionTable} + WHERE graph_id = ${graphId} + `), + ); + const row = rows[0]; + if (row === undefined) return { prunedBeforeRevision: 0, prunedAt: nowIso() }; + return { + prunedBeforeRevision: toRevisionNumber(row.pruned_before_revision), + // `pruned_at` is `timestamp(..., { withTimezone: true }).notNull()` on + // PostgreSQL (schema/postgres.ts), the exact column shape + // `normalizeIdentityTransitionRow` above decodes `recorded_at`/`valid_at` + // through `toCanonicalIdentityTimestamp` for — node-postgres returns a JS + // `Date`, and a text-returning driver returns a non-ISO string that must + // still be canonicalized. `asRowString` would throw on the former and + // pass the latter through uncanonicalized. + prunedAt: toCanonicalIdentityTimestamp(row.pruned_at), + }; +} + +/** + * The `IdentityReplayError` every replay-family operation raises when the + * store keeps no TypeGraph transition log. Two states reach it: a store opened + * without `history: true`, and a backend that owns recorded time itself + * (engine-native), where TypeGraph performs no recorded capture — and so + * writes no transitions — whatever `history` says. The latter names its own + * code, since `history: true` is not the remedy there. + */ +export function identityTransitionLogUnavailableError( + graphId: string, + recordedTimeOwnership: RecordedTimeOwnership, +): IdentityReplayError { + if (recordedTimeOwnership === "engine-native") { + return new IdentityReplayError( + "Identity replay is not supported under engine-native recorded time.", + { code: "IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED", graphId }, + { + suggestion: + "This backend declares recordedTime, so it owns recorded history itself and TypeGraph keeps no identity transition log for it, with or without history: true. Replay needs a backend whose recorded time TypeGraph captures in its own relations.", + }, + ); + } + return new IdentityReplayError( + "Identity replay requires the store to be opened with history: true.", + { code: "IDENTITY_REPLAY_REQUIRES_HISTORY", graphId }, + { + suggestion: + "Open the store with createStore(graph, backend, { history: true }); the transition log annotates the recorded axis and has nothing to annotate without it.", + }, + ); +} + +/** Refuses a replay-family operation on a context whose store keeps no TypeGraph transition log — see {@link identityTransitionLogUnavailableError}. */ +export function requireIdentityTransitionLog( + ctx: Pick< + IdentityServiceContext, + "graphId" | "historyEnabled" | "recordedTimeOwnership" + >, +): void { + if (!ctx.historyEnabled) { + throw identityTransitionLogUnavailableError( + ctx.graphId, + ctx.recordedTimeOwnership, + ); + } +} + +/** + * Writes a graph's transition-retention watermark — the ONE owner of this + * INSERT ... ON CONFLICT, shared by `pruneIdentityTransitionsForContext` + * (which pairs it with deleting the rows it now covers) and archival restore + * (`importIdentityTransitionsIntoTarget`, `service-interchange-write.ts`, + * which sets it to the revision the restore's first commit takes, without + * deleting anything — a restore into a fresh graph has nothing there to delete). The + * `WHERE` guard makes the write itself monotonic: a `resolvedWatermark` at or + * below what is already stored is a no-op, so neither caller needs its own + * read-compare-write race guard beyond the one each already has for its own + * return value (prune's `pruned` count, restore's `retention-truncated` honesty). + */ +export async function writeIdentityTransitionRetentionWatermark( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, + resolvedWatermark: number, + at: string, +): Promise { + await executeIdentityStatement( + target, + sql` + INSERT INTO ${schema.identityTransitionRetentionTable} ( + graph_id, pruned_before_revision, pruned_at + ) VALUES (${graphId}, ${resolvedWatermark}, ${at}) + ON CONFLICT (graph_id) DO UPDATE + SET pruned_before_revision = excluded.pruned_before_revision, + pruned_at = excluded.pruned_at + WHERE ${schema.identityTransitionRetentionTable}.pruned_before_revision < excluded.pruned_before_revision + `, + ); +} + +async function readHighestRetainedTransitionRevision( + target: IdentityTarget, + schema: SqlSchema, + graphId: string, +): Promise { + const rows = await target.execute>( + asCompiledRowsSql(sql` + SELECT MAX(recorded_revision) AS highest + FROM ${schema.identityTransitionsTable} + WHERE graph_id = ${graphId} + `), + ); + const highest = rows[0]?.highest; + return highest === null || highest === undefined ? + undefined + : toRevisionNumber(highest); +} + +/** + * Prunes retained explanation: deletes every transition row strictly below + * the requested revision and advances the retention watermark monotonically. + * A prune at an earlier revision than the current watermark never lowers it. + * A prune beyond every revision the graph holds — the next commit's revision, + * or one past the highest retained row when archival restore carried higher + * revisions in from another graph — is refused: it would claim history the + * graph has yet to record as already pruned. The watermark itself is clamped to + * the next commit's revision, because restored revisions are not on this + * graph's axis. + * + * Explicit operator action only — no automatic retention policy exists. + * Deliberately, a prune does NOT advance the content revision (the + * same non-advancing contract `rebuildIdentityClosure` follows): it destroys + * retained explanation, never truth, so branch staleness must track truth, + * not explanation. + */ +export async function pruneIdentityTransitionsForContext( + ctx: IdentityServiceContext, + options: Readonly<{ beforeRecorded: string }>, +): Promise> { + requireIdentityTransitionLog(ctx); + const targetRevision = requireTypeGraphRecordedRevision( + options.beforeRecorded, + "beforeRecorded", + ); + return runIdentityMutation(ctx, async (rawTarget) => { + const existingWatermark = await readTransitionRetention( + rawTarget, + ctx.schema, + ctx.graphId, + ); + const nextRevision = await readNextRecordedRevision( + rawTarget, + ctx.schema, + ctx.graphId, + ); + const highestRetained = await readHighestRetainedTransitionRevision( + rawTarget, + ctx.schema, + ctx.graphId, + ); + const highestPrunableRevision = Math.max( + nextRevision, + highestRetained === undefined ? nextRevision : highestRetained + 1, + ); + if (targetRevision > highestPrunableRevision) { + throw new IdentityReplayError( + `Cannot prune identity transitions before ${options.beforeRecorded}: the graph holds no transition at or above revision ${String(highestPrunableRevision)}, so a prune beyond it would claim history that does not exist yet as already pruned.`, + { + code: "IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK", + requestedBefore: options.beforeRecorded, + requestedRevision: targetRevision, + highestPrunableRevision, + }, + { + suggestion: + "Pass a recorded instant this graph has reached, such as store.recordedNow(), as beforeRecorded.", + }, + ); + } + // Restored rows keep the revision their source graph minted, so they can + // sit above this graph's clock: the rows are deleted by the requested + // revision, while the watermark — a claim about this graph's own axis — + // never rises above the revision its next commit takes. + const resolvedWatermark = Math.max( + existingWatermark, + Math.min(targetRevision, nextRevision), + ); + const deleted = await rawTarget.execute< + Readonly<{ transition_id: unknown }> + >( + asCompiledRowsSql(sql` + DELETE FROM ${ctx.schema.identityTransitionsTable} + WHERE graph_id = ${ctx.graphId} + AND recorded_revision < ${targetRevision} + RETURNING transition_id + `), + ); + await writeIdentityTransitionRetentionWatermark( + rawTarget, + ctx.schema, + ctx.graphId, + resolvedWatermark, + nowIso(), + ); + return { pruned: deleted.length, prunedBeforeRevision: resolvedWatermark }; + }); +} + +/** + * Prunes a graph's retained identity transitions. + * + * An explicit operator action with no automatic retention policy — see + * "Retention" in the identity documentation. Requires the store to be opened + * with `history: true`. + */ +export async function pruneIdentityTransitions( + store: Store, + options: Readonly<{ beforeRecorded: string }>, +): Promise> { + const ctx = storeRuntime(store).identityContext(); + return pruneIdentityTransitionsForContext(ctx, options); +} diff --git a/packages/typegraph/src/identity/types.ts b/packages/typegraph/src/identity/types.ts index f530cb83b..d6c548a65 100644 --- a/packages/typegraph/src/identity/types.ts +++ b/packages/typegraph/src/identity/types.ts @@ -11,6 +11,11 @@ import { type Node, type NodeRef, } from "../store/types"; +import { + type IdentityReplay, + type IdentityReplayOptions, + type IdentityTransitionHistory, +} from "./replay"; /** * The accepted *input* form for every identity facade method: a whole node or @@ -227,6 +232,63 @@ export type IdentityFacade = IdentityReadFacade & bulkRetractAssertions: ( ids: readonly IdentityAssertionId[], ) => Promise[]>; + /** + * Every transition (assertion, retraction, fold, deletion, restore, + * window end, kind drop, or reconciliation decision) that changed + * `ref`'s identity class — recorded while `ref` was a member of the + * class it names, never the later history of a class `ref` has left — + * ascending by recorded revision, in pages of at most `options.limit` + * boundaries (default 200, maximum 2000). A capped page carries + * `nextCursor`, naming the first boundary it stopped short of: pass it + * back as `options.cursor` for the next page of the same `ref`. Requires + * the store to be opened with `history: true`. + * + * On `tx.identity` specifically: reads the transition log itself, which + * — unlike every other read on this facade — is NOT read-your-writes + * inside an open transaction. A transition a write earlier in the SAME + * transaction notes is buffered in the capture session and only reaches + * this table when the transaction commits, so `tx.identity.transitionsOf` + * can undercount relative to `tx.identity.assertionsOf` on the identical + * pending write until the transaction flushes; call it again on + * `store.identity` after commit for the complete answer. Such a page's + * `completeness` carries the `pending-transaction-notes` reason. + * + * `completeness` is the one place to check whether a page is the whole + * answer the log can give: more pages, a retention cut, restored + * transitions discovery could not attribute, and current class members + * that arrived through writes history capture never saw (for example + * before `history: true` was enabled) are all reported there. + * + * Deliberately absent from {@link IdentityReadFacade}: it answers across + * every recorded coordinate, not the one a read-only lens is pinned to. + */ + transitionsOf: ( + ref: IdentityNodeRefInput, + options?: IdentityReplayOptions, + ) => Promise>; + /** + * Pairs every transition {@link IdentityFacade.transitionsOf} reports — + * those recorded while `ref` was a member of the class they name — with + * the class membership immediately before and after it, reconstructed through + * the same historical reader `asOf` / `asOfRecorded` reads use. Pages by + * boundary exactly as {@link IdentityFacade.transitionsOf} does, through + * the same `nextCursor` / `cursor` pair. Requires the store to be opened with + * `history: true`. + * + * On `tx.identity`: carries the same pending-notes caveat as + * {@link IdentityFacade.transitionsOf} — a transition noted earlier in + * the SAME open transaction is not yet in the log this reads, so it is + * absent from `steps` until the transaction commits, and `completeness` + * says so. `completeness` also counts the restored transitions of the + * page that are not `steps`. + * + * Deliberately absent from {@link IdentityReadFacade}: it answers across + * every recorded coordinate, not the one a read-only lens is pinned to. + */ + replay: ( + ref: IdentityNodeRefInput, + options?: IdentityReplayOptions, + ) => Promise>; }>; /** @@ -249,5 +311,20 @@ export type IdentityWriteSummary = Readonly<{ sameAssertions: number; differentAssertions: number; retractions: number; + /** + * Identity transition-log notes flushed for this graph during the + * transaction. An ANNOTATION of the writes above, not a fourth kind of + * write — deliberately excluded from `total`, which stays the count of + * ledger truth rows the transaction produced. Always `0` when identity is + * disabled or opened without `history: true`. + * + * Counted at commit-flush time, not at the collection surface `nodes`/ + * `edges`/the assertion counters above are: a `tx.measure(fn)` scoped + * receipt (see `TransactionReceipt`) therefore always reports `0` here even + * when `fn` made identity writes that go on to note real transitions — + * those notes are attributed to the transaction's own flush, which happens + * once, after every scope has already returned. + */ + transitions: number; total: number; }>; diff --git a/packages/typegraph/src/identity/validity-window.ts b/packages/typegraph/src/identity/validity-window.ts index d50baa9cb..74da95542 100644 --- a/packages/typegraph/src/identity/validity-window.ts +++ b/packages/typegraph/src/identity/validity-window.ts @@ -80,3 +80,14 @@ export function identityValidityWindowsOverlap( (left.validTo === undefined || right.validFrom < left.validTo) ); } + +/** + * Whether an identity assertion holds NOW. A window can never end in the + * future, so the ledger's current state is exactly its open rows: an assertion + * written bounded, retracted, or ended by deleting an endpoint is history. + */ +export function isCurrentIdentityAssertion( + assertion: Readonly<{ validTo?: string | undefined }>, +): boolean { + return assertion.validTo === undefined; +} diff --git a/packages/typegraph/src/index.ts b/packages/typegraph/src/index.ts index 50808199b..a34a1afe0 100644 --- a/packages/typegraph/src/index.ts +++ b/packages/typegraph/src/index.ts @@ -91,7 +91,6 @@ export { isGraphDef, isNodeType, isSearchableSchema, - metaEdge, type NodeKinds, type RecordedInstant, recordedInstantRevision, @@ -99,6 +98,7 @@ export { // Vector-slot enumeration for manual provisioning (the boot step // createStoreWithSchema performs via backend.ensureVectorSlotContribution) resolveGraphVectorSlots, + type RestoredRecordedInstant, // Searchable type for fulltext search searchable, type SearchableMetadata, @@ -112,24 +112,44 @@ export { export { asIdentityAssertionId, + IDENTITY_REPLAY_DEFAULT_LIMIT, + IDENTITY_REPLAY_MAX_LIMIT, type IdentityAssertion, type IdentityAssertionId, + // The governing decision a merge attaches to the identity transitions it + // causes — named on `StoreRuntime.applyIdentityMergeAtTarget`, so a backend + // or store author implementing the port needs it by name. type IdentityAssertionResult, type IdentityAssertionWriteFacade, type IdentityClass, type IdentityClassPage, type IdentityClassPageOptions, + type IdentityDecisionProvenance, type IdentityFacade, + type IdentityHistoryCompleteness, + type IdentityHistoryIncompleteReason, type IdentityNode, type IdentityNodeReference, type IdentityNodeRefInput, type IdentityPair, type IdentityReadFacade, type IdentityRelation, + type IdentityReplay, + type IdentityReplayOptions, + type IdentityReplayStep, type IdentitySamePathStep, + type IdentityTransition, + type IdentityTransitionCause, + type IdentityTransitionCursor, + type IdentityTransitionHistory, + type IdentityTransitionTransfer, type IdentityValidityWindow, type IdentityWriteSummary, + pruneIdentityTransitions, rebuildIdentityClosure, + type RecordedIdentityTransition, + type RestoredIdentityTransition, + type TransitionPageCursor, } from "./identity"; // ============================================================ @@ -177,6 +197,7 @@ export type { EdgeConvergenceMatch, EdgeCreateCommand, EdgeCreateCommandResult, + EdgeEndpointAllowance, EdgeEntityReadBackend, EdgeEntityWriteBackend, FilteredApproximateSearch, @@ -200,6 +221,7 @@ export type { GraphEntityReadBackend, GraphEntityWriteBackend, GraphLifecycleBackend, + GraphWriteFenceAcquisition, HardDeleteUniquesByConcreteKindParams, HardDeleteUniquesByNodeIdsParams, IndexMaterializationBackend, @@ -207,6 +229,7 @@ export type { ManagedEdgeCreatePlan, ManagedNodeCreateMode, ManagedNodeCreatePlan, + MisassignedEdgeEndpointRow, NodeCreateCommand, NodeCreateCommandResult, NodeEntityReadBackend, @@ -338,11 +361,11 @@ export type { JsonScalar, JsonValue, KindAnnotations, - MetaEdgeOptions, NodeId, NodeProps, NodeRegistration, NodeType, + TargetCardinality, TemporalMode, UniqueConstraint, UniquenessScope, @@ -353,20 +376,27 @@ export type { // ============================================================ export type { - InferenceType, + CompositionExistence, + CompositionOptions, + CompositionPartSide, + CompositionViaRef, + EquivalentToCheck, MetaEdge, MetaEdgeProperties, OntologyRelation, + PolymorphicNodeType, + StructuralSubtypeMismatch, + SubClassOfCheck, + TypedOntologyRelation, } from "./ontology"; export { // Individual relation factories (for convenience) broader, + compositionViaKind, // Transitive-closure utilities (reason over subClassOf/equivalentTo hierarchies) computeTransitiveClosure, // Core ontology module core, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility re-export until the next major - differentFrom, disjointWith, equivalentTo, hasPart, @@ -379,8 +409,6 @@ export { narrower, partOf, relatedTo, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility re-export until the next major - sameAs, subClassOf, } from "./ontology"; @@ -391,11 +419,16 @@ export { export type { BaseSchemaMigrationErrorDetails, CardinalityErrorDetails, + CompositionCycleErrorDetails, + CompositionErrorDetails, + CompositionExistenceErrorDetails, ContributionRebuildRefusal, ContributionUnavailableErrorDetails, DatabaseOperationErrorDetails, DisjointErrorDetails, EagerMaterializationErrorDetails, + EdgeAcyclicityErrorDetails, + EdgeAcyclicityIndeterminateErrorDetails, EdgeNotFoundErrorDetails, EmbeddingDimensionChangedErrorDetails, EndpointErrorDetails, @@ -404,6 +437,7 @@ export type { ErrorCategory, IdentityContradictionErrorDetails, IdentityEndpointValidityErrorDetails, + IdentityReplayErrorDetails, IdentitySeparationViolationErrorDetails, IdentityValidityWindowErrorDetails, InvalidEdgeWeightErrorDetails, @@ -412,6 +446,7 @@ export type { MigrationErrorDetails, MigrationFailureReason, NodeConstraintNotFoundErrorDetails, + NodeDeletePlanMismatchErrorDetails, NodeIndexNotFoundErrorDetails, NodeNotFoundErrorDetails, RecordedCaptureGuardCode, @@ -437,6 +472,9 @@ export { BaseSchemaMigrationError, CardinalityError, CompilerInvariantError, + CompositionCycleError, + CompositionError, + CompositionExistenceError, ConfigurationError, ContributionRebuildUnsupportedError, ContributionUnavailableError, @@ -444,6 +482,8 @@ export { DisjointError, EagerMaterializationError, EDGE_IDENTITY_MISMATCH_CODE, + EdgeAcyclicityError, + EdgeAcyclicityIndeterminateError, EdgeMatchIdentityConflictError, EdgeNotFoundError, EmbeddingDimensionChangedError, @@ -458,6 +498,7 @@ export { GraphAlgorithmConvergenceError, IdentityContradictionError, IdentityEndpointValidityError, + IdentityReplayError, IdentitySeparationViolationError, IdentityValidityWindowError, IMMUTABLE_VALIDITY_LOWER_BOUND_CODE, @@ -472,6 +513,7 @@ export { MIGRATION_FAILURE_REASONS, MigrationError, NodeConstraintNotFoundError, + NodeDeletePlanMismatchError, NodeIndexNotFoundError, NodeNotFoundError, RECORDED_CAPTURE_GUARD_CODES, @@ -666,6 +708,37 @@ export { isSchemaInitialized, registerGraphTemplate, } from "./schema"; +// The data checks a schema commit's ontology tightening owes. Named here so +// a consumer narrowing `MigrationErrorDetails.changes[n].probes` or a +// `ConstraintFenceViolation`'s `edgeEndpointAssignability` member (below) +// can name every field's type without a subpath import. Also available from +// the "./schema" subpath. +export type { + OntologyDataProbe, + UniquenessComponentProbeGroup, +} from "./schema"; +export { PROBE_VIOLATION_FAMILIES, probeCoversViolationFamily } from "./schema"; +// The constraints a schema commit refuses to strand, for a consumer +// narrowing `MigrationErrorDetails` on +// `reason: "unique-constraint-requires-empty-kind"`. Also available from the +// "./schema" subpath. +export type { UniquenessClaimGate } from "./schema"; +// The axes a schema commit's edge-cardinality tightening owes, for a +// consumer narrowing `MigrationErrorDetails` on +// `reason: "edge-cardinality-tightening-violated"` — same reasoning as the +// ontology probe types above. Also available from the "./backend" subpath. +// `CompositionClaimScope` is exported alongside it: it is `EdgeCardinalityDeclaration.scope`'s +// only non-`undefined` member, so a consumer narrowing that field needs it +// too, without reaching into `./backend/types` directly. +export type { + CompositionClaimScope, + EdgeCardinalityDeclaration, +} from "./backend/types"; +// `EdgeCardinalityDeclaration` and `CardinalityErrorDetails.direction` are +// both built from these two: a consumer narrowing either one needs to name +// the intersection member (`EdgeCardinalityAxisRef`) or the bare direction +// discriminant (`EdgeCardinalityDirection`) without reaching into +// `store/claims/edge-claims` directly. export type { AlgorithmCyclePolicy, BaseTraversalOptions, @@ -691,9 +764,14 @@ export type { WeightedShortestPathOptions, WeightedShortestPathResult, } from "./store/algorithms"; +export type { + EdgeCardinalityAxisRef, + EdgeCardinalityDirection, +} from "./store/claims/edge-claims"; export type { AnyEdge, AnyNode, + SubgraphCompositionSelection, SubgraphEdgeKey, SubgraphEdgeLookup, SubgraphEdgeResult, @@ -704,6 +782,7 @@ export type { SubgraphNodeResult, SubgraphOptions, SubgraphResult, + SubgraphResultEdgeKinds, SubsetEdge, SubsetNode, } from "./store/subgraph"; @@ -714,6 +793,11 @@ export type { BulkOperationHookContext, CompareAndSetAbsent, CompareAndSetExpected, + CompositionAttachment, + CompositionAttachmentProps, + CompositionCascadedPart, + CompositionHeldEdge, + CompositionNodeRef, ConstraintNames, CreateEdgeInput, CreateNodeInput, @@ -726,12 +810,16 @@ export type { EdgeBulkFindEndpointOptions, EdgeBulkFindOptions, EdgeCollection, + EdgeEndpointRef, EdgeFindByEndpointsOptions, EdgeGetOrCreateByEndpointsOptions, EdgeGetOrCreateByEndpointsResult, + EdgeInGraph, GetOrCreateAction, GraphEdgeCollections, GraphNodeCollections, + GraphNodeForKinds, + GraphNodeReferenceInput, HeterogeneousNodeUpsertInput, HeterogeneousNodeUpsertResult, HistoryStoreOptions, @@ -745,9 +833,15 @@ export type { Node, NodeBulkFindByIndexOptions, NodeCollection, + NodeCreateOptions, + NodeDeleteOptions, + NodeDeletePlan, NodeGetOrCreateByConstraintOptions, NodeGetOrCreateByConstraintResult, NodeRef, + NodeReferenceLookup, + NodeReparentOptions, + NodeReparentResult, NoRecordedCoordinate, OperationHookContext, QueryHookContext, @@ -766,6 +860,7 @@ export type { StoreOptions, StoreProjection, StoreRef, + SubsumedNodeReference, TransactionContext, TransactionOutcome, TransactionReceipt, @@ -786,6 +881,7 @@ export { compareAndSetAbsent } from "./store/types"; export type { AggregateResult, AliasMap, + AssignableNodeKinds, BatchableQuery, BatchResults, CommonPropertyKeys, @@ -793,6 +889,8 @@ export type { CompiledRowsSql, CompiledSelectSql, CompiledStatementSql, + CompositionNavigationOptions, + CompositionWholeKinds, DynamicEdgeAccessor, DynamicEdgeType, DynamicFieldBuilder, @@ -849,6 +947,7 @@ export type { SqlSchema, SqlTableNames, StreamOptions, + SubsumedNodeType, TraversalExpansion, } from "./query"; export type { ParameterRef } from "./query/ast"; @@ -905,6 +1004,14 @@ export { UnionableQuery, } from "./query"; +// The one expansion axis a `from`/`to` alias, a store-wide default, and +// `search()` state (the default and search forms exclude `"narrower"`). +export type { AliasExpansionAxis, DefaultAliasExpansionAxis } from "./query"; +export { + DEFAULT_ALIAS_EXPANSION_AXIS, + SEARCH_EXPANSION_DEFAULT, +} from "./query"; + // Fragment composition types export type { FlexibleQueryFragment, diff --git a/packages/typegraph/src/indexes/system.ts b/packages/typegraph/src/indexes/system.ts index ead46fb69..81c01c4ce 100644 --- a/packages/typegraph/src/indexes/system.ts +++ b/packages/typegraph/src/indexes/system.ts @@ -33,7 +33,8 @@ import { type GraphRelationNameSource, resolveGraphRelationNames, } from "../backend/graph-relations"; -import { quoteIdentifier, shortHash } from "../query/dialect/vector-strategy"; +import { quoteIdentifier } from "../query/dialect/vector-strategy"; +import { shortHash } from "../utils/hash"; import { requireDefined } from "../utils/presence"; /** The TypeGraph relations that carry system indexes. */ diff --git a/packages/typegraph/src/interchange/export.ts b/packages/typegraph/src/interchange/export.ts index 6b82adc4c..c06fc078b 100644 --- a/packages/typegraph/src/interchange/export.ts +++ b/packages/typegraph/src/interchange/export.ts @@ -19,8 +19,14 @@ import { ExportStreamCancelledError, ExportStreamIdleTimeoutError, } from "../errors"; +import { + type IdentityTransitionCursor, + type IdentityTransitionTransfer, +} from "../identity/transition-log"; +import { activeOnlyAxisReferences } from "../store/claims/edge-claims"; import { storeBackend, storeRuntime } from "../store/runtime-port"; import { type Store } from "../store/store"; +import { compareStrings } from "../utils/compare"; import { nowIso } from "../utils/date"; import { requireDefined } from "../utils/presence"; import { serializedStreamRefusal } from "./import"; @@ -35,6 +41,7 @@ import { type GraphInterchangeChunk, type InterchangeEdge, type InterchangeIdentityAssertion, + type InterchangeIdentityTransition, type InterchangeNode, } from "./types"; @@ -67,6 +74,7 @@ export async function exportGraph( const nodes: InterchangeNode[] = []; const edges: InterchangeEdge[] = []; const identityAssertions: InterchangeIdentityAssertion[] = []; + const identityTransitions: InterchangeIdentityTransition[] = []; let header: GraphDataHeader | undefined; for await (const chunk of exportGraphStream(store, options)) { switch (chunk.type) { @@ -86,6 +94,10 @@ export async function exportGraph( identityAssertions.push(...chunk.assertions); break; } + case "identity-transitions": { + identityTransitions.push(...chunk.transitions); + break; + } } } if (header === undefined) { @@ -96,10 +108,24 @@ export async function exportGraph( ...headerWithoutIdentity, nodes, edges, + // Rebuilt field-by-field rather than spread: the header's `identity` + // also carries `hasTransitions`, a streaming-only announcement with no + // place in the full envelope — the real `transitions` array right below + // already says everything it would. ...(identity === undefined ? {} : { - identity: { ...identity, assertions: identityAssertions }, + identity: { + profile: identity.profile, + mode: identity.mode, + ...(identity.retention === undefined ? + {} + : { retention: identity.retention }), + assertions: identityAssertions, + ...(identity.mode === "archival" ? + { transitions: identityTransitions } + : {}), + }, }), }; } @@ -172,24 +198,53 @@ export function exportGraphStream( ); } -async function* exportGraphStreamFromBackend( - store: Store, - backend: GraphBackend, - options?: ExportStreamOptionsInput, -): AsyncIterable { - const parsed = ExportStreamOptionsSchema.parse(options ?? {}); - // Identity rows carry their effective-time windows unconditionally. Their - // endpoint rows must therefore carry temporal bounds too, or the store's own - // export could not prove on re-import that each endpoint existed throughout - // an assertion's window. - const exportsIdentity = store.graph.identity !== undefined; - const explicitlyDisabledTemporalFields = options?.includeTemporal === false; - if (exportsIdentity && explicitlyDisabledTemporalFields) { - throw new ConfigurationError( +/** + * Why this graph's export must carry temporal fields — or `undefined` when the + * caller's choice stands. A graph that requires them exports them by default, + * and an explicit `includeTemporal: false` earns {@link temporalFieldsRefusal}. + * + * - Identity rows carry their effective-time windows unconditionally. Their + * endpoint rows must therefore carry temporal bounds too, or the store's + * own export could not prove on re-import that each endpoint existed + * throughout an assertion's window. + * - An active-only cardinality axis (`cardinality` or `targetCardinality` + * `"oneActive"` — which is also how a `oneActive` composition pair's + * population is declared) counts a row only while its window is open. An + * ended row exported without its `validTo` would re-import as current: + * a reparented part attached to its former whole again, or a second + * active edge refused against the first. + */ +function requiredTemporalFields( + graph: GraphDef, +): TemporalFieldsRequirement | undefined { + if (graph.identity !== undefined) return { reason: "identity" }; + const activeOnlyEdgeKinds = Object.entries(graph.edges) + .filter( + ([, registration]) => activeOnlyAxisReferences(registration).length > 0, + ) + .map(([edgeKind]) => edgeKind) + .toSorted(compareStrings); + if (activeOnlyEdgeKinds.length === 0) return undefined; + return { reason: "active-only-cardinality", edgeKinds: activeOnlyEdgeKinds }; +} + +type TemporalFieldsRequirement = + | Readonly<{ reason: "identity" }> + | Readonly<{ + reason: "active-only-cardinality"; + edgeKinds: readonly string[]; + }>; + +function temporalFieldsRefusal( + requirement: TemporalFieldsRequirement, + identityMode: string, +): ConfigurationError { + if (requirement.reason === "identity") { + return new ConfigurationError( "Identity export requires temporal endpoint fields.", { code: "IDENTITY_EXPORT_REQUIRES_TEMPORAL_FIELDS", - identityMode: parsed.identityMode, + identityMode, includeTemporal: false, }, { @@ -198,8 +253,36 @@ async function* exportGraphStreamFromBackend( }, ); } + return new ConfigurationError( + "Export of a graph with an active-only edge cardinality requires temporal fields.", + { + code: "ACTIVE_CARDINALITY_EXPORT_REQUIRES_TEMPORAL_FIELDS", + edgeKinds: requirement.edgeKinds, + includeTemporal: false, + }, + { + suggestion: `Remove includeTemporal or set it to true: ${requirement.edgeKinds.map((edgeKind) => `"${edgeKind}"`).join(", ")} count a row only while its window is open, so an ended row exported without its validTo would re-import as current.`, + }, + ); +} + +async function* exportGraphStreamFromBackend( + store: Store, + backend: GraphBackend, + options?: ExportStreamOptionsInput, +): AsyncIterable { + const parsed = ExportStreamOptionsSchema.parse(options ?? {}); + const temporalFieldsRequirement = requiredTemporalFields(store.graph); + if ( + temporalFieldsRequirement !== undefined && + options?.includeTemporal === false + ) { + throw temporalFieldsRefusal(temporalFieldsRequirement, parsed.identityMode); + } const resolved = - exportsIdentity ? { ...parsed, includeTemporal: true } : parsed; + temporalFieldsRequirement === undefined ? parsed : ( + { ...parsed, includeTemporal: true } + ); const signal = resolved.signal; const idleTimeoutMs = resolved.idleTimeoutMs; const channel = createRendezvousChannel(); @@ -465,6 +548,23 @@ function claimSnapshotExportLeaseOrRefuse( return lease.release; } +/** + * Whether `store`'s archival transitions section will carry at least one + * row — a cheap existence probe (never the full page), read for the + * streaming header's `hasTransitions` announcement (see + * `produceExportChunks`'s call site). + */ +async function graphHasIdentityTransitions( + store: Store, + backend: GraphBackend | TransactionBackend, +): Promise { + const probe = await storeRuntime(store).readIdentityTransitionPageAtTarget( + backend, + { limit: 1 }, + ); + return probe.transitions.length > 0; +} + async function produceExportChunks( store: Store, backend: GraphBackend | TransactionBackend, @@ -475,6 +575,36 @@ async function produceExportChunks( const nodeKinds = options.nodeKinds ?? getNodeKinds(store.graph); const edgeKinds = options.edgeKinds ?? getEdgeKinds(store.graph); const schemaVersion = await backend.getActiveSchema(graphId); + const archivalIdentityEnabled = + store.graph.identity !== undefined && options.identityMode === "archival"; + // The source graph's own retention watermark, so a reader of the raw + // payload knows the transitions section excludes anything the source had + // already pruned. Read from the same snapshot the rest of the export reads + // from, before the header is emitted, so a transactional export's header + // is consistent with everything that follows it. Omitted entirely when + // nothing has EVER been pruned (`prunedBeforeRevision === 0`): + // `identityTransitionRetentionAtTarget`'s "nothing pruned" default stamps a + // fresh `prunedAt` on every read (it names no real prune event), which + // would otherwise make two archival exports of an unchanged graph compare + // unequal on that field alone. + const rawRetention = + archivalIdentityEnabled ? + await storeRuntime(store).identityTransitionRetentionAtTarget(backend) + : undefined; + const retention = + rawRetention === undefined || rawRetention.prunedBeforeRevision === 0 ? + undefined + : rawRetention; + // Read for the SAME reason as `retention` above: a streaming importer's + // header handler is the only place it can refuse an archival-transitions + // restore into a `history: false` target BEFORE any node or edge write — + // the "identity-transitions" chunk itself always arrives last. A cheap + // existence probe (never the full page) answers that from the same + // snapshot the header's other fields come from. + const hasTransitions = + archivalIdentityEnabled ? + await graphHasIdentityTransitions(store, backend) + : false; await emit({ type: "header", @@ -492,6 +622,8 @@ async function produceExportChunks( identity: { profile: "typegraph-identity-v1" as const, mode: options.identityMode, + ...(retention === undefined ? {} : { retention }), + ...(hasTransitions ? { hasTransitions: true } : {}), }, }), }, @@ -534,6 +666,91 @@ async function produceExportChunks( if (page.assertions.length > 0) { await emit({ type: "identity", assertions: [...page.assertions] }); } + if (page.done) break; + after = requireDefined(page.nextAfter); + } + // Explanation, not truth: `archival` mode alone carries transitions — + // `state` mode carries current truth only, and neither branch cloning nor a + // fresh graph's own history should carry explanation for events it never + // lived through. + // + // Deliberately NOT scoped by `options.nodeKinds`, unlike the assertions + // page above: `readIdentityTransitionPageForInterchange` walks the whole + // log every archival export, so a `nodeKinds`-filtered archival export + // still carries transitions naming excluded kinds. Restoring such an + // export into a target that never receives those kinds' nodes inserts + // transitions whose class refs the target's shape-only restore validation + // does not check for referential presence (identity.md, "Archival + // transitions and the retention watermark"). + if (options.identityMode === "archival") { + await produceIdentityTransitionChunks( + store, + backend, + emit, + options.batchSize, + ); + } +} + +/** + * Widens the internal transfer shape's `readonly` array fields + * (`assertionIds`, `decision.branchAncestry`) to the mutable arrays the wire + * schema's `z.array(...)` fields infer — a structural, value-preserving copy, + * never a semantic transform. + */ +function wireTransition( + transition: IdentityTransitionTransfer, +): InterchangeIdentityTransition { + const { decision, ...rest } = transition; + const { branchAncestry, ...decisionRest } = decision ?? {}; + return { + ...rest, + assertionIds: [...transition.assertionIds], + ...(decision === undefined ? + {} + : { + decision: { + ...decisionRest, + ...(branchAncestry === undefined ? + {} + : { branchAncestry: [...branchAncestry] }), + }, + }), + }; +} + +async function produceIdentityTransitionChunks( + store: Store, + backend: GraphBackend | TransactionBackend, + emit: (chunk: GraphInterchangeChunk) => Promise, + batchSize: number, +): Promise { + let after: IdentityTransitionCursor | undefined; + // At least one "identity-transitions" chunk is ALWAYS emitted, even when + // the graph has zero retained transitions (a fully-pruned source) — this is + // what lets the streaming importer set `identity.transitions` (possibly to + // `[]`) at all, so a carried retention watermark still reaches the restore + // path. `exportGraph`'s accumulator gets this for free (it always assigns + // `transitions: identityTransitions` for archival mode); the stream must + // emit the chunk itself to give an incremental consumer the same signal. + let emittedAny = false; + for (;;) { + const page = await storeRuntime(store).readIdentityTransitionPageAtTarget( + backend, + { + ...(after === undefined ? {} : { after }), + limit: batchSize, + }, + ); + if (page.transitions.length > 0 || !emittedAny) { + await emit({ + type: "identity-transitions", + transitions: page.transitions.map((transition) => + wireTransition(transition), + ), + }); + emittedAny = true; + } if (page.done) return; after = requireDefined(page.nextAfter); } diff --git a/packages/typegraph/src/interchange/import.ts b/packages/typegraph/src/interchange/import.ts index b97a2b6e0..6a3d900fb 100644 --- a/packages/typegraph/src/interchange/import.ts +++ b/packages/typegraph/src/interchange/import.ts @@ -85,46 +85,79 @@ import { type GraphDef, } from "../core/define-graph"; import { - type Cardinality, type EdgeRegistration, type NodeRegistration, type UniqueConstraint, } from "../core/types"; import { CardinalityError, + CompositionError, + CompositionExistenceError, ConfigurationError, DatabaseOperationError, DisjointError, + EdgeAcyclicityError, EdgeMatchIdentityConflictError, IdentityContradictionError, + IdentityEndpointValidityError, IMMUTABLE_VALIDITY_LOWER_BOUND_CODE, INVERTED_VALIDITY_WINDOW_CODE, NodeNotFoundError, UniquenessError, ValidationError, } from "../errors"; +import { zodIssuesToValidationIssues } from "../errors/validation"; import { + assertIdentityTransitionsMonotone, IDENTITY_IMPORT_FAILED_ASSERTION, IDENTITY_IMPORT_PROGRESS, + isIdentityTransitionRestore, } from "../identity/service"; import { type IdentityTarget } from "../identity/sql-target"; +import { + type IdentityRestoreBaseline, + identityTransitionLogUnavailableError, +} from "../identity/transition-log"; +import { type SqlSchema } from "../query/compiler/schema"; +import { orientCompositionEndpoints } from "../registry/composition-relation"; import { type KindRegistry } from "../registry/kind-registry"; -import { edgeCardinalityClaim } from "../store/claims/edge-claims"; +import { + acyclicityProbeContext, + assertEdgeRelationsAcyclic, + edgeKindIsInAcyclicRelation, +} from "../store/acyclicity"; +import { edgeInsertClaims } from "../store/claims/composition-claims"; +import { + edgeCardinalityAxisReferences, + type EdgeCardinalityDeclarations, + edgeCardinalitySpec, +} from "../store/claims/edge-claims"; import { checkUniquenessConstraints, type NodeClaimItem, planNodeCreateClaims, } from "../store/claims/node-claims"; import { - checkCardinalityConstraint, checkDisjointnessConstraint, + checkEdgeCardinalityConstraints, graphOwesClaims, + graphOwesLockOnlyFence, } from "../store/constraints"; import { classifyDurableEdgeBatchOutcomes } from "../store/durable-edge-batch"; import { edgeMatchIdentityUpdateRefusal, resolveEdgeMatchIdentityStorage, } from "../store/edge-match-key"; +import { + assertCompositionExistencePreserved, + COMPOSITION_ATTACHMENT_PAGE_SIZE, + compositionWindowRefusal, + compositionWindowSubject, + declaresRequiredCompositionParts, + edgeWriteEndsOpenWindow, + readCompositionAttachmentsForPage, + readCompositionPartStandings, +} from "../store/operations/composition-create"; import { createEdgeBatchValidationBackend } from "../store/operations/edge-batch-validation"; import { createNodeBatchValidationSeams, @@ -148,22 +181,25 @@ import { type WriteTransactionMode, } from "../store/operations/write-transaction"; import { runRecordedTransactionSavepoint } from "../store/recorded-capture"; +import { type GraphWriteLock } from "../store/recorded-capture/clock"; import { storeBackend, storeCaptureEnabled, storeRuntime, } from "../store/runtime-port"; import { type Store } from "../store/store"; +import { chunk } from "../utils/array"; import { compareCodePoints } from "../utils/compare"; import { assertOrderedValidityWindow, assertWritableValidityWindow, + nowIso, validateOptionalCanonicalIsoDate, type ValidityLowerBoundFence, } from "../utils/date"; import { createDataKeyedBag, hasOwnKey } from "../utils/object"; import { requireDefined } from "../utils/presence"; -import { encodeTupleKey } from "../utils/tuple-key"; +import { encodeTupleKey, nodeRefKey } from "../utils/tuple-key"; import { type IngestionImportTarget, resolveIngestionImportTarget, @@ -174,13 +210,16 @@ import { type GraphDataHeader, GraphDataHeaderSchema, type GraphInterchangeChunk, + type IdentityInterchangeMode, type ImportError, type ImportOptions, ImportOptionsSchema, type ImportResult, type InterchangeEdge, type InterchangeIdentityAssertion, + type InterchangeIdentityRetention, InterchangeIdentitySchema, + type InterchangeIdentityTransition, type InterchangeNode, type ResolvedImportOptions, type UnknownPropertyStrategy, @@ -307,6 +346,51 @@ export async function withImportStreamLease( * alone, because it is a property of the frame, not of the leg. It takes the * READS to answer; the executor owns decorating its own target with them. */ +/** + * One required-existence part THIS import created, owing a live whole by the + * time the import commits. + */ +type PendingRequiredPart = Readonly<{ kind: string; id: string }>; + +/** + * Every required part this import created, by `makeNodeKey`. Nothing clears + * an entry while the payload is processed: whether a part ends up attached + * is one question, asked once, of the committed rows + * ({@link assertImportedRequiredPartsAttached}). + */ +type PendingRequiredParts = Map; + +/** The result counters a committed row was counted under. */ +interface CommittedWriteCounts { + created: number; + updated: number; +} + +/** + * What an import must remember to undo a required part it has to refuse: the + * parts themselves, and how each row this import wrote was counted, so a + * purge takes exactly those counts back — a part created and then updated by + * a later duplicate row, an edge the purge removes with it — and no edge that + * predates the import is ever counted as one it wrote. Empty for a graph that + * declares no required part, which never purges. + */ +type RequiredPartLedger = Readonly<{ + /** Whether the graph declares any required-existence part, fixed per attempt. */ + declaresRequiredParts: boolean; + /** + * Parts no row of the payload and no row of the target can attach, by + * `makeNodeKey`, decided before the first write + * ({@link readUnattachableRequiredParts}). A row that would create one is + * refused unwritten. + */ + unattachableParts: ReadonlySet; + pendingParts: PendingRequiredParts; + /** Update rows this import applied to a pending part, by `makeNodeKey`. */ + partUpdates: Map; + /** How each edge this import wrote was counted, by edge id. */ + edgeWrites: Map; +}>; + type ImportWriteFrame = Readonly<{ session: WriteSession; target: WriteTarget; @@ -319,6 +403,25 @@ type ImportWriteFrame = Readonly<{ uniqueSidecarBatch: BundleVerdictOf; /** Threaded `statementExecution` verdict — resolved once, from `backend`. */ statementExecution: BundleVerdictOf; + /** + * The write-transaction's lock evidence — real coordination only when + * `graphOwesLockOnlyFence` made this chunk take the per-graph lock (an + * acyclic edge kind is declared). Threaded here so `processEdgeSlice` can + * run the combined post-flush acyclicity probe under the same evidence a + * managed store write would use. + */ + lock: GraphWriteLock; + /** The code-level graph, for `acyclicRelationForEdgeKind`. */ + graph: GraphDef; + schema: SqlSchema; + /** + * The identity half of a node update that states `validTo`, bound to this + * frame's target — the store's own window-end owner, reached through the + * runtime port. + */ + applyNodeWindowEnd: ( + input: Readonly<{ kind: string; id: string; validTo?: string }>, + ) => Promise; /** * Endpoint liveness verdicts this attempt already read, by node key. An * attempt is one write plan: inside its transaction the endpoint locks the @@ -378,6 +481,8 @@ function createImportAttemptState(): ImportAttemptState { */ export type ImportAttemptInputs = Readonly<{ graphId: string; + graph: G; + schema: SqlSchema; registry: KindRegistry; data: GraphData; nodeSchemas: ReadonlyMap; @@ -387,6 +492,14 @@ export type ImportAttemptInputs = Readonly<{ batchPointRead: BundleVerdictOf; uniqueSidecarBatch: BundleVerdictOf; statementExecution: BundleVerdictOf; + /** + * The destination as it stood before the import began, when the caller + * already read it: a streamed import reads it once, ahead of its first + * chunk, and hands it to the chunk that restores transitions. Absent for a + * single-document import, whose one attempt reads it itself before its + * first write. + */ + identityRestoreBaseline?: IdentityRestoreBaseline | undefined; }>; /** @@ -396,12 +509,19 @@ export type ImportAttemptInputs = Readonly<{ * identity-fold, and edge processing, and returns it, so a rolled-back * attempt leaves nothing behind for a later one to read. * + * `importGraphData` reaches this function through `runWritePlan`, whose + * `runInWriteTransaction` replays the whole attempt through `runRetriedUnit` + * when the store owns a unit its fence can refuse — an import into a graph + * with an acyclic or composition relation (the frame's lock-only reason), in + * a transaction TypeGraph opened on a keyed fence — and for any import on an + * `"optimistic-retry"` backend. A replay calls + * this function again on the new transaction, which is why it never reuses + * state across attempts. + * * Exported for `tests/transaction-retry.test.ts` ONLY, which drives this * function directly through `runRetriedUnit` and a fault-injected backend to - * prove attempt state is fresh every time. Import itself is NOT routed - * through the retry owner in this stage: `importGraphData` still calls this - * function exactly once, through its own single-attempt `runWritePlan`. No - * published entrypoint re-exports it. + * prove attempt state is fresh every time. No published entrypoint + * re-exports it. */ export async function runImportWritePlanAttempt( inputs: ImportAttemptInputs, @@ -409,8 +529,33 @@ export async function runImportWritePlanAttempt( target: WriteTarget, overlaidSession: OverlaidSessionMint<"mixed">, transactionMode: WriteTransactionMode, + lock: GraphWriteLock, ): Promise { const { result, errors, importedNodeIds } = createImportAttemptState(); + // Every required-existence part THIS import creates, keyed by + // `makeNodeKey`, and every edge it writes. Frame-scoped, like + // `pendingMatchIdentityOwners`: nodes are written before any edge is even + // seen (`processNodes` then `processEdges`), so whether a part ended up + // attached can only be decided once the whole edge set is written — except + // for a part nothing can attach, which is settled here, before any row. + const declaresRequiredParts = declaresRequiredCompositionParts( + inputs.registry, + ); + const requiredPartLedger: RequiredPartLedger = { + declaresRequiredParts, + unattachableParts: + declaresRequiredParts ? + await readUnattachableRequiredParts( + target, + inputs.graphId, + inputs.registry, + inputs.data, + ) + : new Set(), + pendingParts: new Map(), + partUpdates: new Map(), + edgeWrites: new Map(), + }; let nextEdgeSavepointId = 0; const frame: ImportWriteFrame = { session, @@ -422,9 +567,22 @@ export async function runImportWritePlanAttempt( batchPointRead: inputs.batchPointRead, uniqueSidecarBatch: inputs.uniqueSidecarBatch, statementExecution: inputs.statementExecution, + lock, + graph: inputs.graph, + schema: inputs.schema, + applyNodeWindowEnd: (input) => + inputs.runtime.applyImportedNodeWindowEnd(target, input), endpointLiveness: new Map(), endpointLocksContend: !serializesWriters(resolveWriteFencePlan(target)), }; + // Before the first row: whether this destination has identity history of + // its own is a fact about it BEFORE this import, and the node and assertion + // writes below record transitions of their own. + const identityRestoreBaseline = + inputs.identityRestoreBaseline ?? + (identitySectionRestoresTransitions(inputs.data.identity) ? + await inputs.runtime.readIdentityRestoreBaselineAtTarget(target) + : undefined); await processNodes( frame, inputs.graphId, @@ -435,6 +593,7 @@ export async function runImportWritePlanAttempt( result, errors, importedNodeIds, + requiredPartLedger, ); await inputs.runtime.foldImportedIdentityNodes( target, @@ -453,6 +612,22 @@ export async function runImportWritePlanAttempt( result, errors, importedNodeIds, + requiredPartLedger, + ); + // Every required part this import created is judged once every edge in the + // payload is written. Runs AFTER `foldImportedIdentityNodes` above (which + // needs the full node batch) — a part purged here undoes that fold itself, + // through `runtime.detachDeletedImportedIdentityNode`, rather than never + // having been folded in the first place. + await assertImportedRequiredPartsAttached( + frame, + inputs.graphId, + inputs.registry, + inputs.runtime, + requiredPartLedger, + result, + importedNodeIds, + errors, ); if (inputs.data.identity !== undefined) { await importIdentitySection( @@ -460,6 +635,7 @@ export async function runImportWritePlanAttempt( target, inputs.graphId, inputs.data.identity, + identityRestoreBaseline, result, errors, ); @@ -471,12 +647,26 @@ async function importGraphData( store: Store, data: GraphData, options: ResolvedImportOptions, + identityRestoreBaseline?: IdentityRestoreBaseline, ): Promise { // Reject an identity payload aimed at an identity-disabled graph, and // runtime-validate the (bounded) identity section, BEFORE any entity write — // see assertIdentityImportSupported / validateIdentitySection. assertIdentityImportSupported(store, data.identity !== undefined); validateIdentitySection(data.identity); + if (data.identity !== undefined) { + assertStateIdentityCarriesNoHistory( + data.identity.mode, + (data.identity.transitions?.length ?? 0) > 0, + data.identity.retention, + ); + assertIdentityTransitionsMonotone(data.identity.transitions ?? []); + } + assertIdentityTransitionsRestoreSupported( + store, + (data.identity?.transitions?.length ?? 0) > 0, + data.identity?.retention, + ); const graph = store.graph; const graphId = store.graphId; @@ -508,6 +698,20 @@ async function importGraphData( ); if (claimRefusal !== undefined) throw claimRefusal; + // The lock-only question is separate from the claim question above and can + // answer differently: acyclicity (`lockOnly`) has no claim row to + // substitute for the per-graph lock import otherwise takes none of, so a + // graph with an acyclic edge kind and no claim-backed constraint at all + // would sail past the check above and reach a transactionless backend + // unfenced. Answered per graph, before the first chunk, same as the claim + // question — see `graphOwesLockOnlyFence`. + const owedLockOnlyReason = graphOwesLockOnlyFence(graph, registry); + const lockOnlyRefusal = + owedLockOnlyReason === undefined ? undefined : ( + constraintFenceRefusal({ graphId }, backend, owedLockOnlyReason) + ); + if (lockOnlyRefusal !== undefined) throw lockOnlyRefusal; + // Build lookup maps for schema validation const nodeSchemas = buildNodeSchemaMap(graph); const edgeSchemas = buildEdgeSchemaMap(graph); @@ -524,6 +728,8 @@ async function importGraphData( // schema maps, verdicts, and request pieces reach whichever attempt(s) run. const attemptInputs: ImportAttemptInputs = { graphId, + graph, + schema: store.revisionSchema, registry, data, nodeSchemas, @@ -533,13 +739,15 @@ async function importGraphData( batchPointRead, uniqueSidecarBatch, statementExecution, + identityRestoreBaseline, }; // One transaction on a transactional backend; runs directly otherwise, with // the per-graph write lock taken before any row work — see runWritePlan and // the runInWriteTransaction contract underneath it, the shared - // lock-before-rows discipline every writer follows. A single, unretried - // attempt in this stage — see runImportWritePlanAttempt's doc comment. + // lock-before-rows discipline every writer follows. That contract may replay + // the attempt on a new transaction — see runImportWritePlanAttempt's doc + // comment. const { result, errors } = await runWritePlan( { graphId, @@ -568,15 +776,22 @@ async function importGraphData( }, // An import writes node rows AND edge rows in one frame, so it declares the // mixed family explicitly instead of receiving either narrower session. - mixedWritePlan(undefined, true), + // The constraint probe is the lock-only reason ONLY (never the claim + // reason above): a claim-backed axis is fenced by its reservation row, + // never by this lock, so passing it here would take a lock this import + // has never needed and does not document taking. `undefined` when the + // graph declares no acyclic edge kind, matching every prior release's + // behavior byte-for-byte. + mixedWritePlan(owedLockOnlyReason, true), backend, - (session, target, overlaidSession, _lock, transactionMode) => + (session, target, overlaidSession, lock, transactionMode) => runImportWritePlanAttempt( attemptInputs, session, target, overlaidSession, transactionMode, + lock, ), ); @@ -612,6 +827,12 @@ async function importGraphData( * Nodes must precede edges. Once a node chunk commits, edge validation reads the * target store rather than retaining every imported node id in memory. * + * Refused with a typed {@link ConfigurationError} + * (`IMPORT_STREAM_REQUIRED_COMPOSITION_UNSUPPORTED`) before any chunk is read + * when the target graph declares a required-existence composition part kind: + * a part and the edge attaching it arrive in different chunks, so only + * {@link importGraph}'s single transaction can check the part's attachment. + * * Refused with a typed {@link ConfigurationError} when the target writes through * a serialized database connection that another long-lived interchange stream * already holds — either because this stream came from a snapshot export on that @@ -642,28 +863,33 @@ export async function importGraphStream( ): Promise { const store = resolveIngestionImportTarget(target); const options = ImportOptionsSchema.parse(rawOptions); - const sourceBackend = exportStreamBackend(chunks); const targetBackend = storeBackend(store); - // Whether this export would hold the connection the import writes through is - // decided by the single owner of that predicate — including the - // `execution.interactiveTransactions: false` abstention, where nothing is held open and refusing - // would refuse work that succeeds. - const contention = - sourceBackend === undefined ? undefined : ( - snapshotExportContention(sourceBackend, targetBackend) - ); - if (contention !== undefined) { + const refusal = streamedImportRefusal(store, exportStreamBackend(chunks)); + if (refusal?.reason === "requiredComposition") { + throw streamedRequiredCompositionRefusal(store.graphId); + } + if (refusal?.reason === "exportContention") { throw serializedStreamRefusal({ graphId: store.graphId, requested: "import-stream", heldBy: "export-snapshot", - detector: contention, + detector: refusal.detector, }); } const result = emptyImportResult(); let header: GraphDataHeader | undefined; let receivedEdges = false; let receivedIdentity = false; + let receivedIdentityTransitions = false; + // The last revision a transitions chunk carried, so the next one is held + // to the same non-decreasing order a single document is. + let lastTransitionRevision = Number.NEGATIVE_INFINITY; + // The destination before this stream's first chunk, when the header + // announces an archival transition restore. Read there, once: every chunk + // commits on its own, so by the time the transitions chunk runs the + // destination already holds the transitions this stream's own node and + // assertion chunks recorded. + let identityRestoreBaseline: IdentityRestoreBaseline | undefined; let releaseImportLease: (() => void) | undefined; try { @@ -717,6 +943,28 @@ export async function importGraphStream( store, chunk.header.identity !== undefined, ); + // Same guard as importGraph's upfront check, using the header's + // `hasTransitions` announcement in place of the array itself — the + // "identity-transitions" chunk always arrives last, after nodes, + // edges and identity assertions have already been written. + if (chunk.header.identity !== undefined) { + assertStateIdentityCarriesNoHistory( + chunk.header.identity.mode, + chunk.header.identity.hasTransitions === true, + chunk.header.identity.retention, + ); + } + assertIdentityTransitionsRestoreSupported( + store, + chunk.header.identity?.hasTransitions === true, + chunk.header.identity?.retention, + ); + if (streamHeaderAnnouncesRestore(chunk.header)) { + identityRestoreBaseline = + await storeRuntime(store).readIdentityRestoreBaselineAtTarget( + targetBackend, + ); + } header = chunk.header; break; } @@ -726,11 +974,17 @@ export async function importGraphStream( "Graph interchange stream must start with a header.", ); } - if (receivedEdges || receivedIdentity) { + if ( + receivedEdges || + receivedIdentity || + receivedIdentityTransitions + ) { + const after = + receivedEdges ? "edges" + : receivedIdentity ? "identity assertions" + : "identity transitions"; throw new Error( - `Graph interchange stream cannot emit nodes after ${ - receivedEdges ? "edges" : "identity assertions" - }.`, + `Graph interchange stream cannot emit nodes after ${after}.`, ); } if (chunk.nodes.length === 0) break; @@ -754,7 +1008,7 @@ export async function importGraphStream( "Graph interchange stream must start with a header.", ); } - if (receivedIdentity) { + if (receivedIdentity || receivedIdentityTransitions) { throw new Error( "Graph interchange stream cannot emit edges after identity assertions.", ); @@ -786,6 +1040,11 @@ export async function importGraphStream( "Graph interchange stream emitted identity rows without an identity header.", ); } + if (receivedIdentityTransitions) { + throw new Error( + "Graph interchange stream cannot emit identity assertions after identity transitions.", + ); + } receivedIdentity = true; if (chunk.assertions.length === 0) break; mergeImportResult( @@ -799,6 +1058,46 @@ export async function importGraphStream( throwIfStreamChunkFailed(result, options); break; } + case "identity-transitions": { + if (header === undefined) { + throw new Error( + "Graph interchange stream must start with a header.", + ); + } + if (header.identity === undefined) { + throw new Error( + "Graph interchange stream emitted identity transitions without an identity header.", + ); + } + // The destination baseline was read at the header, before any chunk + // wrote. Transitions the header never announced have no baseline to + // be restored against, and applying them without one would drop the + // retention watermark an announced stream sets. + if ( + identityRestoreBaseline === undefined && + chunk.transitions.length > 0 + ) { + throw new Error( + "Graph interchange stream emitted identity transitions its header did not announce (identity.hasTransitions).", + ); + } + lastTransitionRevision = assertIdentityTransitionsMonotone( + chunk.transitions, + lastTransitionRevision, + ); + receivedIdentityTransitions = true; + mergeImportResult( + result, + await importGraphData( + store, + graphDataForChunk(header, [], [], [], chunk.transitions), + { ...options, refreshStatistics: false }, + identityRestoreBaseline, + ), + ); + throwIfStreamChunkFailed(result, options); + break; + } } } if (header === undefined) { @@ -806,6 +1105,9 @@ export async function importGraphStream( "Graph interchange stream ended before emitting a header.", ); } + if (streamHeaderAnnouncesRestore(header) && !receivedIdentityTransitions) { + throw streamTransitionsMissingError(); + } // The trailing ANALYZE is a WRITE on the target connection, exactly like // the chunks were, so it belongs inside the lease rather than after it. // Releasing at the end of the chunk loop instead left this write outside @@ -957,6 +1259,57 @@ function throwIfStreamChunkFailed( ); } +/** + * Why {@link importGraphStream} would refuse to stream into `store` from an + * export read through `sourceBackend`, or `undefined` when it can stream. The + * one owner of that decision: the streamed import raises it before reading a + * chunk, and a caller choosing between the streamed and the materialized + * import (`graph-merge`'s clone) asks it first. + * + * - `"requiredComposition"`: the graph declares required-existence parts. + * Every chunk commits on its own and a part's attaching edge arrives in a + * later chunk than the part, so the required-existence check is exact only + * inside ONE transaction, which `importGraph` provides. + * - `"exportContention"`: the export would hold the connection the import + * writes through, as decided by `snapshotExportContention` (which abstains + * under `execution.interactiveTransactions: false`, where nothing is held + * open and refusing would refuse work that succeeds). + */ +export function streamedImportRefusal( + store: Store, + sourceBackend: GraphBackend | undefined, +): + | Readonly<{ reason: "requiredComposition" }> + | Readonly<{ reason: "exportContention"; detector: SnapshotExportContention }> + | undefined { + if (declaresRequiredCompositionParts(store.registry)) { + return { reason: "requiredComposition" }; + } + const detector = + sourceBackend === undefined ? undefined : ( + snapshotExportContention(sourceBackend, storeBackend(store)) + ); + return detector === undefined ? undefined : ( + { reason: "exportContention", detector } + ); +} + +function streamedRequiredCompositionRefusal( + graphId: string, +): ConfigurationError { + return new ConfigurationError( + "Streamed import cannot validate required-existence composition parts: each chunk commits separately, so a part would commit before the edge that attaches it.", + { + code: "IMPORT_STREAM_REQUIRED_COMPOSITION_UNSUPPORTED", + graphId, + }, + { + suggestion: + "Use importGraph(), which writes every node and edge in one transaction and checks each required part's attachment there.", + }, + ); +} + /** * Rejects an identity payload aimed at an identity-disabled graph BEFORE any * entity write. The internal identity import coordinator raises the same @@ -978,6 +1331,126 @@ function assertIdentityImportSupported( } } +/** + * Rejects an archival transitions/retention payload aimed at a store that + * keeps no TypeGraph transition log (history off, or engine-native recorded + * time) BEFORE any entity write. Whether a payload is a restore at all is + * {@link isIdentityTransitionRestore}'s decision — the same one the baseline + * read and the restore itself take — so a document this guard lets through is + * never refused later for being a restore. + * + * `importIdentityTransitionsAtTarget` (`store.ts`) raises the same + * `IdentityReplayError` (both gate on TypeGraph capture, never the public + * `historyEnabled` getter, which is also true for engine-native history) as + * a backstop, + * but only from the LAST import section — after nodes, edges and identity + * assertions have already committed durably on a non-transactional target, + * or after they have queued inside a transaction that a later error would + * still have to unwind. This guard runs first: from + * {@link importGraphData}'s upfront validation for the atomic path (which + * already has the full `transitions` array in hand), and from + * `importGraphStream`'s "header" chunk arm for the streaming path (which + * does not — by protocol the `identity-transitions` chunk is always last, + * so the header's `hasTransitions` announcement is the only pre-write + * signal available). + */ +function assertIdentityTransitionsRestoreSupported( + store: Store, + hasTransitions: boolean, + retention: Readonly<{ prunedBeforeRevision: number }> | undefined, +): void { + if ( + isIdentityTransitionRestore( + hasTransitions, + retention?.prunedBeforeRevision, + ) && + !storeCaptureEnabled(store) + ) { + throw identityTransitionLogUnavailableError( + store.graphId, + store.recordedTimeOwnership, + ); + } +} + +/** + * A `state` identity section carries current truth only, never explanation: + * one naming transitions or a retention watermark is a shape defect, refused + * before the import's first write (and, for a stream, at its header) rather + * than after the node and edge writes, where a backend without a transaction + * would already have committed them. + */ +function assertStateIdentityCarriesNoHistory( + mode: IdentityInterchangeMode, + hasTransitions: boolean, + retention: InterchangeIdentityRetention | undefined, +): void { + if (mode !== "state") return; + if (hasTransitions) { + throw new ValidationError( + "State identity import cannot carry archival transitions.", + { + issues: [ + { + path: "identity.transitions", + message: 'transitions is only valid for mode: "archival".', + code: "IDENTITY_STATE_IMPORT_TRANSITIONS", + }, + ], + }, + ); + } + if (retention !== undefined) { + throw new ValidationError( + "State identity import cannot carry a retention watermark.", + { + issues: [ + { + path: "identity.retention", + message: 'retention is only valid for mode: "archival".', + code: "IDENTITY_STATE_IMPORT_RETENTION", + }, + ], + }, + ); + } +} + +/** Whether a stream header announces an archival transition restore its last chunk must carry. */ +function streamHeaderAnnouncesRestore(header: GraphDataHeader): boolean { + return ( + header.identity !== undefined && + isIdentityTransitionRestore( + header.identity.hasTransitions === true, + header.identity.retention?.prunedBeforeRevision, + ) + ); +} + +/** + * The refusal for a stream whose header announced a transition restore but + * that ended without the `identity-transitions` chunk carrying it: a + * truncated stream, or a writer that left out the trailing chunk. Accepting + * it would drop the transitions and the retention watermark the header + * promised. Raised at the end of the stream, after the earlier chunks + * committed, because only the end shows the chunk is missing. + */ +function streamTransitionsMissingError(): ValidationError { + return new ValidationError( + "Graph interchange stream ended without the identity transitions its header announced (identity.hasTransitions / identity.retention).", + { + issues: [ + { + path: "identity.transitions", + message: + "The header announced an archival transition restore, but no identity-transitions chunk arrived.", + code: "IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING", + }, + ], + }, + ); +} + /** * Runtime-validates just the identity section of an otherwise pre-typed * `GraphData`. {@link importGraph} deliberately trusts the type for the @@ -994,11 +1467,7 @@ function validateIdentitySection(identity: GraphData["identity"]): void { throw new ValidationError( `Invalid identity interchange section: ${parsed.error.message}`, { - issues: parsed.error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code, - })), + issues: zodIssuesToValidationIssues(parsed.error), }, { cause: parsed.error }, ); @@ -1163,6 +1632,7 @@ async function importIdentitySection( target: IdentityTarget, graphId: string, identity: NonNullable, + restoreBaseline: IdentityRestoreBaseline | undefined, result: ImportResult, errors: ImportError[], ): Promise { @@ -1182,6 +1652,58 @@ async function importIdentitySection( result.identity.skipped += progress.skipped; errors.push(entry); } + await importIdentityTransitionsSection( + runtime, + target, + identity, + restoreBaseline, + ); +} + +/** + * Restores an archival payload's `transitions` section, if any — a verbatim + * restore (see "Archival transitions and the retention watermark" in the + * identity documentation): no closure repair, no renumbering, nothing reported back on + * {@link ImportResult} (there is no live-conflict dimension to count, unlike + * assertions). Never reached for `state` mode, which carries current truth + * only: {@link assertStateIdentityCarriesNoHistory} refused a `state` payload + * naming transitions or a watermark before the first write. + */ +async function importIdentityTransitionsSection( + runtime: ReturnType>, + target: IdentityTarget, + identity: NonNullable, + restoreBaseline: IdentityRestoreBaseline | undefined, +): Promise { + if (!identitySectionRestoresTransitions(identity)) return; + await runtime.importIdentityTransitionsAtTarget( + target, + identity.transitions ?? [], + identity.retention?.prunedBeforeRevision, + requireDefined( + restoreBaseline, + "an archival transition restore reached its write with no destination baseline", + ), + ); +} + +/** + * Whether an identity section restores transitions (see + * {@link isIdentityTransitionRestore}), and so whether the import owes the + * destination's {@link IdentityRestoreBaseline}: both the baseline read + * (before the import's first write) and the restore itself (after its last) + * decide on it, so neither runs without the other. + */ +function identitySectionRestoresTransitions( + identity: GraphData["identity"], +): boolean { + return ( + identity !== undefined && + isIdentityTransitionRestore( + (identity.transitions?.length ?? 0) > 0, + identity.retention?.prunedBeforeRevision, + ) + ); } function identityImportProgress( @@ -1212,11 +1734,7 @@ function validateStreamHeader(header: GraphDataHeader): void { throw new ValidationError( `Invalid graph interchange stream header: ${parsed.error.message}`, { - issues: parsed.error.issues.map((issue) => ({ - path: issue.path.join("."), - message: issue.message, - code: issue.code, - })), + issues: zodIssuesToValidationIssues(parsed.error), }, { cause: parsed.error }, ); @@ -1237,16 +1755,42 @@ function graphDataForChunk( nodes: GraphData["nodes"], edges: GraphData["edges"], assertions: readonly InterchangeIdentityAssertion[], + // Present ONLY for the "identity-transitions" chunk — its absence (as + // opposed to an empty array) is what tells `importIdentityTransitionsSection` + // this reconstructed document carries no transitions section at all, so the + // nodes/edges/identity(assertions) chunk calls never redundantly re-write + // the retention watermark. `header.identity.retention`, when the source + // carried one, is likewise omitted on those same three chunk calls (it is + // only ever attached alongside `transitions` below) for the same reason: + // left in, every chunk would reconstruct a `retention`-bearing identity + // section and re-invoke the watermark write once per chunk instead of once + // for the whole stream. + transitions?: readonly InterchangeIdentityTransition[], ): GraphData { const { identity, ...headerWithoutIdentity } = header; return { ...headerWithoutIdentity, nodes, edges, + // `identity.hasTransitions` is a streaming-header-only announcement (see + // `InterchangeIdentityHeaderSchema`) with no place on the reconstructed + // per-chunk document — deliberately dropped, not spread through. ...(identity === undefined ? {} : { - identity: { ...identity, assertions: [...assertions] }, + identity: { + profile: identity.profile, + mode: identity.mode, + assertions: [...assertions], + ...(transitions === undefined ? + {} + : { + transitions: [...transitions], + ...(identity.retention === undefined ? + {} + : { retention: identity.retention }), + }), + }, }), }; } @@ -1370,6 +1914,97 @@ export function buildEdgeSchemaMap( // Node Processing // ============================================================ +/** + * The required-existence parts in `data` that nothing can attach: no + * composition edge in the payload names the part, and the target holds no + * current attachment for it. Whatever the rest of the import writes, such a + * part ends with no whole, so it is decided before the first row instead of + * after the last edge — a row that is never written is never a row another + * row of the same import collides with. + * + * Deliberately narrower than "will be refused". A part some payload edge + * names, or the target already attaches, is judged on the written rows + * ({@link assertImportedRequiredPartsAttached}): whether that edge attaches + * it depends on rows this import has yet to write. + */ +async function readUnattachableRequiredParts( + target: WriteTarget, + graphId: string, + registry: KindRegistry, + data: GraphData, +): Promise> { + const namedByPayloadEdge = new Set(); + for (const edge of data.edges) { + const partSide = registry.compositionPartSide(edge.kind); + if (partSide === undefined) continue; + const { part } = orientCompositionEndpoints(partSide, edge.from, edge.to); + namedByPayloadEdge.add(makeNodeKey(part.kind, part.id)); + } + const candidates = new Map(); + for (const node of data.nodes) { + if (registry.compositionExistence(node.kind) !== "required") continue; + const key = makeNodeKey(node.kind, node.id); + if (namedByPayloadEdge.has(key)) continue; + candidates.set(key, { kind: node.kind, id: node.id }); + } + const unattachable = new Set(); + const parts = [...candidates.values()]; + for (const page of chunk(parts, COMPOSITION_ATTACHMENT_PAGE_SIZE)) { + const attachments = await readCompositionAttachmentsForPage( + registry, + target, + graphId, + page, + ); + for (const part of page) { + if (attachments.has(nodeRefKey(part))) continue; + unattachable.add(makeNodeKey(part.kind, part.id)); + } + } + return unattachable; +} + +/** + * The refusal for a row that would CREATE a part + * {@link readUnattachableRequiredParts} settled as unattachable — asked by + * both create legs at the point each has found no existing row, and before + * either probes a constraint on the part's behalf. A row for a part already + * on the target is not a create and is not refused here. + */ +function unattachableRequiredPartRefusal( + ledger: RequiredPartLedger, + node: InterchangeNode, +): ProcessResult | undefined { + if (!ledger.unattachableParts.has(makeNodeKey(node.kind, node.id))) { + return undefined; + } + return { + status: "error", + error: unattachedRequiredPartMessage({ kind: node.kind, id: node.id }), + }; +} + +/** The message a required part refused before its create, for want of any composition edge to attach it, is reported with. */ +function unattachedRequiredPartMessage(part: PendingRequiredPart): string { + return new CompositionExistenceError({ + partKind: part.kind, + partId: part.id, + situation: "create", + }).message; +} + +/** + * The message a required part this import created and then removed is + * reported with. It was written, but once every edge in the payload was it + * still held no live whole: its composition edge was refused, ended, or named + * a whole that is not live, or the whole holding it was itself removed. The + * create-situation text ("no `partOf` was given") would name only the first + * of those, and a payload that carried the edge would read it as wrong. + */ +function purgedRequiredPartMessage(part: PendingRequiredPart): string { + return `${part.kind} "${part.id}" was removed: its kind requires a whole (\`existence: "required"\`), and once every edge in the payload was written it held no live whole — its composition edge was refused, ended, or named a whole that is not live, or the whole that held it was removed.`; +} + async function processNodes( frame: ImportWriteFrame, graphId: string, @@ -1380,6 +2015,7 @@ async function processNodes( result: ImportResult, errors: ImportError[], importedNodeIds: ImportedNodes, + requiredPartLedger: RequiredPartLedger, ): Promise { const batchSize = options.batchSize; @@ -1395,6 +2031,7 @@ async function processNodes( result, errors, importedNodeIds, + requiredPartLedger, ); } } @@ -1467,9 +2104,31 @@ async function processNodeSlice( result: ImportResult, errors: ImportError[], importedNodeIds: ImportedNodes, + requiredPartLedger: RequiredPartLedger, ): Promise { const record = (node: InterchangeNode, outcome: ProcessResult): void => { recordNodeOutcome(node, outcome, result, errors, importedNodeIds); + // A freshly created required-existence part owes a live whole before + // this import commits — tracked here, judged by + // `assertImportedRequiredPartsAttached` once every edge is written. + if (registry.compositionExistence(node.kind) !== "required") return; + const key = makeNodeKey(node.kind, node.id); + if (outcome.status === "created") { + requiredPartLedger.pendingParts.set(key, { + kind: node.kind, + id: node.id, + }); + } else if ( + outcome.status === "updated" && + requiredPartLedger.pendingParts.has(key) + ) { + // A later row for a part this import created: counted as an update, + // and taken back with the part if the part is refused. + requiredPartLedger.partUpdates.set( + key, + (requiredPartLedger.partUpdates.get(key) ?? 0) + 1, + ); + } }; // Pass 1 (synchronous): kind + property + validity validation, and @@ -1609,6 +2268,7 @@ async function processNodeSlice( validatedProps: props, uniqueConstraints, windowFence: updateWindow.value, + windowEndFrame: frame, }); if (updateError === undefined) { // The update mutated the real backend's uniqueness rows directly; @@ -1635,6 +2295,15 @@ async function processNodeSlice( continue; } + const unattachable = unattachableRequiredPartRefusal( + requiredPartLedger, + node, + ); + if (unattachable !== undefined) { + record(node, unattachable); + continue; + } + // Both declared node constraints are probed here, per row, against the // pending-aware overlay — so an in-slice pair (a `Person` and a `Company` // sharing an id, two rows apart) is seen through `pendingNodes` and refuses @@ -1696,7 +2365,15 @@ async function processNodeSlice( for (const node of deferred) { record( node, - await processNode(frame, graphId, registry, node, schemas, options), + await processNode( + frame, + graphId, + registry, + node, + schemas, + options, + requiredPartLedger, + ), ); } } @@ -1808,12 +2485,18 @@ function isDeclaredConstraintRefusal( | UniquenessError | DisjointError | CardinalityError - | EdgeMatchIdentityConflictError { + | CompositionError + | CompositionExistenceError + | EdgeMatchIdentityConflictError + | EdgeAcyclicityError { return ( error instanceof UniquenessError || error instanceof DisjointError || error instanceof CardinalityError || - error instanceof EdgeMatchIdentityConflictError + error instanceof CompositionError || + error instanceof CompositionExistenceError || + error instanceof EdgeMatchIdentityConflictError || + error instanceof EdgeAcyclicityError ); } @@ -1922,6 +2605,69 @@ async function catchEdgeCreateRefusalWithSavepoint( const NODE_UPDATE_TARGET_CHANGED_CODE = "INTERCHANGE_NODE_UPDATE_TARGET_CHANGED"; +/** The slice of an import write frame a node update that states `validTo` reads. */ +type NodeWindowEndFrame = Pick< + ImportWriteFrame, + | "target" + | "allocateEdgeSavepoint" + | "transactionMode" + | "statementExecution" + | "applyNodeWindowEnd" +>; + +/** + * Runs one node update's row write under the identity half of the window end + * its document states, so the two stand or fall together. + * + * The identity half runs first, as every store update runs it: a `validTo` may + * not end a node's window under an identity assertion that would outlive it, + * and an end that does land is a membership boundary the transition log + * records. A store update that refuses aborts its transaction, taking that + * note with it. An import row that refuses is a per-row error and the + * transaction goes on, so a row that states an end and then fails (a + * uniqueness refusal the row's own claim raises, a target that changed) would + * otherwise leave a `window-end` transition for an end that never landed. The + * pair therefore runs behind a savepoint, which restores the recorded state it + * noted along with the SQL. + * + * A row that states no end has no identity half and takes no savepoint. Where + * the target takes none either (no transaction, or no raw statement member) + * the pair runs unguarded; no transaction means no recorded capture to leave a + * note in. + */ +async function updateNodeUnderWindowEnd( + frame: NodeWindowEndFrame, + node: InterchangeNode, + write: () => Promise, +): Promise { + const applyWindowEnd = (): Promise => + frame.applyNodeWindowEnd({ + kind: node.kind, + id: node.id, + ...(node.validTo !== undefined && { validTo: node.validTo }), + }); + if ( + node.validTo === undefined || + frame.transactionMode === "none" || + !frame.statementExecution.supported + ) { + await applyWindowEnd(); + return write(); + } + return runRecordedTransactionSavepoint( + frame.target, + frame.statementExecution, + frame.allocateEdgeSavepoint(), + async () => { + await applyWindowEnd(); + const rowError = await write(); + return rowError === undefined ? + { action: "release", value: undefined } + : { action: "rollback", value: rowError, cause: new Error(rowError) }; + }, + ); +} + /** * Updates an existing node under the effective validity lower bound this import * checked it carries, and reports a write that landed on nothing as a per-row @@ -1958,6 +2704,7 @@ async function updateImportedNode( validatedProps: Record; uniqueConstraints: readonly UniqueConstraint[]; windowFence: ValidityLowerBoundFence; + windowEndFrame: NodeWindowEndFrame; }>, ): Promise { try { @@ -1968,24 +2715,31 @@ async function updateImportedNode( // path's claim set carries uniqueness entries only. Widening the guard // therefore changes nothing here today; it is listed because a changed // contract re-audits its consumers. - const result = await catchDeclaredConstraintRefusal(() => - // The fence is an ARGUMENT the method requires, not a spread the call - // site may forget: the guard's verdict travels to the statement that - // honors it, and an empty fence (`{}`) is the stated decision to assert - // nothing rather than an omission. - session.reviseNode( - { - existing: args.existing, - schema: args.schema, - validatedProps: args.validatedProps, - uniqueConstraints: args.uniqueConstraints, - ...(node.validTo !== undefined && { validTo: node.validTo }), - }, - { validityLowerBound: args.windowFence }, - ), + return await updateNodeUnderWindowEnd( + args.windowEndFrame, + node, + async () => { + const result = await catchDeclaredConstraintRefusal(() => + // The fence is an ARGUMENT the method requires, not a spread the call + // site may forget: the guard's verdict travels to the statement that + // honors it, and an empty fence (`{}`) is the stated decision to + // assert nothing rather than an omission. + session.reviseNode( + { + existing: args.existing, + schema: args.schema, + validatedProps: args.validatedProps, + uniqueConstraints: args.uniqueConstraints, + ...(node.validTo !== undefined && { validTo: node.validTo }), + }, + { validityLowerBound: args.windowFence }, + ), + ); + return result.ok ? undefined : result.error; + }, ); - return result.ok ? undefined : result.error; } catch (error) { + if (error instanceof IdentityEndpointValidityError) return error.message; if ( !(error instanceof DatabaseOperationError) || error.details.reason !== "no_row_returned" @@ -2134,11 +2888,29 @@ function edgeIdentityConflict( * abort an import whose earlier rows are already written. */ async function updateImportedEdge( - session: WriteSession, + frame: ImportWriteFrame, + graphId: string, + registry: KindRegistry, edge: InterchangeEdge, + existing: EdgeRow, props: Readonly>, windowFence: ValidityLowerBoundFence, ): Promise { + // The same refusal a store edge update raises before it ends an open + // window: a document's `validTo` must not detach a live required part. + if (edgeWriteEndsOpenWindow(existing, edge.validTo)) { + try { + await assertCompositionExistencePreserved( + { graphId, registry, lock: frame.lock }, + existing, + frame.target, + ); + } catch (error) { + if (!(error instanceof CompositionExistenceError)) throw error; + return `${error.code}: ${error.message}`; + } + } + const { session } = frame; try { // Both halves of the predicate are stated as FENCES — required keys of the // method's fence record, applied into the statement's own `WHERE` by their @@ -2150,6 +2922,9 @@ async function updateImportedEdge( id: edge.id, props, ...(edge.validTo !== undefined && { validTo: edge.validTo }), + // Import re-admits no row to a counted population here (see below), + // so it owes no claim. + claims: [], }, { validityLowerBound: windowFence, @@ -2294,6 +3069,36 @@ function validateValidityWindow( }); } +/** + * The composition window rule (`assertCompositionWindowCurrent`) for one + * document edge, as the per-row error import reports: a composition edge may + * not start or end later than the import's own clock. Judged on the + * document's stated bounds, so it covers the insert and both update legs + * alike — neither applies a bound the document does not state. + */ +function importedCompositionWindowError( + registry: KindRegistry, + edge: InterchangeEdge, +): string | undefined { + const subject = compositionWindowSubject(registry, { + kind: edge.kind, + id: edge.id, + fromKind: edge.from.kind, + fromId: edge.from.id, + toKind: edge.to.kind, + toId: edge.to.id, + }); + if (subject === undefined) return undefined; + const refusal = compositionWindowRefusal( + subject, + { validFrom: edge.validFrom, validTo: edge.validTo }, + nowIso(), + ); + return refusal === undefined ? undefined : ( + `${refusal.code}: ${refusal.message}` + ); +} + /** * The UPDATE-shaped half, for `onConflict: "update"` against a row that already * exists. All four update legs — batched and sequential, nodes and edges — send @@ -2353,6 +3158,7 @@ async function processNode( node: InterchangeNode, schemas: ReadonlyMap, options: ResolvedImportOptions, + requiredPartLedger: RequiredPartLedger, ): Promise { // Validate kind exists const schemaEntry = schemas.get(node.kind); @@ -2423,6 +3229,7 @@ async function processNode( validatedProps: propsResult.data, uniqueConstraints, windowFence: updateWindow.value, + windowEndFrame: frame, }); if (updateError !== undefined) { return { status: "error", error: updateError }; @@ -2432,6 +3239,12 @@ async function processNode( } } + const unattachable = unattachableRequiredPartRefusal( + requiredPartLedger, + node, + ); + if (unattachable !== undefined) return unattachable; + // Create new node. Pre-check both declared node constraints (as the // collection create does) so a conflict is a per-row error rather than an // orphaned node row, then create through the session, which applies the claims @@ -2551,6 +3364,7 @@ async function processEdges( result: ImportResult, errors: ImportError[], importedNodeIds: ImportedNodes, + requiredPartLedger: RequiredPartLedger, ): Promise { const batchSize = options.batchSize; // A slice flush makes its accepted keys visible to later database reads, but @@ -2573,8 +3387,203 @@ async function processEdges( errors, importedNodeIds, pendingMatchIdentityOwners, + requiredPartLedger, + ); + } +} + +/** + * The end-of-import existence assertion: every required-existence part this + * import created must hang from a live whole once every node AND every edge + * of the payload is written. Parts that do not are refused per row and their + * node rows removed in the same transaction — no orphan survives. + * + * Every pending part is judged, through the one owner the + * `compositionExistence` audit reads ({@link readCompositionPartStandings}): + * an accepted composition edge is not evidence on its own. An edge whose + * window does not attach its part (an ended `oneActive` window), or whose + * whole is absent or tombstoned (a ghost endpoint admitted under + * `validateReferences: false`), leaves the part exactly as unattached as no + * edge at all, and is refused the same way. + * + * The assertion runs to a FIXED POINT. Purging a part removes every edge + * touching it, including the composition edge that attached a required part + * BENEATH it which an earlier pass already accepted; those descendants are + * judged again, and again refused if nothing else holds them up. + * + * A refused part's row is removed through the session's ordinary hard-delete + * step (the row was created THIS import, so `session.purgeNode`'s uniqueness + * release and embedding cleanup are exactly what an ordinary `hardDelete` + * would run). Its delete-behavior enforcement is explicitly turned OFF + * (`enforceDeleteBehavior: false`): `hardDeleteNode` + * (`src/backend/drizzle/operation-backend-core.ts`) unconditionally deletes + * every edge connected to the node before deleting the node row itself, so + * nothing is left dangling, and passing the default policy here would let a + * part's ordinary edge throw `RestrictedDeleteError` PAST this function, + * aborting the whole import instead of refusing this one row. + * + * The part row is new, but an edge touching it need not be: an earlier load + * with `validateReferences: false` can have written an edge ahead of the + * endpoint it names. Such an edge is removed with the part like any other. + * + * The result describes what is committed. The purged node comes off + * `result.nodes.created`, and off `result.nodes.updated` once for each later + * row of this import that updated it. EVERY edge the purge removed is + * reported as its own per-row error, whether or not this import wrote it, + * and comes off `result.edges.created` / `result.edges.updated` exactly as + * often as this import counted it there. + * + * This runs AFTER `foldImportedIdentityNodes` already folded the batch's new + * node references into identity — a purged part's identity membership is + * undone here, through `runtime.detachDeletedImportedIdentityNode`, the same + * `identity.detachDeleted(..., "hard")` `executeNodeHardDelete` + * (`src/store/operations/node-operations.ts`) issues for an ordinary hard + * delete. That call, too, is inside the `try`: an identity-layer failure on + * an already-purged row must not abort every other accepted row either. + */ +async function assertImportedRequiredPartsAttached( + frame: ImportWriteFrame, + graphId: string, + registry: KindRegistry, + runtime: ReturnType>, + ledger: RequiredPartLedger, + result: ImportResult, + importedNodeIds: ImportedNodes, + errors: ImportError[], +): Promise { + // Accepted parts by the whole that holds them, so a whole purged later + // re-queues exactly the parts its purge detached. + const acceptedByWhole = new Map(); + let candidates: readonly PendingRequiredPart[] = [ + ...ledger.pendingParts.values(), + ]; + while (candidates.length > 0) { + const purgedKeys: string[] = []; + for (const page of chunk(candidates, COMPOSITION_ATTACHMENT_PAGE_SIZE)) { + const standings = await readCompositionPartStandings( + registry, + frame.target, + graphId, + page, + frame.batchPointRead, + ); + for (const { part, whole } of standings) { + if (whole !== undefined) { + const wholeKey = makeNodeKey(whole.kind, whole.id); + const held = acceptedByWhole.get(wholeKey) ?? []; + acceptedByWhole.set(wholeKey, held); + held.push(part); + continue; + } + const purged = await purgeUnattachedRequiredPart( + frame, + graphId, + runtime, + part, + ledger, + result, + importedNodeIds, + errors, + ); + if (purged) purgedKeys.push(makeNodeKey(part.kind, part.id)); + } + } + candidates = purgedKeys.flatMap((key) => { + const detached = acceptedByWhole.get(key) ?? []; + acceptedByWhole.delete(key); + return detached; + }); + } +} + +/** + * Removes one refused required part and settles the import's result for it. + * Returns whether the row was removed; a failed purge is reported as that + * row's own error and leaves the counts alone. + * + * Once the row is gone its identity detach is owed, not optional: a failure + * there is not this row's outcome but a graph whose closure still names a + * deleted node, so it propagates and fails the import, exactly as a store + * delete whose detach fails does. Catching it here would also report the part + * as kept while its row and edges were already removed. + */ +async function purgeUnattachedRequiredPart( + frame: ImportWriteFrame, + graphId: string, + runtime: ReturnType>, + part: PendingRequiredPart, + ledger: RequiredPartLedger, + result: ImportResult, + importedNodeIds: ImportedNodes, + errors: ImportError[], +): Promise { + const registration = frame.graph.nodes[part.kind]; + if (registration === undefined) return false; + // Read BEFORE the purge, which removes them with the node: every edge + // touching the part, whichever import wrote it. + const removedEdges = await frame.target.findEdgesConnectedTo({ + graphId, + nodeKind: part.kind, + nodeId: part.id, + }); + try { + await frame.session.purgeNode( + { + kind: part.kind, + id: part.id, + schema: registration.type.schema, + onDelete: registration.onDelete, + }, + { enforceDeleteBehavior: false }, ); + } catch (error: unknown) { + errors.push({ + entityType: "node", + kind: part.kind, + id: part.id, + error: + error instanceof Error ? + error.message + : `Failed to purge unattached required-existence part: ${String(error)}`, + }); + return false; } + await runtime.detachDeletedImportedIdentityNode(frame.target, { + kind: part.kind, + id: part.id, + }); + const partKey = makeNodeKey(part.kind, part.id); + result.nodes.created--; + result.nodes.updated -= ledger.partUpdates.get(partKey) ?? 0; + importedNodeIds.delete(partKey); + ledger.pendingParts.delete(partKey); + ledger.partUpdates.delete(partKey); + errors.push({ + entityType: "node", + kind: part.kind, + id: part.id, + error: purgedRequiredPartMessage(part), + }); + for (const edge of removedEdges) { + const written = ledger.edgeWrites.get(edge.id); + result.edges.created -= written?.created ?? 0; + result.edges.updated -= written?.updated ?? 0; + ledger.edgeWrites.delete(edge.id); + // An edge this import never created was on the target before it ran — + // written ahead of its endpoint under `validateReferences: false`. It + // goes with the part all the same, so it is named all the same. + const origin = + (written?.created ?? 0) > 0 ? + "" + : " The edge was on the target before this import; re-import it together with the part and the part's composition edge."; + errors.push({ + entityType: "edge", + kind: edge.kind, + id: edge.id, + error: `Edge "${edge.id}" was removed with its endpoint ${part.kind} "${part.id}", a required-existence part this import could not attach to a live whole.${origin}`, + }); + } + return true; } function recordEdgeOutcome( @@ -2616,17 +3625,19 @@ type EdgeImportCandidate = Readonly<{ type PreparedEdgeImportCreate = Readonly<{ candidate: EdgeImportCandidate; params: InsertEdgeParams; - cardinality: Cardinality; + declarations: EdgeCardinalityDeclarations; + work: EdgeInsertWork; }>; /** - * THE insert params one accepted edge candidate carries, built once and read by - * three consumers: the in-slice cardinality registration, the claim, and the - * flush. The candidate loop needs them before the flush does — that is what + * THE insert params and insert work one accepted edge candidate carries, built + * once and read by every consumer: the in-slice cardinality registration, the + * probe, the claim, and the flush. The candidate loop needs them before the flush does — that is what * lets an in-slice collision refuse the ROW — so they cannot be materialized * only at the flush the way the pre-fence path did. */ function prepareEdgeImportCreate( + registry: KindRegistry, graphId: string, candidate: EdgeImportCandidate, registration: EdgeRegistration, @@ -2659,7 +3670,8 @@ function prepareEdgeImportCreate( return { candidate, params, - cardinality: registration.cardinality ?? "many", + declarations: registration, + work: importEdgeInsertWork(registry, params, registration), }; } @@ -2836,9 +3848,24 @@ async function processEdgeSlice( errors: ImportError[], importedNodeIds: ImportedNodes, pendingMatchIdentityOwners: Set, + requiredPartLedger: RequiredPartLedger, ): Promise { + // Only a graph that can purge a part ever reads how its edges were counted. + const tracksEdgeWrites = requiredPartLedger.declaresRequiredParts; const record = (edge: InterchangeEdge, outcome: ProcessResult): void => { recordEdgeOutcome(edge, outcome, result, errors); + if ( + !tracksEdgeWrites || + (outcome.status !== "created" && outcome.status !== "updated") + ) { + return; + } + const counts = requiredPartLedger.edgeWrites.get(edge.id) ?? { + created: 0, + updated: 0, + }; + counts[outcome.status] += 1; + requiredPartLedger.edgeWrites.set(edge.id, counts); }; // The store's own in-batch cardinality accounting, constructed once per @@ -2856,9 +3883,15 @@ async function processEdgeSlice( // Pass 1 (synchronous): kind, endpoint-kind, endpoint-assignability, // property, and validity validation, plus in-slice duplicate deferral. const candidates: EdgeImportCandidate[] = []; - const deferred: InterchangeEdge[] = []; - const seenIds = new Set(); - for (const edge of batch) { + // Rows written one at a time after the flush, each with its slice position. + // They are deferred from two places — a repeated id here, an acyclic kind + // in the routing loop below — and written in DOCUMENT order, not deferral + // order: the second row naming an id must observe the first one's row. + const deferred: Readonly<{ edge: InterchangeEdge; sliceIndex: number }>[] = + []; + // The slice position of each id's FIRST occurrence — the one candidate. + const firstSliceIndexById = new Map(); + for (const [sliceIndex, edge] of batch.entries()) { const schemaEntry = edgeSchemas.get(edge.kind); if (!schemaEntry) { record(edge, { @@ -2901,16 +3934,18 @@ async function processEdgeSlice( record(edge, { status: "error", error: propsResult.error }); continue; } - const validityError = validateValidityWindow(edge); + const validityError = + validateValidityWindow(edge) ?? + importedCompositionWindowError(registry, edge); if (validityError !== undefined) { record(edge, { status: "error", error: validityError }); continue; } - if (seenIds.has(edge.id)) { - deferred.push(edge); + if (firstSliceIndexById.has(edge.id)) { + deferred.push({ edge, sliceIndex }); continue; } - seenIds.add(edge.id); + firstSliceIndexById.set(edge.id, sliceIndex); candidates.push({ edge, props: propsResult.data }); } @@ -3024,8 +4059,11 @@ async function processEdgeSlice( break; } const updateError = await updateImportedEdge( - frame.session, + frame, + graphId, + registry, edge, + existing, props, updateWindow.value, ); @@ -3042,6 +4080,7 @@ async function processEdgeSlice( const preparation = await catchEdgeCreateRefusal(() => prepareEdgeImportCreate( + registry, graphId, candidate, requireDefined(edgeSchemas.get(edge.kind)).registration, @@ -3069,7 +4108,7 @@ async function processEdgeSlice( // read keeps the existing cached singleton fallback in the validation seam. const cardinalityCounts = new Map< string, - Parameters[0] + Parameters[0] >(); const uniquePairs = new Map< string, @@ -3080,52 +4119,59 @@ async function processEdgeSlice( Readonly<{ kind: string; id: string }> >(); const edgeKinds = new Set(); - for (const { candidate, cardinality } of preparedCreates) { + for (const { candidate, declarations } of preparedCreates) { const { edge } = candidate; - if (cardinality === "many") continue; - edgeKinds.add(edge.kind); - const sourceKey = encodeTupleKey([edge.from.kind, edge.from.id]); - sourceEndpoints.set(sourceKey, { - kind: edge.from.kind, - id: edge.from.id, - }); - if (cardinality === "unique") { + for (const ref of edgeCardinalityAxisReferences(declarations)) { + const spec = edgeCardinalitySpec(ref); + // The priming read is from-side. A target count cannot be seeded from + // it; that axis keeps the per-row probe. + if (spec.keyShape === "to") continue; + edgeKinds.add(edge.kind); + const sourceKey = encodeTupleKey([edge.from.kind, edge.from.id]); + sourceEndpoints.set(sourceKey, { + kind: edge.from.kind, + id: edge.from.id, + }); + if (spec.keyShape === "fromAndTo") { + const params = { + graphId, + edgeKind: edge.kind, + fromKind: edge.from.kind, + fromId: edge.from.id, + toKind: edge.to.kind, + toId: edge.to.id, + } satisfies Parameters[0]; + uniquePairs.set( + encodeTupleKey([ + params.edgeKind, + params.fromKind, + params.fromId, + params.toKind, + params.toId, + ]), + params, + ); + continue; + } const params = { graphId, edgeKind: edge.kind, - fromKind: edge.from.kind, - fromId: edge.from.id, - toKind: edge.to.kind, - toId: edge.to.id, - } satisfies Parameters[0]; - uniquePairs.set( + endpoint: spec.keyShape, + endpointKind: edge.from.kind, + endpointId: edge.from.id, + activeOnly: spec.holderLiveness === "liveAndActive", + } satisfies Parameters[0]; + cardinalityCounts.set( encodeTupleKey([ params.edgeKind, - params.fromKind, - params.fromId, - params.toKind, - params.toId, + params.endpoint, + params.endpointKind, + params.endpointId, + params.activeOnly ? "1" : "0", ]), params, ); - continue; } - const params = { - graphId, - edgeKind: edge.kind, - fromKind: edge.from.kind, - fromId: edge.from.id, - activeOnly: cardinality === "oneActive", - } satisfies Parameters[0]; - cardinalityCounts.set( - encodeTupleKey([ - params.edgeKind, - params.fromKind, - params.fromId, - params.activeOnly ? "1" : "0", - ]), - params, - ); } const setRead = frame.target.findEdgesByHeterogeneousEndpointSet; if (setRead !== undefined && sourceEndpoints.size > 0 && edgeKinds.size > 0) { @@ -3153,23 +4199,33 @@ async function processEdgeSlice( const accepted: PreparedEdgeImportCreate[] = []; for (const prepared of preparedCreates) { - const { candidate, params, cardinality } = prepared; + const { candidate, params, declarations, work } = prepared; const { edge } = candidate; + // Acyclic-kind rows never join the batched flush below: the + // in-batch overlay `cardinalityValidationBackend` intercepts + // `countEdgesFrom` / `edgeExistsBetween`, not a recursive `execute` + // statement, so it cannot account for an in-batch cycle. Each instead + // joins the same sequential write-as-you-go fallback in-slice duplicate + // ids already use (`deferred`, processed via `processEdge` below), + // where the row lands inside the transaction before the next row's + // probe runs and the database itself carries the in-batch state. + if (edgeKindIsInAcyclicRelation(frame.graph, registry, edge.kind)) { + deferred.push({ + edge, + sliceIndex: requireDefined(firstSliceIndexById.get(edge.id)), + }); + continue; + } + // The cardinality probe, per row and against the pending-aware overlay, so - // two `cardinality: "one"` edges from one source IN ONE SLICE refuse the - // second row instead of both passing and colliding at the batch claim — - // which runs once for the whole slice, outside every per-row recovery. + // two edges declaring the same axis from/to one node IN ONE SLICE refuse + // the second row instead of both passing and colliding at the batch claim + // — which runs once for the whole slice, outside every per-row recovery. const cardinalityResult = await catchDeclaredConstraintRefusal(() => - checkCardinalityConstraint( + checkEdgeCardinalityConstraints( { graphId, registry, backend: cardinalityValidationBackend }, - edge.kind, - cardinality, - edge.from.kind, - edge.from.id, - edge.to.kind, - edge.to.id, - edge.validTo, + work.claims, ), ); if (!cardinalityResult.ok) { @@ -3196,7 +4252,7 @@ async function processEdgeSlice( }); continue; } - registerPendingEdgeForCardinality(params, cardinality); + registerPendingEdgeForCardinality(params, declarations); if (pendingIdentityKey !== undefined) { pendingMatchIdentityOwners.add(pendingIdentityKey); } @@ -3214,17 +4270,12 @@ async function processEdgeSlice( // there aborts the import, which is the honest outcome when the graph // changed under a bulk load and is why the per-row probe above exists for // everything that is not concurrent. - const acceptedWork = accepted.map((prepared) => - importEdgeInsertWork(prepared.params, prepared.cardinality), - ); + const acceptedWork = accepted.map((prepared) => prepared.work); const retryAcceptedIndividually = async (): Promise => { for (const prepared of accepted) { const { result: rowResult } = await catchEdgeCreateRefusalWithSavepoint( frame, - () => - frame.session.createEdge( - importEdgeInsertWork(prepared.params, prepared.cardinality), - ), + () => frame.session.createEdge(prepared.work), ); if (rowResult.ok) { record(prepared.candidate.edge, { status: "created" }); @@ -3240,7 +4291,7 @@ async function processEdgeSlice( ({ params }) => params.matchIdentity !== undefined, ).length; const hasDurableIdentity = durableIdentityCount > 0; - const hasClaims = acceptedWork.some(({ claim }) => claim !== undefined); + const hasClaims = acceptedWork.some(({ claims }) => claims.length > 0); if ( hasDurableIdentity && durableIdentityCount === accepted.length && @@ -3311,7 +4362,9 @@ async function processEdgeSlice( } } - for (const edge of deferred) { + for (const { edge } of deferred.toSorted( + (left, right) => left.sliceIndex - right.sliceIndex, + )) { record( edge, await processEdge( @@ -3409,7 +4462,9 @@ async function processEdge( return { status: "error", error: propsResult.error }; } - const validityError = validateValidityWindow(edge); + const validityError = + validateValidityWindow(edge) ?? + importedCompositionWindowError(registry, edge); if (validityError !== undefined) { return { status: "error", error: validityError }; } @@ -3456,8 +4511,11 @@ async function processEdge( return { status: "error", error: updateWindow.error }; } const updateError = await updateImportedEdge( - frame.session, + frame, + graphId, + registry, edge, + existing, propsResult.data, updateWindow.value, ); @@ -3471,6 +4529,7 @@ async function processEdge( const preparation = await catchEdgeCreateRefusal(() => prepareEdgeImportCreate( + registry, graphId, { edge, props: propsResult.data }, schemaEntry.registration, @@ -3484,26 +4543,52 @@ async function processEdge( // claims before it writes — the same order the collection create uses. No // pending state is needed: this path writes each edge as it goes, so the next // row's probe reads the previous one from the same transaction. - const { cardinality, params } = preparation.value; + const { params, work } = preparation.value; const cardinalityResult = await catchDeclaredConstraintRefusal(() => - checkCardinalityConstraint( + checkEdgeCardinalityConstraints( { graphId, registry, backend: frame.target }, - edge.kind, - cardinality, - edge.from.kind, - edge.from.id, - edge.to.kind, - edge.to.id, - edge.validTo, + work.claims, ), ); if (!cardinalityResult.ok) { return { status: "error", error: cardinalityResult.error }; } + // Acyclicity is checked AFTER the row lands, inside the same + // savepoint-guarded attempt as the insert: import writes each row as it + // goes, so this row's own probe already sees the previous rows in this + // same transaction, and a cycle rolls back to the savepoint and reports as + // this row's per-row error rather than aborting the whole chunk. const { result: createResult } = await catchEdgeCreateRefusalWithSavepoint( frame, - () => frame.session.createEdge(importEdgeInsertWork(params, cardinality)), + async () => { + const row = await frame.session.createEdge(work); + if (edgeKindIsInAcyclicRelation(frame.graph, registry, edge.kind)) { + await assertEdgeRelationsAcyclic( + acyclicityProbeContext({ + graphId, + graph: frame.graph, + registry, + schema: frame.schema, + target: frame.target, + lock: frame.lock, + operation: "importGraph", + }), + [ + { + edgeId: params.id, + edgeKind: params.kind, + fromKind: params.fromKind, + fromId: params.fromId, + toKind: params.toKind, + toId: params.toId, + validTo: params.validTo, + }, + ], + ); + } + return row; + }, ); if (!createResult.ok) { return { status: "error", error: createResult.error }; @@ -3513,20 +4598,23 @@ async function processEdge( } /** - * The insert unit for one imported edge: the row params and the cardinality + * The insert unit for one imported edge: the row params and every cardinality * claim the row owes. * * ONE owner, shared by the batched slice and the per-row fallback, for the same * reason {@link importNodeCreateWork} is one: two spellings of the same row are - * two spellings that can drift. The claim is built here and ISSUED by the + * two spellings that can drift. The claims are built here and ISSUED by the * session, which is the only handle in this module that reaches a write member. */ function importEdgeInsertWork( + registry: KindRegistry, params: InsertEdgeParams, - cardinality: Cardinality, + declarations: EdgeCardinalityDeclarations, ): EdgeInsertWork { - const claim = edgeCardinalityClaim(cardinality, params); - return { params, claim }; + return { + params, + claims: edgeInsertClaims(registry, declarations, params), + }; } // ============================================================ diff --git a/packages/typegraph/src/interchange/index.ts b/packages/typegraph/src/interchange/index.ts index 9eb9ad759..b5af9d9cd 100644 --- a/packages/typegraph/src/interchange/index.ts +++ b/packages/typegraph/src/interchange/index.ts @@ -69,7 +69,11 @@ export { type InterchangeIdentity, type InterchangeIdentityAssertion, InterchangeIdentityAssertionSchema, + type InterchangeIdentityRetention, + InterchangeIdentityRetentionSchema, InterchangeIdentitySchema, + type InterchangeIdentityTransition, + InterchangeIdentityTransitionSchema, type InterchangeNode, InterchangeNodeSchema, type UnknownPropertyStrategy, diff --git a/packages/typegraph/src/interchange/trusted-import.ts b/packages/typegraph/src/interchange/trusted-import.ts index c11332017..48cded81c 100644 --- a/packages/typegraph/src/interchange/trusted-import.ts +++ b/packages/typegraph/src/interchange/trusted-import.ts @@ -11,6 +11,8 @@ import { resolveGraphVectorSlots } from "../core/embedding"; import { getSearchableFields } from "../core/searchable"; import type { EdgeRegistration } from "../core/types"; import { TrustedImportError } from "../errors"; +import { acyclicEdgeKinds } from "../store/acyclicity"; +import { edgeCardinalityAxisReferences } from "../store/claims/edge-claims"; import { resolveEdgeMatchIdentityStorage } from "../store/edge-match-key"; import { storeBackend, storeCaptureEnabled } from "../store/runtime-port"; import type { Store } from "../store/store"; @@ -41,6 +43,45 @@ function rejectUnsupportedStoreFeatures( kind, ); } + // Composition first, ahead of the cardinality refusal below. Every + // composition edge kind must declare a part-side cardinality, so the + // cardinality check would otherwise always answer for it — with a reason a + // caller cannot branch on as composition, and advice (declare the kind + // unconstrained) the ontology refuses. + const compositionKinds = store.registry.compositionEdgeKinds(); + if (compositionKinds.length > 0) { + throw new TrustedImportError( + "Trusted import does not enforce composition (`partOf`/`hasPart`): it writes no composition claim row and does not check the acyclicity relation, so a graph loaded this way can carry a part with two live wholes, or a part/whole cycle. store.verifyConstraintFences() reports either afterward.", + "composition_unsupported", + { graphId: store.graphId, edgeKinds: [...compositionKinds] }, + { + suggestion: + 'Use importGraphStream(), or importGraph() when a pair declares `existence: "required"`; both maintain the composition claim and its acyclicity check.', + }, + ); + } + // Trusted import maintains no edge claim: `trustedImportGraphStream` writes + // through `session.insertEdges` beneath the claim path entirely, so a + // declared axis — source OR target — would be accepted here and written + // unfenced. One reason for both directions: the restriction is "this path + // maintains no claims", which is direction-blind. + const constrainedEdgeKinds = Object.entries(store.graph.edges) + .filter( + ([, registration]) => + edgeCardinalityAxisReferences(registration).length > 0, + ) + .map(([kind]) => kind); + if (constrainedEdgeKinds.length > 0) { + throw new TrustedImportError( + "Trusted import does not maintain edge cardinality claims.", + "cardinality_unsupported", + { graphId: store.graphId, edgeKinds: constrainedEdgeKinds }, + { + suggestion: + 'Use importGraph(), or declare the edge kind(s) unconstrained ("many" on both axes) and enforce the limit in application code.', + }, + ); + } if (storeCaptureEnabled(store)) { // Trusted import writes rows directly, bypassing the node/edge- // operations layer that TypeGraph's own capture uses to populate the @@ -80,6 +121,17 @@ function rejectUnsupportedStoreFeatures( ); } + // No composition kind reaches here (refused above), so every acyclic edge + // kind left is one declared `acyclic: true`. + const acyclicKinds = acyclicEdgeKinds(store.graph, store.registry); + if (acyclicKinds.length > 0) { + throw new TrustedImportError( + "Trusted import does not enforce edge acyclicity: it holds one transaction for the whole stream, validates nothing by contract, and has no per-row point to probe the relation at.", + "acyclicity_unsupported", + { graphId: store.graphId, edgeKinds: acyclicKinds }, + ); + } + const searchableKinds = Object.values(store.graph.nodes) .filter( (registration) => @@ -392,6 +444,16 @@ async function consumeTrustedChunks( "Use importGraphStream() for an export that carries identity truth.", ); } + case "identity-transitions": { + // Same refusal as "identity" above, and unreachable in practice: the + // header check earlier in this loop already refuses a stream whose + // header declares `identity` at all, and archival export never emits + // this chunk type without one. + throw invalidStream( + "Trusted graph interchange import does not support identity transitions. " + + "Use importGraphStream() for an archival export that carries identity history.", + ); + } } } @@ -407,8 +469,8 @@ async function consumeTrustedChunks( * Atomically imports a header-first stream into a fresh, dedicated database. * * This is an intentionally trusted path. It checks stream ordering and kind - * names, but it does not validate properties, references, cardinality, or - * conflicts. The caller must guarantee those invariants. Use + * names, but it does not validate properties, references, cardinality, + * composition, or conflicts. The caller must guarantee those invariants. Use * {@link importGraphStream} for untrusted data. * * Trusted of the DATA, not of the connection: this holds ONE write transaction diff --git a/packages/typegraph/src/interchange/types.ts b/packages/typegraph/src/interchange/types.ts index c3bfe4fd9..1a5e3fc6e 100644 --- a/packages/typegraph/src/interchange/types.ts +++ b/packages/typegraph/src/interchange/types.ts @@ -9,7 +9,10 @@ */ import { z } from "zod"; +import { isRecordedRevision } from "../core/temporal"; +import type { IdentityTransitionCause } from "../identity"; import { isCanonicalIsoDate } from "../utils/date"; +import { type Assert, type Equal } from "../utils/type-assert"; // ============================================================ // Format Version @@ -20,21 +23,23 @@ import { isCanonicalIsoDate } from "../utils/date"; * * Read-side compatibility policy: the format is versioned separately from what * it *accepts*. A 1.0 document is a structurally valid 2.0 document — the only - * 2.0 addition is the optional `identity` section — so import and - * {@link GraphDataSchema} accept both `"1.0"` and `"2.0"` (see + * 2.0 addition is the optional `identity` section — and a 2.0 document is in + * turn a structurally valid 3.0 document — the only 3.0 addition is the + * optional `identity.transitions` / `identity.retention` archival fields — so + * import and {@link GraphDataSchema} accept `"1.0"`, `"2.0"`, and `"3.0"` (see * {@link AcceptedFormatVersionSchema}), while exports keep writing this * constant. Bump this only when exports must emit a new version; add the old * value to the accepted set rather than dropping read compatibility. */ -export const FORMAT_VERSION = "2.0" as const; +export const FORMAT_VERSION = "3.0" as const; /** * Format versions accepted on the read side (import + `GraphDataSchema.parse`). - * A pre-existing 1.0 export is a valid 2.0 document (the 2.0 change is the - * additive optional `identity` section), so both validate; exports always write - * {@link FORMAT_VERSION}. + * A pre-existing 1.0 or 2.0 export is a valid 3.0 document (the 3.0 change is + * the additive optional `identity.transitions` / `identity.retention` archival + * fields), so all three validate; exports always write {@link FORMAT_VERSION}. */ -const AcceptedFormatVersionSchema = z.enum(["1.0", "2.0"]); +const AcceptedFormatVersionSchema = z.enum(["1.0", "2.0", "3.0"]); /** * A stored validity-window timestamp (`validFrom` / `validTo`). Must be a @@ -56,6 +61,11 @@ const ValidityTimestampSchema = z.iso // Node Interchange // ============================================================ +const InterchangeNodeRefSchema = z.object({ + kind: z.string().min(1), + id: z.string().min(1), +}); + /** * Interchange format for a node. * @@ -102,14 +112,8 @@ export type InterchangeNode = z.infer; export const InterchangeEdgeSchema = z.object({ kind: z.string().min(1), id: z.string().min(1), - from: z.object({ - kind: z.string().min(1), - id: z.string().min(1), - }), - to: z.object({ - kind: z.string().min(1), - id: z.string().min(1), - }), + from: InterchangeNodeRefSchema, + to: InterchangeNodeRefSchema, properties: z.record(z.string(), z.unknown()).default({}), /** See {@link InterchangeNodeSchema}'s `validFrom` for the null/undefined contract. */ validFrom: ValidityTimestampSchema.nullable().optional(), @@ -136,8 +140,8 @@ export type IdentityInterchangeMode = z.infer< export const InterchangeIdentityAssertionSchema = z.object({ id: z.string().min(1), relation: z.enum(["same", "different"]), - a: z.object({ kind: z.string().min(1), id: z.string().min(1) }), - b: z.object({ kind: z.string().min(1), id: z.string().min(1) }), + a: InterchangeNodeRefSchema, + b: InterchangeNodeRefSchema, validFrom: ValidityTimestampSchema, validTo: ValidityTimestampSchema.optional(), /** @@ -147,23 +151,131 @@ export const InterchangeIdentityAssertionSchema = z.object({ * explicit retractions; when present it names one of `a` / `b` and requires * `validTo`. Import rejects both violations with an attributed error. */ - endedBy: z - .object({ kind: z.string().min(1), id: z.string().min(1) }) - .optional(), + endedBy: InterchangeNodeRefSchema.optional(), }); export type InterchangeIdentityAssertion = z.infer< typeof InterchangeIdentityAssertionSchema >; +/** + * The nine causes `IdentityTransitionCause` (`identity/transition-log.ts`) + * declares — spelled again here, rather than imported as a value, because that + * module owns internal transition-writing semantics and this schema owns only + * the wire shape, matching this file's existing precedent of inlining + * `InterchangeIdentityAssertionSchema.relation` rather than importing + * `IdentityRelation`. A type-level check keeps the two spellings from drifting. + */ +const InterchangeIdentityTransitionCauseSchema = z.enum([ + "assert", + "retract", + "fold", + "detach", + "restore", + "window-end", + "kind-drop", + "schema-transition", + "reconcile", +]); + +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- compile-time assertion +type _interchangeCauseMatchesTransitionLog = Assert< + Equal< + z.infer, + IdentityTransitionCause + > +>; + +/** + * Strict: a decision names only the evidence this format defines, so an + * archive carrying any other decision field is rejected rather than restored + * with that field silently stripped. + */ +const InterchangeIdentityTransitionDecisionSchema = z.strictObject({ + branchId: z.string().optional(), + branchAncestry: z.array(z.string()).optional(), + mergePlanDigest: z.string().optional(), + reviewDigest: z.string().optional(), + sourceId: z.string().optional(), +}); + +/** + * One archived identity transition-log row: an explanation of why a class's + * membership changed, carried verbatim — `recordedRevision` and + * `recordedAt` are the SOURCE graph's own values, preserved rather than + * renumbered, because a restore records history, it does not relive it. See + * "Archival transitions and the retention watermark" in the identity + * documentation. Absent from `state`-mode interchange, which carries current + * truth only. + */ +export const InterchangeIdentityTransitionSchema = z.object({ + transitionId: z.string().min(1), + cause: InterchangeIdentityTransitionCauseSchema, + // The range a recorded instant can carry, by the predicate that reads the + // restored row back: a revision outside it would be stored and then throw + // on every `transitionsOf` and `replay` that reaches it. + recordedRevision: z + .number() + .refine((revision) => isRecordedRevision(revision), { + message: "recordedRevision is outside the recorded-revision range", + }), + recordedAt: z.iso.datetime(), + validAt: ValidityTimestampSchema, + class: InterchangeNodeRefSchema, + priorClass: InterchangeNodeRefSchema.optional(), + assertionIds: z.array(z.string().min(1)), + decision: InterchangeIdentityTransitionDecisionSchema.optional(), +}); +export type InterchangeIdentityTransition = z.infer< + typeof InterchangeIdentityTransitionSchema +>; + +/** + * The retention watermark accompanying an `archival` export: the SOURCE + * graph's own `pruneIdentityTransitions` state at export time, so a reader of + * the raw interchange payload knows the transitions array excludes anything + * the source had already pruned. Distinct from the watermark import WRITES + * into the restored graph, which is always the revision the restore's first + * commit takes on the DESTINATION (see `importIdentityTransitionsIntoTarget`) — + * this value is never written verbatim into a foreign graph's watermark. + */ +export const InterchangeIdentityRetentionSchema = z.object({ + prunedBeforeRevision: z.number().int().nonnegative(), + prunedAt: z.iso.datetime(), +}); +export type InterchangeIdentityRetention = z.infer< + typeof InterchangeIdentityRetentionSchema +>; + export const InterchangeIdentitySchema = z.object({ profile: z.literal("typegraph-identity-v1"), mode: IdentityInterchangeModeSchema, assertions: z.array(InterchangeIdentityAssertionSchema), + /** + * `archival` mode only. Absent (or empty) on `state` mode — state carries + * current truth, never explanation. Branch cloning and state export carry + * nothing here either, for the same reason (`working-copy.ts`). + */ + transitions: z.array(InterchangeIdentityTransitionSchema).optional(), + /** `archival` mode only — the source graph's retention watermark. */ + retention: InterchangeIdentityRetentionSchema.optional(), }); export type InterchangeIdentity = z.infer; const InterchangeIdentityHeaderSchema = InterchangeIdentitySchema.omit({ assertions: true, + transitions: true, +}).extend({ + /** + * `archival` mode only: whether the `identity-transitions` chunk this + * stream will later emit carries at least one row. The header is the ONLY + * place a streaming import can learn this before that chunk arrives — by + * protocol it is always the LAST chunk, after nodes, edges and identity + * assertions have already been written — so `importGraphStream` reads it + * to refuse an archival-transitions restore into a `history: false` target + * up front, matching `importGraph`'s atomic upfront check (which reads the + * full `transitions` array directly, since it never streams). + */ + hasTransitions: z.boolean().optional(), }); // ============================================================ @@ -313,6 +425,12 @@ export const GraphInterchangeChunkSchema = z.discriminatedUnion("type", [ type: z.literal("identity"), assertions: z.array(InterchangeIdentityAssertionSchema), }), + // Archival-only, always after every "identity" chunk (see `importStream`'s + // ordering guard): the class-lineage transitions those assertions caused. + z.object({ + type: z.literal("identity-transitions"), + transitions: z.array(InterchangeIdentityTransitionSchema), + }), ]); export type GraphInterchangeChunk = z.infer; @@ -367,7 +485,7 @@ export type ImportResult = z.infer; const MAX_TIMER_DELAY_MS = 2_147_483_647; // Annotated as the wider `ZodCustom` because zod 4.6 narrowed `z.instanceof`'s -// return type to `ZodInstanceOf`, which zod 4.0–4.5 (inside our `^4.0.0` peer +// return type to `ZodInstanceOf`, which zod 4.5 (inside our `^4.5.0` peer // range) does not declare; emitting it would break consumers' declarations. const AbortSignalSchema: z.ZodCustom = z.instanceof(AbortSignal); @@ -380,7 +498,14 @@ export const ExportOptionsSchema = z.object({ nodeKinds: z.array(z.string()).optional(), /** Filter to specific edge kinds (undefined = all) */ edgeKinds: z.array(z.string()).optional(), - /** Include temporal fields (validFrom, validTo) */ + /** + * Include temporal fields (validFrom, validTo). Always included for a graph + * with Operational Identity or with an edge kind whose `cardinality` or + * `targetCardinality` is `"oneActive"` (a `oneActive` composition pair + * included): those exports default to `true` and refuse an explicit + * `false`, since an ended row exported without its `validTo` would + * re-import as current. + */ includeTemporal: z.boolean().default(false), /** Include metadata (version, timestamps) */ includeMeta: z.boolean().default(false), diff --git a/packages/typegraph/src/ontology/constants.ts b/packages/typegraph/src/ontology/constants.ts index d599bab1d..791ae39c9 100644 --- a/packages/typegraph/src/ontology/constants.ts +++ b/packages/typegraph/src/ontology/constants.ts @@ -65,3 +65,24 @@ export const ALL_META_EDGE_NAMES = [ ] as const; export type MetaEdgeName = (typeof ALL_META_EDGE_NAMES)[number]; + +// ============================================================ +// Structural Subsumption Meta-Edges +// ============================================================ + +/** + * The meta-edges the structural-subsumption contract applies to: + * `subClassOf` (always one direction), and `equivalentTo`/`sameAs` (mutual + * subsumption in both directions). The one owner of "is this relation a + * subsumption relation" — `src/registry/build-validated.ts` and + * `src/schema/migration.ts` both classify relations against this set rather + * than re-spelling the membership test. + */ +export function isSubsumptionMetaEdge(metaEdge: string): boolean { + return metaEdge === META_EDGE_SUB_CLASS_OF || isEquivalenceMetaEdge(metaEdge); +} + +/** Whether `metaEdge` declares mutual subsumption: `equivalentTo` or `sameAs`. */ +export function isEquivalenceMetaEdge(metaEdge: string): boolean { + return metaEdge === META_EDGE_EQUIVALENT_TO || metaEdge === META_EDGE_SAME_AS; +} diff --git a/packages/typegraph/src/ontology/core-meta-edges.ts b/packages/typegraph/src/ontology/core-meta-edges.ts index 854619848..c18cd0d84 100644 --- a/packages/typegraph/src/ontology/core-meta-edges.ts +++ b/packages/typegraph/src/ontology/core-meta-edges.ts @@ -1,4 +1,14 @@ import { type AnyEdgeType, type NodeType } from "../core/types"; +import { ConfigurationError } from "../errors"; +import { + COMPOSITION_EXISTENCE_VALUES, + COMPOSITION_PART_SIDE_VALUES, + type CompositionExistence, + type CompositionPartSide, + compositionRelationFields, + isCompositionExistence, + isCompositionPartSide, +} from "../registry/composition-relation"; import { META_EDGE_BROADER, META_EDGE_DIFFERENT_FROM, @@ -12,8 +22,16 @@ import { META_EDGE_RELATED_TO, META_EDGE_SAME_AS, META_EDGE_SUB_CLASS_OF, + type MetaEdgeName, } from "./constants"; -import { META_EDGE_BRAND, type MetaEdge, type OntologyRelation } from "./types"; +import { + type EquivalentToCheck, + META_EDGE_BRAND, + type MetaEdge, + type OntologyRelation, + type SubClassOfCheck, + type TypedOntologyRelation, +} from "./types"; // ============================================================ // Helper to Create Meta-Edge @@ -21,33 +39,12 @@ import { META_EDGE_BRAND, type MetaEdge, type OntologyRelation } from "./types"; function createMetaEdge( name: K, - properties: { - transitive?: boolean; - symmetric?: boolean; - reflexive?: boolean; - inverse?: string; - inference: - | "subsumption" - | "hierarchy" - | "substitution" - | "constraint" - | "composition" - | "association" - | "none"; - description: string; - }, + description: string, ): MetaEdge { return Object.freeze({ [META_EDGE_BRAND]: true as const, name, - properties: { - transitive: properties.transitive ?? false, - symmetric: properties.symmetric ?? false, - reflexive: properties.reflexive ?? false, - inverse: properties.inverse, - inference: properties.inference, - description: properties.description, - }, + properties: { description }, }); } @@ -59,19 +56,34 @@ function createMetaEdge( * Type inheritance relationship. * A subClassOf B means instances of A are also instances of B. */ -const subClassOfMetaEdge = createMetaEdge(META_EDGE_SUB_CLASS_OF, { - transitive: true, - inference: "subsumption", - description: "Type inheritance (Podcast subClassOf Media)", -}); +const subClassOfMetaEdge = createMetaEdge( + META_EDGE_SUB_CLASS_OF, + "Type inheritance (Podcast subClassOf Media)", +); /** * Creates a subClassOf ontology relation. + * + * Compile-time structural contract: `child`'s schema + * output must structurally extend `parent`'s — every property `parent` + * requires, `child` has with a compatible type; `child` may add properties + * or narrow an optional-in-parent property. A pair that fails this is + * refused at compile time with a {@link SubClassOfCheck} mismatch naming + * the incompatible fields, and — for pairs the type checker cannot see + * through (refinements, transforms, value-level constraints) — at registry + * build time by the authoritative runtime check + * (`src/registry/validate-structural-subsumption.ts`). The compile-time check is a filter, not + * the authority: it accepts strictly more than the registry does, never + * less. + * + * A hierarchy that is a taxonomy rather than a subtype relationship — the + * child does not extend the parent's schema — should use `broader(child, + * parent)` instead; see `expansion: "narrower"` for kind-level taxonomy queries. */ -export function subClassOf( - child: NodeType, - parent: NodeType, -): OntologyRelation { +export function subClassOf( + child: C, + parent: P & SubClassOfCheck, +): TypedOntologyRelation { return { metaEdge: subClassOfMetaEdge, from: child, @@ -87,20 +99,18 @@ export function subClassOf( * Broader concept relationship. * A broader B means A is a more specific concept than B. */ -const broaderMetaEdge = createMetaEdge(META_EDGE_BROADER, { - transitive: true, - inverse: META_EDGE_NARROWER, - inference: "hierarchy", - description: "Broader concept (ML broader AI)", -}); +const broaderMetaEdge = createMetaEdge( + META_EDGE_BROADER, + "Broader concept (ML broader AI)", +); /** * Creates a broader ontology relation. */ -export function broader( - narrowerConcept: NodeType, - broaderConcept: NodeType, -): OntologyRelation { +export function broader( + narrowerConcept: N, + broaderConcept: B, +): TypedOntologyRelation { return { metaEdge: broaderMetaEdge, from: narrowerConcept, @@ -112,20 +122,18 @@ export function broader( * Narrower concept relationship. * A narrower B means A is a more general concept than B. */ -const narrowerMetaEdge = createMetaEdge(META_EDGE_NARROWER, { - transitive: true, - inverse: META_EDGE_BROADER, - inference: "hierarchy", - description: "Narrower concept (AI narrower ML)", -}); +const narrowerMetaEdge = createMetaEdge( + META_EDGE_NARROWER, + "Narrower concept (AI narrower ML)", +); /** * Creates a narrower ontology relation. */ -export function narrower( - broaderConcept: NodeType, - narrowerConcept: NodeType, -): OntologyRelation { +export function narrower( + broaderConcept: B, + narrowerConcept: N, +): TypedOntologyRelation { return { metaEdge: narrowerMetaEdge, from: broaderConcept, @@ -137,19 +145,18 @@ export function narrower( * Related concept relationship. * Non-hierarchical association between concepts. */ -const relatedToMetaEdge = createMetaEdge(META_EDGE_RELATED_TO, { - symmetric: true, - inference: "association", - description: "Non-hierarchical association", -}); +const relatedToMetaEdge = createMetaEdge( + META_EDGE_RELATED_TO, + "Non-hierarchical association", +); /** * Creates a relatedTo ontology relation. */ -export function relatedTo( - conceptA: NodeType, - conceptB: NodeType, -): OntologyRelation { +export function relatedTo( + conceptA: A, + conceptB: B, +): TypedOntologyRelation { return { metaEdge: relatedToMetaEdge, from: conceptA, @@ -165,19 +172,57 @@ export function relatedTo( * Type equivalence relationship. * A equivalentTo B means they represent the same class. */ -const equivalentToMetaEdge = createMetaEdge(META_EDGE_EQUIVALENT_TO, { - symmetric: true, - transitive: true, - inference: "substitution", - description: "Same class, different representation", -}); +const equivalentToMetaEdge = createMetaEdge( + META_EDGE_EQUIVALENT_TO, + "Same class, different representation", +); /** * Creates an equivalentTo ontology relation. - * Can be used with external IRIs for cross-system mapping. + * + * Between two registered kinds, `equivalentTo` is MUTUAL SUBSUMPTION: + * `KindRegistry` folds the class into `subClassAncestors`/`subClassDescendants` + * before the transitive closure, so `isAssignableTo`, `expandSubClasses`, + * disjointness propagation and the `kindWithSubClasses` claim axis all agree + * that the two kinds are substitutable. Subsumption is a node-kind relation, + * so the left parameter widens to `NodeType | AnyEdgeType` only so an edge + * kind can be mapped to an external IRI for cross-system mapping — an edge + * kind equivalenced to a registered node or edge kind is refused at registry + * build (`ONTOLOGY_EQUIVALENCE_INVALID_CLASS`). + * + * Between two node kinds this carries the same compile-time structural + * contract as `subClassOf`, checked in BOTH directions -- mutual + * subsumption means each kind's schema must extend the other's. The IRI + * form (`equivalentTo(kind, iri)`) and the edge-to-node form carry no + * check: an external IRI, and an edge kind paired with a node kind (always + * refused at registry build), have no comparable schema pair. + * + * `sameAs` (since removed) was a type-level alias of this relation: a + * document persisted before the removal that still names a `sameAs` + * relation continues to load and fold into the same equivalence bucket as + * `equivalentTo` — `collectOntologyRelations` + * (`src/registry/kind-registry.ts`) switches on the meta-edge name, not on + * whether the deleted factory produced it. */ +export function equivalentTo( + kindA: A, + kindB: B & EquivalentToCheck, +): TypedOntologyRelation; +export function equivalentTo( + kindA: A, + kindBOrIri: string, +): TypedOntologyRelation; +// Kept apart from the node-to-node overload above, which constrains `A` to +// NodeType and applies EquivalentToCheck to its partner: this edge-to-node +// form constrains `A` to AnyEdgeType and carries no structural check. Merging +// the two would also admit `equivalentTo(nodeKind, edgeKind)`, which this +// overload set refuses on purpose (see the docblock above). +export function equivalentTo( + kindA: A, + kindB: B, +): TypedOntologyRelation; export function equivalentTo( - kindA: NodeType, + kindA: NodeType | AnyEdgeType, kindBOrIri: NodeType | string, ): OntologyRelation { return { @@ -187,74 +232,50 @@ export function equivalentTo( }; } -/** Type-level compatibility alias retained for migration only. */ -const sameAsMetaEdge = createMetaEdge(META_EDGE_SAME_AS, { - symmetric: true, - transitive: true, - inference: "substitution", - description: "Deprecated type-level equivalence alias", -}); - /** - * Creates the deprecated type-level `sameAs` alias of `equivalentTo`. - * - * @deprecated Enable `identity: { sameIdAcrossKinds: "fold" }` and use the - * TypeGraph Identity Profile's runtime `store.identity` ledger. The ledger - * does not perform OWL property substitution or automatic query expansion. + * `sameAs`'s meta-edge object. The public `sameAs()`/`differentFrom()` + * factories were removed, and neither this object nor + * {@link differentFromMetaEdge} is a member of the public `core` export: a + * new graph definition cannot construct a relation carrying either name any + * more, whether through a factory or by reaching into `core` directly. + * `buildRegistryFromSerializedSchema` needs neither object — a persisted + * document's relations carry the meta-edge as a plain string name, and + * `collectOntologyRelations` (`src/registry/kind-registry.ts`) switches on + * that string. The one reader that still resolves a meta-edge NAME to its + * object is `compileOntologyRelation` (`src/graph-extension/compiler.ts`, + * compiling a declarative graph extension into the same `OntologyRelation` + * shape a compile-time factory produces): it looks every + * `ALL_META_EDGE_NAMES` member up by `${name}MetaEdge` through + * {@link metaEdgesByName} below, the one internal record both deprecated + * meta-edges are exported for. */ -export function sameAs( - kindA: NodeType, - kindBOrIri: NodeType | string, -): OntologyRelation { - return { - metaEdge: sameAsMetaEdge, - from: kindA, - to: kindBOrIri, - }; -} - -/** Deprecated decorative type-level non-identity relation. */ -const differentFromMetaEdge = createMetaEdge(META_EDGE_DIFFERENT_FROM, { - symmetric: true, - inference: "constraint", - description: "Deprecated decorative type-level non-identity relation", -}); +const sameAsMetaEdge = createMetaEdge( + META_EDGE_SAME_AS, + "Deprecated type-level equivalence alias", +); -/** - * Creates the deprecated decorative type-level `differentFrom` relation. - * - * @deprecated Enable `identity: { sameIdAcrossKinds: "fold" }` and use the - * TypeGraph Identity Profile's runtime `store.identity` ledger. The ledger - * does not perform OWL property substitution or automatic query expansion. - */ -export function differentFrom( - kindA: NodeType, - kindB: NodeType, -): OntologyRelation { - return { - metaEdge: differentFromMetaEdge, - from: kindA, - to: kindB, - }; -} +/** See {@link sameAsMetaEdge} — the `differentFrom` counterpart. */ +const differentFromMetaEdge = createMetaEdge( + META_EDGE_DIFFERENT_FROM, + "Deprecated decorative type-level non-identity relation", +); /** * Disjoint types relationship. * A disjointWith B means nothing can be both an A and a B. */ -const disjointWithMetaEdge = createMetaEdge(META_EDGE_DISJOINT_WITH, { - symmetric: true, - inference: "constraint", - description: "Mutually exclusive types", -}); +const disjointWithMetaEdge = createMetaEdge( + META_EDGE_DISJOINT_WITH, + "Mutually exclusive types", +); /** * Creates a disjointWith ontology relation. */ -export function disjointWith( - kindA: NodeType, - kindB: NodeType, -): OntologyRelation { +export function disjointWith( + kindA: A, + kindB: B, +): TypedOntologyRelation { return { metaEdge: disjointWithMetaEdge, from: kindA, @@ -270,21 +291,113 @@ export function disjointWith( * Part-of relationship. * A partOf B means A is a component of B. */ -const partOfMetaEdge = createMetaEdge(META_EDGE_PART_OF, { - transitive: true, - inverse: META_EDGE_HAS_PART, - inference: "composition", - description: "X is part of Y", -}); +const partOfMetaEdge = createMetaEdge(META_EDGE_PART_OF, "X is part of Y"); + +/** + * The options every composition relation (`partOf`/`hasPart`) requires. + * + * Composition is a closed relation, not a relation class. The reserved + * claim axis, cascade, required existence, and multi-edge acyclicity live + * only on `partOf`/`hasPart`. `acyclic: true` on an ordinary edge composes + * through `implies`/`inverseOf` by a different mechanism and does not grant + * these guarantees. `memberOf` or `dependsOn` cannot opt into them by + * declaring another pair — there is no public composition-pair factory. + */ +export type CompositionOptions = Readonly<{ + /** The edge kind that realizes the composition instance-level. */ + via: AnyEdgeType; + /** Required only when the edge admits both orientations (e.g. same-kind containment). */ + partSide?: CompositionPartSide; + /** + * `"required"`: the part cannot exist without a live whole. + * Default `"optional"` keeps older declarations unchanged. + */ + existence?: CompositionExistence; +}>; + +const COMPOSITION_OPTION_KEYS: readonly string[] = [ + "via", + "partSide", + "existence", +] satisfies readonly (keyof CompositionOptions)[]; + +/** The enum-valued composition options, in the order they are judged. */ +const COMPOSITION_ENUM_OPTIONS = [ + { + option: "existence", + values: COMPOSITION_EXISTENCE_VALUES, + isValid: isCompositionExistence, + code: "ONTOLOGY_COMPOSITION_EXISTENCE_INVALID", + }, + { + option: "partSide", + values: COMPOSITION_PART_SIDE_VALUES, + isValid: isCompositionPartSide, + code: "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID", + }, +] as const satisfies readonly Readonly<{ + option: keyof CompositionOptions; + values: readonly string[]; + isValid: (value: unknown) => boolean; + code: string; +}>[]; + +/** + * THE one read of a `partOf` / `hasPart` options object, shared by both + * factories so neither can accept what the other refuses. A key this library + * does not know is refused rather than dropped — a misspelled `existence` + * would otherwise silently declare an optional part — and so is an + * `existence` or `partSide` outside its enum, which the registry would read + * as "not required" / "to" and the schema serializer would persist as written. + */ +function compositionRelationOptions( + factory: typeof META_EDGE_PART_OF | typeof META_EDGE_HAS_PART, + options: CompositionOptions, +): ReturnType { + const unknownKeys = Object.keys(options).filter( + (key) => !COMPOSITION_OPTION_KEYS.includes(key), + ); + if (unknownKeys.length > 0) { + throw new ConfigurationError( + `${factory}() was given unknown option${unknownKeys.length === 1 ? "" : "s"} ${unknownKeys.map((key) => `"${key}"`).join(", ")}.`, + { code: "ONTOLOGY_COMPOSITION_OPTION_UNKNOWN", factory, unknownKeys }, + { + suggestion: `Accepted options: ${COMPOSITION_OPTION_KEYS.join(", ")}.`, + }, + ); + } + for (const { option, values, isValid, code } of COMPOSITION_ENUM_OPTIONS) { + const value = options[option]; + if (value === undefined || isValid(value)) continue; + throw new ConfigurationError( + `${factory}() was given \`${option}: ${JSON.stringify(value)}\`.`, + { code, factory, [option]: value }, + { suggestion: `Pass one of: ${values.join(", ")}.` }, + ); + } + return compositionRelationFields({ + via: options.via.kind, + partSide: options.partSide, + existence: options.existence, + }); +} /** * Creates a partOf ontology relation. + * + * `via` names the edge kind whose live rows realize this composition; a + * typo is a compile error because it is the edge's TYPE, not its name. */ -export function partOf(part: NodeType, whole: NodeType): OntologyRelation { +export function partOf( + part: Part, + whole: Whole, + options: CompositionOptions, +): TypedOntologyRelation { return { metaEdge: partOfMetaEdge, from: part, to: whole, + ...compositionRelationOptions(META_EDGE_PART_OF, options), }; } @@ -292,21 +405,24 @@ export function partOf(part: NodeType, whole: NodeType): OntologyRelation { * Has-part relationship. * A hasPart B means A contains B as a component. */ -const hasPartMetaEdge = createMetaEdge(META_EDGE_HAS_PART, { - transitive: true, - inverse: META_EDGE_PART_OF, - inference: "composition", - description: "Y has part X", -}); +const hasPartMetaEdge = createMetaEdge(META_EDGE_HAS_PART, "Y has part X"); /** * Creates a hasPart ontology relation. + * + * `via` names the edge kind whose live rows realize this composition; a + * typo is a compile error because it is the edge's TYPE, not its name. */ -export function hasPart(whole: NodeType, part: NodeType): OntologyRelation { +export function hasPart( + whole: Whole, + part: Part, + options: CompositionOptions, +): TypedOntologyRelation { return { metaEdge: hasPartMetaEdge, from: whole, to: part, + ...compositionRelationOptions(META_EDGE_HAS_PART, options), }; } @@ -318,30 +434,28 @@ export function hasPart(whole: NodeType, part: NodeType): OntologyRelation { * Inverse edge relationship. * Edge A inverseOf edge B means traversing A is equivalent to traversing B backwards. */ -const inverseOfMetaEdge = createMetaEdge(META_EDGE_INVERSE_OF, { - symmetric: true, - inference: "none", - description: "Edge A is inverse of edge B", -}); +const inverseOfMetaEdge = createMetaEdge( + META_EDGE_INVERSE_OF, + "Edge A is inverse of edge B", +); /** * Implication relationship. * Edge A implies edge B means if A exists, B should also exist. */ -const impliesMetaEdge = createMetaEdge(META_EDGE_IMPLIES, { - transitive: true, - inference: "none", - description: "Edge A implies edge B exists", -}); +const impliesMetaEdge = createMetaEdge( + META_EDGE_IMPLIES, + "Edge A implies edge B exists", +); /** * Creates an inverseOf ontology relation. * Edge A inverseOf edge B means traversing A is equivalent to traversing B backwards. */ -export function inverseOf( - edgeA: AnyEdgeType, - edgeB: AnyEdgeType, -): OntologyRelation { +export function inverseOf( + edgeA: A, + edgeB: B, +): TypedOntologyRelation { return { metaEdge: inverseOfMetaEdge, from: edgeA, @@ -353,10 +467,10 @@ export function inverseOf( * Creates an implies ontology relation. * Edge A implies edge B means if A exists between two nodes, B should also exist. */ -export function implies( - edgeA: AnyEdgeType, - edgeB: AnyEdgeType, -): OntologyRelation { +export function implies( + edgeA: A, + edgeB: B, +): TypedOntologyRelation { return { metaEdge: impliesMetaEdge, from: edgeA, @@ -369,8 +483,13 @@ export function implies( // ============================================================ /** - * The core ontology module containing all built-in meta-edges - * and their relation factory functions. + * The core ontology module containing all built-in meta-edges and their + * relation factory functions. Deliberately excludes `sameAsMetaEdge` and + * `differentFromMetaEdge` (removed from it deliberately): a package consumer with + * `core` in hand cannot construct a `sameAs`/`differentFrom` relation by + * reaching into it any more than by calling the deleted factories — see + * {@link metaEdgesByName} below for the internal-only record that still + * carries them. */ export const core = { // Meta-edges @@ -379,8 +498,6 @@ export const core = { narrowerMetaEdge, relatedToMetaEdge, equivalentToMetaEdge, - sameAsMetaEdge, - differentFromMetaEdge, disjointWithMetaEdge, partOfMetaEdge, hasPartMetaEdge, @@ -393,13 +510,54 @@ export const core = { narrower, relatedTo, equivalentTo, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility member until the next major - sameAs, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility member until the next major - differentFrom, disjointWith, partOf, hasPart, inverseOf, implies, } as const; + +// ============================================================ +// Internal by-name lookup (NOT part of the public `core` export) +// ============================================================ + +/** + * Every built-in meta-edge by name, including `sameAs`/`differentFrom` — + * which have no public factory and are absent from the public `core` + * export above. This record is kept for exactly + * as long as PERSISTED-DOCUMENT interpretation needs it: a `schema_doc` + * committed before this removal can carry a `sameAs`/`differentFrom` + * relation by name (`SerializedOntology.relations`, + * `src/schema/types.ts`), and `collectOntologyRelations` + * (`src/registry/kind-registry.ts`) must still fold that name into the same + * `KindRegistry` state the pre-removal code produced when the document + * loads. + * + * `compileOntologyRelation` (`src/graph-extension/compiler.ts`) is the one + * reader that resolves an `ALL_META_EDGE_NAMES` member to its `MetaEdge` + * object this way, by name, rather than through a factory. That path + * compiles a DECLARATIVE graph extension, not only a persisted document — + * and `ALL_META_EDGE_NAMES` stays closed, not narrowed, so a new extension + * naming `sameAs`/`differentFrom` still compiles today. That is by design: + * the closed name set is what graph extensions validate against + * (`src/graph-extension/validation.ts`), and narrowing it to exclude these + * two names — rather than merely declining to ship a public factory or + * relation-declaration sugar for them — was never part of this removal. + * + * Not re-exported from `../ontology` or the package root — reach it only by + * importing `./core-meta-edges` directly from inside this package. + */ +export const metaEdgesByName: Readonly> = { + [META_EDGE_SUB_CLASS_OF]: subClassOfMetaEdge, + [META_EDGE_BROADER]: broaderMetaEdge, + [META_EDGE_NARROWER]: narrowerMetaEdge, + [META_EDGE_RELATED_TO]: relatedToMetaEdge, + [META_EDGE_EQUIVALENT_TO]: equivalentToMetaEdge, + [META_EDGE_SAME_AS]: sameAsMetaEdge, + [META_EDGE_DIFFERENT_FROM]: differentFromMetaEdge, + [META_EDGE_DISJOINT_WITH]: disjointWithMetaEdge, + [META_EDGE_PART_OF]: partOfMetaEdge, + [META_EDGE_HAS_PART]: hasPartMetaEdge, + [META_EDGE_INVERSE_OF]: inverseOfMetaEdge, + [META_EDGE_IMPLIES]: impliesMetaEdge, +}; diff --git a/packages/typegraph/src/ontology/index.ts b/packages/typegraph/src/ontology/index.ts index f536eb03b..8d32d9e96 100644 --- a/packages/typegraph/src/ontology/index.ts +++ b/packages/typegraph/src/ontology/index.ts @@ -1,9 +1,8 @@ // Core meta-edges and relation factories export { broader, + type CompositionOptions, core, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility re-export until the next major - differentFrom, disjointWith, equivalentTo, hasPart, @@ -12,8 +11,6 @@ export { narrower, partOf, relatedTo, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- compatibility re-export until the next major - sameAs, subClassOf, } from "./core-meta-edges"; @@ -43,12 +40,24 @@ export { } from "./closures"; // Types +export type { + CompositionExistence, + CompositionPartSide, +} from "../registry/composition-relation"; export { + compositionViaKind, + type CompositionViaRef, +} from "../registry/composition-relation"; +export { + type EquivalentToCheck, getTypeName, - type InferenceType, isMetaEdge, META_EDGE_BRAND, type MetaEdge, type MetaEdgeProperties, type OntologyRelation, + type PolymorphicNodeType, + type StructuralSubtypeMismatch, + type SubClassOfCheck, + type TypedOntologyRelation, } from "./types"; diff --git a/packages/typegraph/src/ontology/types.ts b/packages/typegraph/src/ontology/types.ts index 2986514b8..1e96a8de4 100644 --- a/packages/typegraph/src/ontology/types.ts +++ b/packages/typegraph/src/ontology/types.ts @@ -1,4 +1,5 @@ -import { type AnyEdgeType, type NodeType } from "../core/types"; +import { type AnyEdgeType, type NodeProps, type NodeType } from "../core/types"; +import { type CompositionRealization } from "../registry/composition-relation"; // ============================================================ // Brand Key @@ -7,35 +8,23 @@ import { type AnyEdgeType, type NodeType } from "../core/types"; /** Brand key for MetaEdge */ export const META_EDGE_BRAND = "__metaEdge" as const; -// ============================================================ -// Inference Types -// ============================================================ - -/** - * How a meta-edge affects queries and validation. - */ -export type InferenceType = - | "subsumption" // Query for X includes instances of subclasses - | "hierarchy" // Enables broader/narrower traversal - | "substitution" // Can substitute equivalent types - | "constraint" // Validation rules - | "composition" // Part-whole navigation - | "association" // Discovery/recommendation - | "none"; // No automatic inference - // ============================================================ // Meta-Edge Properties // ============================================================ /** * Properties of a meta-edge. + * + * `transitive`/`symmetric`/`reflexive`/`inverse`/`inference` were removed + * along with the sameAs/differentFrom factories: no registry or closure code ever read them off a `MetaEdge` — + * every behavioral decision for a built-in meta-edge is hardcoded per literal + * name in `collectOntologyRelations` (`src/registry/kind-registry.ts`) and + * `classifyKnownRelationSeverity` (`src/schema/ontology-change.ts`). They + * were serialized-introspection metadata only. See + * `apps/docs/src/content/docs/schema-evolution.md`'s upgrade notes for the + * migration; free-form per-relation metadata belongs in `annotations`. */ export type MetaEdgeProperties = Readonly<{ - transitive: boolean; // A→B, B→C implies A→C - symmetric: boolean; // A→B implies B→A - reflexive: boolean; // A→A is always true - inverse: string | undefined; // Name of inverse meta-edge - inference: InferenceType; // How this affects queries description: string | undefined; }>; @@ -75,7 +64,8 @@ export type OntologyRelation = Readonly<{ metaEdge: MetaEdge; from: NodeType | AnyEdgeType | string; // string for external IRIs to: NodeType | AnyEdgeType | string; -}>; +}> & + CompositionRealization; // ============================================================ // Type Guards @@ -104,3 +94,183 @@ export function getTypeName( } return typeOrIri.kind; } + +// ============================================================ +// Typed Subsumption +// ============================================================ + +/** + * A structurally validated ontology relation. Assignable to + * {@link OntologyRelation} unchanged (so it slots into a + * `defineGraph({ ontology: [...] })` array with no cast), but retains the + * meta-edge NAME literal and the `From`/`To` kind literals so + * `G["ontology"]` — and therefore the polymorphic-alias computation in + * `src/query/builder/types.ts` — can see which relation connects which + * kinds. + * + * EVERY meta-edge factory in `core-meta-edges.ts` returns this, including + * the IRI-routed `equivalentTo(Kind, iri)` overload (typed + * `from: Kind, to: string`) and the composition factories. A bare + * {@link OntologyRelation} element in a graph's ontology tuple is therefore + * only reachable through a caller's own annotation, and + * `SubsumptionAffected` (`src/query/builder/types.ts`) widens + * conservatively when it sees one — it can no longer distinguish "a + * relation that provably does not touch this kind" from "a factory whose + * result was never typed". + */ +export type TypedOntologyRelation< + M extends string, + From extends NodeType | AnyEdgeType | string, + To extends NodeType | AnyEdgeType | string, +> = Readonly<{ + metaEdge: MetaEdge; + from: From; + to: To; +}> & + CompositionRealization; + +/** + * `keyof T`, with any generic index-signature key (`[x: string]: ...`) + * dropped — keeping only concrete, named literal keys. `z.object({})` + * infers a props type whose `keyof` is the generic `string` (Zod4's + * representation of "no named properties"), not a literal-key-free `{}`; + * without this filter, `IncompatibleKeys` would iterate that generic + * `string` pseudo-key as if it were a REQUIRED named property, comparing + * every child property against it and reporting the whole comparison as a + * mismatch (its `Fields` resolving to the generic `string` type rather + * than a real property name) even for a `parent` with no named properties + * at all. `string extends K` is true exactly for the generic index-key + * (where `K` is itself `string`), false for every concrete literal key. + */ +type LiteralKeysOf = { + [K in keyof T]-?: string extends K ? never : K; +}[keyof T] & + string; + +/** + * Every key `Parent`'s schema declares that `Child`'s schema has no + * compatible replacement for: a property `Child` is missing while `Parent` + * requires it, or a property both declare whose `Child` type does not fit + * `Parent`'s. Only NAMED (literal-key) parent properties are considered — + * see {@link LiteralKeysOf}; an index signature never represents a + * required property in the JSON-Schema/structural-subtype sense. + * + * Compares PER PROPERTY (`Child[K] extends Parent[K]`), never by building a + * `Pick` object type and checking `Child extends` it as a whole — + * TypeScript's "weak type" detection (TS2559, "has no properties in + * common") refuses that comparison whenever `Parent` is a schema whose + * fields are ALL optional and `Child` shares none of their names, which is + * exactly the width-subtyping case this predicate must accept (a `subClassOf` + * parent with a single optional field and a child that adds unrelated + * required fields). Comparing `Child[K]` against `Parent[K]` directly for + * one key at a time never constructs a weak object type, so it is immune to + * that detector. Each side of the property comparison is tuple-wrapped + * (`[Child[K]] extends [Parent[K]]`) to compare a union type as a whole + * rather than distributing the conditional over its members. + */ +export type IncompatibleKeys = { + [K in LiteralKeysOf]-?: K extends keyof Child ? + [Child[K]] extends [Parent[K]] ? + never + : K + : undefined extends Parent[K] ? never + : K; +}[LiteralKeysOf] & + string; + +/** + * Type-level refusal carrier for `subClassOf` / `equivalentTo` / `sameAs`: + * printed in place of the parent/partner parameter when the child's schema + * does not structurally extend it. A named object, not a branded `never`, + * so the compiler error names the offending properties instead of printing + * "not assignable to type 'never'" — the same idiom `UniqueAlias` + * (`src/query/builder/types.ts`) already uses for a type-level refusal. + */ +export type StructuralSubtypeMismatch< + ChildKind extends string, + ParentKind extends string, + Fields extends string, +> = Readonly<{ + __typegraphSubClassOfError: `subClassOf(${ChildKind}, ${ParentKind}): the child's schema must extend the parent's`; + missingOrIncompatibleProperties: Fields; + fix: "Give the child these properties with compatible types, or declare broader(child, parent) instead."; +}>; + +/** + * Resolves to `unknown` (a no-op intersection member) when `C`'s schema + * output structurally extends `P`'s, or to a {@link StructuralSubtypeMismatch} + * naming the incompatible fields otherwise. `NodeProps` is the Zod OUTPUT + * type, so this sees exactly what `isStructuralSubtype` + * (`src/schema/structural-subtype.ts`) sees when applied to the same pair's + * projected JSON Schema, modulo the refinements, transforms, and + * value-level constraints that projection cannot represent — + * `tests/property/typed-subsumption-agreement.test.ts` pins the one + * direction that must agree between the two. + * + * Consumed as an INTERSECTION member on the parameter + * (`parent: P & SubClassOfCheck`), not as the parameter's type + * directly (`parent: SubClassOfParent` resolving to `P` or the + * mismatch). The direct form puts `P` only inside a conditional's BRANCH, + * with the conditional's own CHECK also depending on `P` — a circular + * inference TypeScript cannot always resolve, confirmed for a `parent` with + * an EMPTY schema (`z.object({})`): `keyof {}` is `never`, so the check has + * no structural position to infer `P` from, inference falls back to the + * unconstrained `P extends NodeType` default, and a valid pair is refused + * with a spurious mismatch whose field list is the generic `string` rather + * than a real property name. The intersection form puts `P` NAKED as its + * own member, so inference reads it directly off the argument regardless of + * whether the check resolves — `SubClassOfCheck` only has to be + * SATISFIABLE, never has to participate in inferring anything. + */ +export type SubClassOfCheck = + [IncompatibleKeys, NodeProps

>] extends [never] ? unknown + : StructuralSubtypeMismatch< + C["kind"], + P["kind"], + IncompatibleKeys, NodeProps

> + >; + +/** + * The `equivalentTo`/`sameAs` sibling of {@link SubClassOfCheck}: resolves to + * `unknown` when `A` and `B`'s schemas are mutually structurally subtyping + * (the "mutual subsumption" reading between two registered kinds), or to + * a {@link StructuralSubtypeMismatch} naming the direction that fails and its + * incompatible fields. Consumed the same way, as an intersection member on + * the second parameter. + */ +export type EquivalentToCheck = + [IncompatibleKeys, NodeProps>] extends [never] ? + [IncompatibleKeys, NodeProps>] extends [never] ? + unknown + : StructuralSubtypeMismatch< + B["kind"], + A["kind"], + IncompatibleKeys, NodeProps> + > + : StructuralSubtypeMismatch< + A["kind"], + B["kind"], + IncompatibleKeys, NodeProps> + >; + +/** + * `N`, with the runtime-variable facts widened. the compile-time and registry-build structural checks guarantee that a + * subtype row satisfies the parent's PROPERTIES; they say nothing about + * which concrete kind produced the row, so `Node>` + * keeps every property fully typed while widening the `kind` discriminant + * to `string` and the `NodeId` brand to one an exact `NodeId` is + * assignable TO but not FROM — the safe direction. See `AliasNodeType` + * (`src/query/builder/types.ts`) for where this is applied selectively, + * only to aliases the ontology can actually affect. + * + * An intersection (`Omit & { kind: string }`), not a fresh + * `NodeType` — the fresh construction drops every + * property `N` carries beyond `schema`, including a brand a runtime-kind + * marker type layers on TOP of `NodeType` (`DynamicNodeType`'s + * `DYNAMIC_NODE_BRAND`, `src/query/builder/dynamic.ts`). Losing that brand + * silently switched `fromDynamic`/`toDynamic` aliases from the dynamic + * `.field()` accessor to the typed one. The intersection widens ONLY `kind`, + * preserving every other property — brands included — unchanged. + */ +export type PolymorphicNodeType = Omit & + Readonly<{ kind: string }>; diff --git a/packages/typegraph/src/ontology/validation.ts b/packages/typegraph/src/ontology/validation.ts index e11b3a520..e0814d523 100644 --- a/packages/typegraph/src/ontology/validation.ts +++ b/packages/typegraph/src/ontology/validation.ts @@ -1,9 +1,17 @@ +import { + type CompositionRealization, + isCompositionMetaEdge, + normalizePartWhole, + ontologyRelationIdentityKey, +} from "../registry/composition-relation"; import { computeDisjointExpansionClosures, + computeEquivalenceClasses, expandDisjointSide, } from "../registry/kind-registry"; import { computeTransitiveClosure } from "./closures"; import { + isEquivalenceMetaEdge, META_EDGE_BROADER, META_EDGE_DISJOINT_WITH, META_EDGE_HAS_PART, @@ -18,14 +26,20 @@ export type NamedOntologyRelation = Readonly<{ metaEdge: string; from: string; to: string; -}>; +}> & + CompositionRealization; type OntologyValidationIssueCode = | "ONTOLOGY_CYCLE" | "ONTOLOGY_SELF_LOOP" | "ONTOLOGY_DISJOINT_CONFLICT" | "ONTOLOGY_INVERSE_MULTIPLE_PARTNERS" - | "DUPLICATE_ONTOLOGY_RELATION"; + | "ONTOLOGY_EQUIVALENCE_INVALID_CLASS" + | "DUPLICATE_ONTOLOGY_RELATION" + | "ONTOLOGY_COMPOSITION_VIA_REQUIRED" + | "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN" + | "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN" + | "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN"; export type OntologyValidationIssue = Readonly<{ relationIndex?: number; @@ -34,6 +48,26 @@ export type OntologyValidationIssue = Readonly<{ details: Readonly>; }>; +/** + * `validateOntologyRelations`'s issue shape when called WITHOUT a `kinds` + * classifier: `detectInvalidEquivalenceClasses` returns immediately in that + * case (see its docstring), so `"ONTOLOGY_EQUIVALENCE_INVALID_CLASS"` is + * PROVABLY absent from the result, not merely unlikely. The no-`kinds` + * overload below encodes that as a type, which is what lets + * `graph-extension/validation.ts` assign an issue's `code` straight into a + * `GraphExtensionIssue` — a union that deliberately does not list this code + * (see `GRAPH_EXTENSION_ISSUE_CODES`) — without a cast. + */ +export type OntologyValidationIssueWithoutEquivalenceClass = Readonly<{ + relationIndex?: number; + message: string; + code: Exclude< + OntologyValidationIssueCode, + "ONTOLOGY_EQUIVALENCE_INVALID_CLASS" + >; + details: Readonly>; +}>; + const STRICTLY_HIERARCHICAL: ReadonlySet = new Set([ META_EDGE_SUB_CLASS_OF, META_EDGE_BROADER, @@ -42,6 +76,13 @@ const STRICTLY_HIERARCHICAL: ReadonlySet = new Set([ META_EDGE_HAS_PART, ]); +// `partOf`/`hasPart` are deliberately absent here: which endpoint is the +// part is `normalizePartWhole`'s decision (`../registry/composition-relation`), +// not a second copy of it. `buildHierarchicalGroups` below routes composition +// relations through that one owner and consults this table only for the +// non-composition narrower/broader flip, which really is a different +// decision (a generic "narrower canonicalizes to broader" convention, not +// "which side is the part"). const HIERARCHICAL_NORMALIZATION: ReadonlyMap< string, Readonly<{ canonical: MetaEdgeName; flip: boolean }> @@ -49,31 +90,98 @@ const HIERARCHICAL_NORMALIZATION: ReadonlyMap< [META_EDGE_SUB_CLASS_OF, { canonical: META_EDGE_SUB_CLASS_OF, flip: false }], [META_EDGE_BROADER, { canonical: META_EDGE_BROADER, flip: false }], [META_EDGE_NARROWER, { canonical: META_EDGE_BROADER, flip: true }], - [META_EDGE_PART_OF, { canonical: META_EDGE_PART_OF, flip: false }], - [META_EDGE_HAS_PART, { canonical: META_EDGE_PART_OF, flip: true }], ]); +/** + * The relation fields only `partOf`/`hasPart` may carry, and the refusal each + * one earns on any other meta-edge. One table rather than three near-identical + * blocks, so a fourth composition qualifier is refused by adding an entry. + */ +const COMPOSITION_ONLY_FIELDS = [ + { + field: "via", + label: "a `via` edge kind", + code: "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN", + }, + { + field: "partSide", + label: "a `partSide`", + code: "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN", + }, + { + field: "existence", + label: "an `existence`", + code: "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN", + }, +] as const satisfies readonly Readonly<{ + field: keyof NamedOntologyRelation; + label: string; + code: OntologyValidationIssueCode; +}>[]; + type NormalizedHierarchicalEdge = Readonly<{ from: string; to: string; originalIndex: number; }>; +/** + * Whether a name in an ontology relation is a REGISTERED node kind, a + * REGISTERED edge kind, or neither (an external IRI, or a kind this document + * only references). Supplied by whoever knows the kinds — the registry + * builder from its own maps, the deserializer from the serialized document's + * `nodes` and `edges` records. Absent when no classification is available, in + * which case the equivalence-class kind check is skipped and the + * merged-graph registry build is the gate. + */ +export type OntologyKindClassification = Readonly<{ + isNodeKind: (name: string) => boolean; + isEdgeKind: (name: string) => boolean; +}>; + /** * Validates the semantic coherence shared by authored extensions, live graph * registries, and serialized-schema registries. + * + * Overloaded on whether `kinds` is supplied: without it, the equivalence- + * class check cannot run (see `detectInvalidEquivalenceClasses`), so the + * result is typed as never carrying `"ONTOLOGY_EQUIVALENCE_INVALID_CLASS"`. */ export function validateOntologyRelations( ontology: readonly NamedOntologyRelation[], +): readonly OntologyValidationIssueWithoutEquivalenceClass[]; +export function validateOntologyRelations( + ontology: readonly NamedOntologyRelation[], + kinds: OntologyKindClassification, +): readonly OntologyValidationIssue[]; +export function validateOntologyRelations( + ontology: readonly NamedOntologyRelation[], + kinds?: OntologyKindClassification, ): readonly OntologyValidationIssue[] { const issues: OntologyValidationIssue[] = []; validateSelfLoopsAndDuplicates(ontology, issues); detectHierarchicalCycles(ontology, issues); detectDisjointExpansionConflicts(ontology, issues); detectMultipleInversePartners(ontology, issues); + detectInvalidEquivalenceClasses(ontology, kinds, issues); + validateCompositionShape(ontology, issues); return issues; } +/** + * A reflexive `partOf`/`hasPart` pair naming its realizing edge is a + * meaningful declaration ("a Section may be part of another Section"): its + * soundness is an instance-level property (the union acyclicity check), + * not a kind-level one. A same-kind pair with no `via` is still refused + * below by `ONTOLOGY_COMPOSITION_VIA_REQUIRED`, so this only widens the + * self-loop exemption for a relation that is otherwise well-formed. + */ +function isReflexiveCompositionAllowed( + relation: NamedOntologyRelation, +): boolean { + return isCompositionMetaEdge(relation.metaEdge) && relation.via !== undefined; +} + function validateSelfLoopsAndDuplicates( ontology: readonly NamedOntologyRelation[], issues: OntologyValidationIssue[], @@ -81,7 +189,10 @@ function validateSelfLoopsAndDuplicates( const seenKeys = new Set(); for (const [index, relation] of ontology.entries()) { if (relation.from === relation.to) { - if (STRICTLY_HIERARCHICAL.has(relation.metaEdge)) { + if ( + STRICTLY_HIERARCHICAL.has(relation.metaEdge) && + !isReflexiveCompositionAllowed(relation) + ) { issues.push({ relationIndex: index, message: `Hierarchical meta-edge "${relation.metaEdge}" cannot be a self-loop ("${relation.from}" → "${relation.to}").`, @@ -101,7 +212,7 @@ function validateSelfLoopsAndDuplicates( } } - const key = `${relation.metaEdge}::${relation.from}->${relation.to}`; + const key = ontologyRelationIdentityKey(relation); if (seenKeys.has(key)) { issues.push({ relationIndex: index, @@ -120,16 +231,27 @@ function buildHierarchicalGroups( ): Map { const groups = new Map(); for (const [index, relation] of ontology.entries()) { - const normalization = HIERARCHICAL_NORMALIZATION.get(relation.metaEdge); - if (normalization === undefined) continue; // Self-loops are reported elsewhere; skip them for cycle detection. if (relation.from === relation.to) continue; - const from = normalization.flip ? relation.to : relation.from; - const to = normalization.flip ? relation.from : relation.to; - const edges = groups.get(normalization.canonical) ?? []; + let canonical: MetaEdgeName; + let from: string; + let to: string; + if (isCompositionMetaEdge(relation.metaEdge)) { + const { partKind, wholeKind } = normalizePartWhole(relation); + canonical = META_EDGE_PART_OF; + from = partKind; + to = wholeKind; + } else { + const normalization = HIERARCHICAL_NORMALIZATION.get(relation.metaEdge); + if (normalization === undefined) continue; + canonical = normalization.canonical; + from = normalization.flip ? relation.to : relation.from; + to = normalization.flip ? relation.from : relation.to; + } + const edges = groups.get(canonical) ?? []; edges.push({ from, to, originalIndex: index }); - groups.set(normalization.canonical, edges); + groups.set(canonical, edges); } return groups; } @@ -229,6 +351,81 @@ function detectMultipleInversePartners( } } +/** + * Refuses an equivalence class that is not a set of node kinds. + * + * `equivalentTo` is MUTUAL SUBSUMPTION between registered kinds, and + * subsumption is a node-kind relation: a node kind and an edge kind cannot + * subsume each other, and two edge kinds have no defined substitution + * semantics yet. The left parameter is widened only so an EDGE kind can be + * mapped to an external IRI, so a class is legal when it contains at most one + * registered edge kind and, if it contains one, no registered node kind. + * + * Transitivity is why this is a runtime check and not only a signature: + * `equivalentTo(edgeA, iri)` plus `equivalentTo(edgeB, iri)` puts two edge + * kinds in one class without either call spelling the pair. + * + * Only fires with a `kinds` classifier supplied — `buildValidatedKindRegistry` + * always supplies one (defaulting to the caller's own node/edge maps), so a + * graph's own ontology is always checked. `validateGraphExtension`'s + * document-scoped call omits `kinds` (an extension's ontology may name + * base-graph kinds it cannot classify on its own) and so never reaches this + * check; the graph-extension issue codes deliberately do NOT list + * `"ONTOLOGY_EQUIVALENCE_INVALID_CLASS"` for that reason. It only ever + * reaches a caller as a `ConfigurationError` thrown when the MERGED graph's + * `KindRegistry` is built (`buildKindRegistry`). + */ +function detectInvalidEquivalenceClasses( + ontology: readonly NamedOntologyRelation[], + kinds: OntologyKindClassification | undefined, + issues: OntologyValidationIssue[], +): void { + if (kinds === undefined) return; + for (const members of computeEquivalenceClasses(ontology)) { + const edgeMembers = members.filter((member) => kinds.isEdgeKind(member)); + if (edgeMembers.length === 0) continue; + const nodeMembers = members.filter((member) => kinds.isNodeKind(member)); + const reason = + nodeMembers.length > 0 ? "mixed-node-and-edge" + : edgeMembers.length > 1 ? "multiple-edge-kinds" + : undefined; + if (reason === undefined) continue; + const relationIndex = findEquivalenceRelationIndex(ontology, members); + issues.push({ + ...(relationIndex === undefined ? {} : { relationIndex }), + message: + reason === "mixed-node-and-edge" ? + `Equivalence class {${members.join(", ")}} mixes node kinds (${nodeMembers.join(", ")}) and edge kinds (${edgeMembers.join(", ")}); equivalentTo between registered kinds is mutual subsumption, which relates node kinds only.` + : `Equivalence class {${members.join(", ")}} contains more than one registered edge kind (${edgeMembers.join(", ")}); equivalentTo may map an edge kind to an external IRI, but two edge kinds have no substitution semantics.`, + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + details: { + reason, + members, + nodeKinds: nodeMembers, + edgeKinds: edgeMembers, + }, + }); + } +} + +/** The first declared equivalence relation whose endpoints are in `members`. */ +function findEquivalenceRelationIndex( + ontology: readonly NamedOntologyRelation[], + members: readonly string[], +): number | undefined { + const memberSet = new Set(members); + for (const [index, relation] of ontology.entries()) { + if ( + isEquivalenceMetaEdge(relation.metaEdge) && + memberSet.has(relation.from) && + memberSet.has(relation.to) + ) { + return index; + } + } + return undefined; +} + function recordInversePartner( edgeKind: string, partnerKind: string, @@ -250,3 +447,46 @@ function recordInversePartner( details: { edgeKind, existingPartner, conflictingPartner: partnerKind }, }); } + +/** + * The two composition shape checks that need nothing but the relation + * itself, run for every ontology relation regardless of meta-edge: `via` is + * required exactly on `partOf`/`hasPart` and forbidden everywhere else, and + * `partSide` is meaningful only alongside `via`. + * + * This ensures a persisted `partOf`/`hasPart` relation missing + * `via` is refused on load, through the same `validateOntologyRelations` + * path the compile-time builder and the extension builder already share. + * The registration-dependent checks (orientation, cardinality, exactness, + * population) run later, in `buildCompositionRelation` + * (`src/registry/composition-relation.ts`), once a `KindRegistry` exists. + */ +function validateCompositionShape( + ontology: readonly NamedOntologyRelation[], + issues: OntologyValidationIssue[], +): void { + for (const [index, relation] of ontology.entries()) { + if (isCompositionMetaEdge(relation.metaEdge)) { + if (relation.via === undefined) { + issues.push({ + relationIndex: index, + message: `${relation.metaEdge}(${relation.from}, ${relation.to}) is missing the required \`via\` edge kind.`, + code: "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + details: { ...relation }, + }); + } + continue; + } + + for (const { field, label, code } of COMPOSITION_ONLY_FIELDS) { + const value = relation[field]; + if (value === undefined) continue; + issues.push({ + relationIndex: index, + message: `Meta-edge "${relation.metaEdge}" cannot carry ${label}; only partOf/hasPart may.`, + code, + details: { metaEdge: relation.metaEdge, [field]: value }, + }); + } + } +} diff --git a/packages/typegraph/src/profiler/query-profiler.ts b/packages/typegraph/src/profiler/query-profiler.ts index 4937513f8..9c1a917a3 100644 --- a/packages/typegraph/src/profiler/query-profiler.ts +++ b/packages/typegraph/src/profiler/query-profiler.ts @@ -4,7 +4,6 @@ * Captures query patterns from the AST and generates index recommendations. * Uses Proxy to wrap Store and QueryBuilder for transparent interception. */ -import { type z } from "zod"; import { type GraphDef } from "../core/define-graph"; import { type QueryAst } from "../query/ast"; @@ -15,8 +14,8 @@ import { parseJsonPointer, } from "../query/json-pointer"; import { - createSchemaIntrospector, type SchemaIntrospector, + schemaIntrospectorFor, } from "../query/schema-introspector"; import { type Store } from "../store/store"; import { requireDefined } from "../utils/presence"; @@ -144,10 +143,7 @@ export class QueryProfiler { ); } this.#attached = true; - this.#schemaIntrospector = createSchemaIntrospector( - buildNodeSchemaMap(store.graph), - buildEdgeSchemaMap(store.graph), - ); + this.#schemaIntrospector = schemaIntrospectorFor(store.registry); return createProfiledStore(store, this); } @@ -237,28 +233,6 @@ export class QueryProfiler { } } -function buildNodeSchemaMap( - graph: GraphDef, -): ReadonlyMap> { - const entries = Object.values(graph.nodes).map( - (n) => [n.type.kind, { schema: n.type.schema }] as const, - ); - return new Map(entries); -} - -function buildEdgeSchemaMap( - graph: GraphDef, -): ReadonlyMap> { - const entries = Object.values(graph.edges).map( - (edgeRegistration) => - [ - edgeRegistration.type.kind, - { schema: edgeRegistration.type.schema }, - ] as const, - ); - return new Map(entries); -} - function resolveAccessPaths( access: ReturnType[number], schemaIntrospector: SchemaIntrospector | undefined, @@ -339,8 +313,9 @@ function getNodeEnv(): string | undefined { /** * Checks whether a JSON pointer exists in the schema for the given kind. * - * This is used to attribute multi-kind aliases (includeSubClasses) to the most - * relevant concrete kinds by verifying that the accessed pointer exists. + * This is used to attribute multi-kind aliases (`expansion: "subclasses"`) to + * the most relevant concrete kinds by verifying that the accessed pointer + * exists. * * Returns false on missing fields or invalid pointers. */ diff --git a/packages/typegraph/src/provenance/index.ts b/packages/typegraph/src/provenance/index.ts index 1ae0ebb21..959ea2ad7 100644 --- a/packages/typegraph/src/provenance/index.ts +++ b/packages/typegraph/src/provenance/index.ts @@ -1,4 +1,5 @@ -import { createClaimsVerdictThunk } from "../backend/capabilities/resolve"; +import type { BATCH_POINT_READ } from "../backend/capabilities/bundle-registry"; +import { type BundleVerdictOf } from "../backend/capabilities/resolve"; import { type EdgeRow, type GraphReadBackend, @@ -7,35 +8,36 @@ import { type LiveNodeRow, type NodeRow, rowPropsToObject, - type TombstonedNodeRow, - type TransactionBackend, } from "../backend/types"; import type { GraphDef } from "../core/define-graph"; import { projectTargetKinds } from "../core/edge-endpoints"; -import type { NodeRegistration } from "../core/types"; import { ConfigurationError, NodeNotFoundError } from "../errors"; +import type { KindRegistry } from "../registry"; import { - applyNodeResurrect, - applyNodeSoftDelete, - createNodeWriteContext, -} from "../store/operations/node-write-pipeline"; + COMPOSITION_ATTACHMENT_PAGE_SIZE, + readCompositionAttachmentsForPage, + readLiveCompositionWholes, + requiredCompositionPairs, +} from "../store/operations/composition-create"; +import { type NodeDeletePolicy } from "../store/operations/node-write-pipeline"; import { lockRecordedGraphWrite } from "../store/recorded-capture"; -import { type GraphWriteLock } from "../store/recorded-capture/clock"; import { - storeBackend, storeRuntime, transactionBackend, - transactionNodeOperationHookRunner, + transactionDeleteNodeWithPolicy, + transactionReviveNode, } from "../store/runtime-port"; import type { HistoryStore } from "../store/store"; import { type DynamicNodeCollection, type TransactionContext, } from "../store/types"; -import { compareStrings } from "../utils/compare"; +import { chunk } from "../utils/array"; +import { compareStrings, compareStringTuples } from "../utils/compare"; import { nowIso, validityWindowContainsInstant } from "../utils/date"; import { isPlainObject } from "../utils/object"; import { requireDefined } from "../utils/presence"; +import { nodeRefKey } from "../utils/tuple-key"; export type { ContributionDiagnostic, @@ -177,6 +179,40 @@ type SupportEdges = Readonly<{ justificationsByPremise: ReadonlyMap; }>; +/** + * The existence dependency composition adds to the support computation: a + * REQUIRED composition part cannot exist without a live whole, so its belief + * status follows the whole's. The maps are read once per transition alongside + * the role rows — a transition flips source properties only, never a + * composition edge or a non-fact node's liveness, so the pre- and + * post-transition snapshots share this view exactly as they share the support + * edges. + * + * `liveWholeKeys` covers only the wholes that are NOT fact rows; a fact whole + * is judged by the snapshot's own support instead. + * + * `requiredPartFactKeys` holds every fact row whose kind is a required part, + * including one whose whole could not be found: that fact is unsupported (the + * same verdict `verifyConstraintFences`' `compositionExistence` family reports + * as a violation while the part is live), which is why the set is kept + * separately from `wholeKeyByPartKey` rather than inferred from it. + */ +type CompositionExistenceDependencies = Readonly<{ + requiredPartFactKeys: ReadonlySet; + wholeKeyByPartKey: ReadonlyMap; + partKeysByWholeKey: ReadonlyMap; + /** Whole keys that are not fact rows and are live at the snapshot instant. */ + liveWholeKeys: ReadonlySet; +}>; + +const NO_COMPOSITION_EXISTENCE_DEPENDENCIES: CompositionExistenceDependencies = + { + requiredPartFactKeys: new Set(), + wholeKeyByPartKey: new Map(), + partKeysByWholeKey: new Map(), + liveWholeKeys: new Set(), + }; + type SupportSnapshot = Readonly<{ facts: ReadonlyMap; supportedFactKeys: ReadonlySet; @@ -184,6 +220,7 @@ type SupportSnapshot = Readonly<{ firingJustificationKeysByFact: ReadonlyMap; affectedFactKeys: ( sources: readonly ProvenanceNodeRef[], + supportedBefore: ReadonlySet, ) => ReadonlySet; }>; @@ -586,25 +623,127 @@ function buildSupportEdges( type SupportGraph = Readonly<{ roles: ProvenanceRows; supportEdges: SupportEdges; + compositionDependencies: CompositionExistenceDependencies; sourceRows: readonly NodeRow[]; }>; async function loadSupportGraph( backend: GraphReadBackend, + registry: KindRegistry, graphId: string, config: NormalizedConfig, + batchPointRead: BundleVerdictOf, ): Promise { // One read instant for every role read, so the whole snapshot shares a // single "currently valid" coordinate. const asOf = nowIso(); const roles = await readRoles(backend, graphId, config, asOf); const supportEdges = buildSupportEdges(config, roles); - const sourceRowsByKind = await Promise.all( - config.sourceKinds.map((kind) => - findNodeRows(backend, graphId, kind, { includeTombstones: false, asOf }), + const [compositionDependencies, sourceRowsByKind] = await Promise.all([ + readCompositionExistenceDependencies( + backend, + registry, + graphId, + roles.facts, + batchPointRead, ), + Promise.all( + config.sourceKinds.map((kind) => + findNodeRows(backend, graphId, kind, { + includeTombstones: false, + asOf, + }), + ), + ), + ]); + return { + roles, + supportEdges, + compositionDependencies, + sourceRows: sourceRowsByKind.flat(), + }; +} + +/** + * Reads which fact rows are required composition parts and which whole each + * currently hangs from, through `readCompositionAttachmentsForPage` — the same + * owner `verifyConstraintFences`' `compositionExistence` family reads, one + * bounded candidate read per page per orientation with a per-row confirmation + * for a negative — so provenance re-spells neither composition orientation nor + * the whole-side population predicate, nor the shape of that read. + * + * A graph that declares no required part costs no read at all: the registry + * answers `compositionExistence` per kind from its memoized relation. + * + * A whole that is itself a fact carries a belief status, so its support decides + * its parts'. Any other whole (a plain node, or a source — whose retraction is + * a property flip, not a liveness change) is judged by `readLiveCompositionWholes`, + * the whole-endpoint liveness owner: present and not tombstoned. Deliberately + * NOT a validity-window question. A whole whose window has closed is still a + * live row that the write path would accept as a whole, so treating it as dead + * here would make a part unbelieved before a transition and tombstoned by it — + * a close the report could not even name, since the fact was never believed. + */ +async function readCompositionExistenceDependencies( + backend: GraphReadBackend, + registry: KindRegistry, + graphId: string, + facts: ReadonlyMap, + batchPointRead: BundleVerdictOf, +): Promise { + const requiredPartRows = [...facts].filter( + ([, row]) => registry.compositionExistence(row.kind) === "required", + ); + if (requiredPartRows.length === 0) { + return NO_COMPOSITION_EXISTENCE_DEPENDENCIES; + } + + const requiredPartFactKeys = new Set(); + const wholeKeyByPartKey = new Map(); + const partKeysByWholeKey = new Map(); + for (const [factKey] of requiredPartRows) requiredPartFactKeys.add(factKey); + + for (const page of chunk( + requiredPartRows, + COMPOSITION_ATTACHMENT_PAGE_SIZE, + )) { + const attachments = await readCompositionAttachmentsForPage( + registry, + backend, + graphId, + page.map(([, row]) => row), + ); + for (const [factKey, row] of page) { + const attachment = attachments.get(nodeRefKey(row)); + if (attachment === undefined) continue; + const wholeKey = refKey({ + kind: attachment.whole.kind, + id: attachment.whole.id, + }); + wholeKeyByPartKey.set(factKey, wholeKey); + appendGroupedValue(partKeysByWholeKey, wholeKey, factKey); + } + } + + const nonFactWholeKeys = [...new Set(wholeKeyByPartKey.values())].filter( + (wholeKey) => !facts.has(wholeKey), + ); + const liveWholes = await readLiveCompositionWholes( + backend, + graphId, + nonFactWholeKeys.map((wholeKey) => keyToRef(wholeKey)), + batchPointRead, ); - return { roles, supportEdges, sourceRows: sourceRowsByKind.flat() }; + const liveWholeKeys = new Set( + liveWholes.map((whole) => refKey({ kind: whole.kind, id: whole.id })), + ); + + return { + requiredPartFactKeys, + wholeKeyByPartKey, + partKeysByWholeKey, + liveWholeKeys, + }; } function availableSourceKeys( @@ -627,10 +766,13 @@ function availableSourceKeys( * snapshots with different availability sets. */ function computeSupportSnapshot( - graph: Pick, + graph: Pick< + SupportGraph, + "roles" | "supportEdges" | "compositionDependencies" + >, available: ReadonlySet, ): SupportSnapshot { - const { roles, supportEdges } = graph; + const { roles, supportEdges, compositionDependencies } = graph; const supported = new Set(); const inSet = new Set(available); const firingJustifications = new Set(); @@ -646,6 +788,7 @@ function computeSupportSnapshot( if (!allPremisesSupported(premises, inSet)) continue; firingJustifications.add(justificationKey); + changed = true; const derivedFacts = supportEdges.factsByJustification.get(justificationKey) ?? []; for (const factKey of derivedFacts) { @@ -655,12 +798,30 @@ function computeSupportSnapshot( factKey, justificationKey, ); - if (supported.has(factKey)) continue; - supported.add(factKey); - inSet.add(factKey); - changed = true; } } + // A derived fact is supported only once its composition existence + // dependency also holds, so a required part whose whole is unsupported + // stays out of the set even while its own justification fires. Both + // conjuncts only ever grow, so this remains the least fixpoint; the + // dependency is re-checked every round because a whole that becomes + // supported later admits the parts an earlier round held back. + for (const factKey of firingJustificationKeysByFact.keys()) { + if (supported.has(factKey)) continue; + if ( + !compositionExistenceSupported( + compositionDependencies, + roles, + supported, + factKey, + ) + ) { + continue; + } + supported.add(factKey); + inSet.add(factKey); + changed = true; + } } const believed = new Set(); @@ -676,18 +837,34 @@ function computeSupportSnapshot( firingJustificationKeysByFact, affectedFactKeys( targets: readonly ProvenanceNodeRef[], + supportedBefore: ReadonlySet, ): ReadonlySet { - return computeAffectedFactKeys(targets, roles, supportEdges); + return computeAffectedFactKeys( + targets, + roles, + supportEdges, + compositionDependencies, + supportedBefore, + supported, + ); }, }; } async function computeSupport( backend: GraphReadBackend, + registry: KindRegistry, graphId: string, config: NormalizedConfig, + batchPointRead: BundleVerdictOf, ): Promise { - const graph = await loadSupportGraph(backend, graphId, config); + const graph = await loadSupportGraph( + backend, + registry, + graphId, + config, + batchPointRead, + ); return computeSupportSnapshot( graph, availableSourceKeys(graph.sourceRows, config.retractedField), @@ -708,10 +885,46 @@ function allPremisesSupported( return true; } +/** + * THE support question composition adds: may this fact be supported at all, + * given that a required composition part cannot exist without its whole? + * + * A fact that is not a required part always may. A required part may only + * while the whole it currently hangs from is itself supported (a whole that is + * a fact carries its own belief status) or live (any other whole). A required + * part with no current whole may not: nothing holds it up. + */ +function compositionExistenceSupported( + dependencies: CompositionExistenceDependencies, + roles: ProvenanceRows, + supported: ReadonlySet, + factKey: string, +): boolean { + if (!dependencies.requiredPartFactKeys.has(factKey)) return true; + const wholeKey = dependencies.wholeKeyByPartKey.get(factKey); + if (wholeKey === undefined) return false; + if (roles.facts.has(wholeKey)) return supported.has(wholeKey); + return dependencies.liveWholeKeys.has(wholeKey); +} + +/** + * Whether bringing this fact row in line with its post-transition support + * writes it: a live row that is unsupported closes, a tombstoned row that is + * supported reopens. The one statement of "this row's currency moves", shared + * by the walk that scopes a whole's parts. The close and reopen passes select + * exactly these rows from the scope that walk builds. + */ +function currencyMovesWithSupport(row: NodeRow, supported: boolean): boolean { + return supported ? isTombstonedNodeRow(row) : isLiveNodeRow(row); +} + function computeAffectedFactKeys( sources: readonly ProvenanceNodeRef[], roles: ProvenanceRows, supportEdges: SupportEdges, + compositionDependencies: CompositionExistenceDependencies, + supportedBefore: ReadonlySet, + supportedAfter: ReadonlySet, ): ReadonlySet { const affected = new Set(); // Seed the BFS with every source at once: the union of facts reachable from @@ -736,6 +949,36 @@ function computeAffectedFactKeys( frontier.push(factKey); } } + + // A whole carries its required parts into scope when this transition + // moves the whole: its support changes, or its currency does (a live whole + // that is unsupported after is about to close, a tombstoned whole that is + // supported after is about to reopen — even when its support verdict did + // not change, as for a fact reached while already unsupported). Either + // way its parts cannot stay as they are, and a part that is itself a + // whole folds out of the same walk. A whole reached with neither change + // (another of its justifications still fires and its row is in step) + // leaves its parts alone: their existence dependency has the same + // verdict before and after. Only a fact whole propagates: a transition + // flips source properties, so a non-fact whole's liveness is the same + // before and after. + const wholeRow = roles.facts.get(current); + if (wholeRow === undefined) continue; + if ( + supportedBefore.has(current) === supportedAfter.has(current) && + !currencyMovesWithSupport(wholeRow, supportedAfter.has(current)) + ) { + continue; + } + const requiredParts = + compositionDependencies.partKeysByWholeKey.get(current) ?? []; + for (const partKey of requiredParts) { + if (!roles.facts.has(partKey)) continue; + affected.add(partKey); + if (seen.has(partKey)) continue; + seen.add(partKey); + frontier.push(partKey); + } } return affected; @@ -749,11 +992,14 @@ function buildReport< before: SupportSnapshot, after: SupportSnapshot, affected: ReadonlySet, + closing: ReadonlyMap, ): RetractionReport { const believedBefore = [...before.believedFactKeys]; - const died = sortedReferences( - believedBefore.filter((key) => !after.supportedFactKeys.has(key)), - ); + // Every fact the transition closes, which is what `closing` IS — not a + // re-derivation over what was believed beforehand. A fact that was already + // unsupported when this transition reached it still loses its currency here, + // so it is named here. + const died = sortedReferences(closing.keys()); const unaffected = sortedReferences( believedBefore.filter((key) => !affected.has(key)), ); @@ -769,156 +1015,110 @@ function buildReport< return { died, survivedVia, unaffected }; } -/** - * Brings fact currency in line with the post-transition support snapshot — - * but only for the facts the transition could have affected (those reachable - * from the flipped sources through justification edges). Facts outside that - * set are never touched: an unsupported live fact elsewhere in the graph - * (e.g. one whose justification edges have not been linked yet) is not this - * transition's business, and silently tombstoning it would be invisible data - * loss the report cannot even mention. - * - * Closes run before reopens: a reopen re-checks uniqueness, and the unique - * key it needs may still be held by a fact this same transition is about to - * close — closing first makes a legal transition order-independent. - */ -/** - * Runs `action` over every fact row in `snapshot` that is both affected by the - * transition and matches `isTargetKey`/`isTargetRow`, concurrently — the - * matched rows are independent facts, so there is no ordering dependency - * within a single pass (only between the close pass and the reopen pass, - * which the caller sequences). - */ -async function forEachAffectedFact( +/** The affected fact rows matching both predicates, keyed by fact. */ +function selectAffectedFactRows( snapshot: SupportSnapshot, affected: ReadonlySet, isTargetKey: (key: string) => boolean, isTargetRow: (row: NodeRow) => row is T, - action: (row: T) => Promise, -): Promise { - const rows: T[] = []; +): ReadonlyMap { + const rows = new Map(); for (const [key, row] of snapshot.facts) { if (!affected.has(key)) continue; if (!isTargetKey(key)) continue; if (!isTargetRow(row)) continue; - rows.push(row); + rows.set(key, row); } - await Promise.all(rows.map((row) => action(row))); + return rows; } /** - * The transaction-scoped hook lifecycle fact close/reopen runs through — - * transaction runtime's operation-hook runner. Using the - * transaction's runner (not the store's) defers each fact's success hook - * until the transition's COMMIT, so hooks never report a closed or reopened - * fact that a failed commit then rolls back. + * THE set of facts this transition closes: affected by it, unsupported in the + * post-transition snapshot, and still a live row. + * + * Computed ONCE per transition and handed to both consumers — the close pass + * that tombstones these rows and the report that names them in `died` — rather + * than spelled twice. A report that re-derived its own answer (previously + * "believed before and unsupported after") could omit a row the close pass + * tombstones: a fact found live but ALREADY unsupported when the transition + * reaches it is closed, and an unnamed close is invisible data loss. */ -type RunNodeOperationHooks = ( - operation: "create" | "update" | "delete", - kind: string, - id: string, - fn: () => Promise, -) => Promise; - -async function synchronizeFactCurrency( - backend: TransactionBackend, - store: HistoryStore, - runHooks: RunNodeOperationHooks, +function closingFactRows( snapshot: SupportSnapshot, affected: ReadonlySet, - lock: GraphWriteLock, -): Promise { - await forEachAffectedFact( +): ReadonlyMap { + return selectAffectedFactRows( snapshot, affected, (key) => !snapshot.supportedFactKeys.has(key), isLiveNodeRow, - (row) => closeFactCurrency(backend, store, runHooks, row, lock), - ); - await forEachAffectedFact( - snapshot, - affected, - (key) => snapshot.supportedFactKeys.has(key), - isTombstonedNodeRow, - (row) => reopenFactCurrency(backend, store, runHooks, row, lock), - ); -} - -function getFactRegistration( - store: HistoryStore, - row: NodeRow, -): NodeRegistration { - const registration = store.graph.nodes[row.kind]; - if (registration !== undefined) return registration; - throw new ConfigurationError( - `Provenance fact kind "${row.kind}" is not a node kind in this graph.`, - { kind: row.kind, graphId: store.graphId }, ); } -async function closeFactCurrency( - backend: TransactionBackend, - store: HistoryStore, - runHooks: RunNodeOperationHooks, - row: LiveNodeRow, - lock: GraphWriteLock, -): Promise { - await runHooks("delete", row.kind, row.id, async () => { - const registration = getFactRegistration(store, row); - // The canonical soft-delete steps (tombstone, uniqueness/embedding/ - // fulltext cleanup) WITHOUT delete-behavior enforcement: closing a - // fact's currency is a belief-status change, not a domain delete, so - // its connected edges neither block the close (`restrict`) nor get - // removed (`cascade` / `disconnect`). Every edge survives untouched, - // making a later reopen an exact inverse of this close. - await applyNodeSoftDelete( - createNodeWriteContext( - store.graphId, - store.registry, - lock, - createClaimsVerdictThunk(storeBackend(store)), - requireDefined(storeRuntime(store).uniqueSidecarBatch), - ), - { - existing: row, - schema: registration.type.schema, - uniqueConstraints: registration.unique ?? [], - onDelete: registration.onDelete, - }, - backend, - { enforceDeleteBehavior: false }, - ); - }); -} +/** + * A belief-status close: the canonical soft delete WITHOUT delete-behavior + * enforcement or composition cascade. Closing a fact's currency is not a + * domain delete, so its connected edges neither block the close (`restrict`) + * nor get removed (`cascade` / `disconnect`) — every edge survives untouched, + * so a later reopen restores the same neighbourhood. No part is cascaded + * either: the REQUIRED parts of a closed whole are closed by this same + * transition instead, because the support computation makes a required part + * support-dependent on its whole, so they arrive here as facts of their own, + * and an optional part is left alone. + */ +const FACT_CURRENCY_CLOSE_POLICY: NodeDeletePolicy = { + enforceDeleteBehavior: false, + cascadeComposition: false, +}; -async function reopenFactCurrency( - backend: TransactionBackend, - store: HistoryStore, - runHooks: RunNodeOperationHooks, - row: TombstonedNodeRow, - lock: GraphWriteLock, +/** + * Brings fact currency in line with the post-transition support snapshot — + * but only for the facts the transition could have affected (those reachable + * from the flipped sources through justification edges). Facts outside that + * set are never touched: an unsupported live fact elsewhere in the graph + * (e.g. one whose justification edges have not been linked yet) is not this + * transition's business, and silently tombstoning it would be invisible data + * loss the report cannot even mention. + * + * Closes run before reopens: a reopen re-checks uniqueness, and the unique + * key it needs may still be held by a fact this same transition is about to + * close — closing first makes a legal transition order-independent. + * + * Which rows the close pass writes is NOT decided here: the caller resolves + * them once through {@link closingFactRows} and hands the same map to the + * report, so every tombstone this pass writes is named in `died`. + * + * Both passes go through the transaction's own node operations — the one + * in-frame delete and revive owners — so each fact takes exactly the steps + * any node delete or revival owes (uniqueness, embeddings, fulltext, identity + * detach on close and restore on reopen) under the transaction's buffered + * hook runner, which defers every success hook until the transition's COMMIT. + * One fact at a time: the writes share the transaction's one connection. + */ +async function synchronizeFactCurrency( + tx: TransactionContext, + snapshot: SupportSnapshot, + affected: ReadonlySet, + closing: ReadonlyMap, ): Promise { - await runHooks("update", row.kind, row.id, async () => { - const registration = getFactRegistration(store, row); - // The delete removed this fact's uniqueness entries, so reopen re-checks and - // re-inserts them (rather than the diff-based update path) before clearing - // the tombstone and re-syncing embeddings/fulltext. - await applyNodeResurrect( - createNodeWriteContext( - store.graphId, - store.registry, - lock, - createClaimsVerdictThunk(storeBackend(store)), - requireDefined(storeRuntime(store).uniqueSidecarBatch), - ), - { - existing: row, - schema: registration.type.schema, - uniqueConstraints: registration.unique ?? [], - }, - backend, + // The rows the caller already resolved through `closingFactRows`, so the + // pass that writes and the report that names them cannot disagree. + for (const row of closing.values()) { + await transactionDeleteNodeWithPolicy( + tx, + { kind: row.kind, id: row.id }, + FACT_CURRENCY_CLOSE_POLICY, ); - }); + } + const reopening = selectAffectedFactRows( + snapshot, + affected, + (key) => snapshot.supportedFactKeys.has(key), + isTombstonedNodeRow, + ); + for (const row of reopening.values()) { + await transactionReviveNode(tx, { kind: row.kind, id: row.id }); + } } function getTransactionNodeCollection( @@ -953,6 +1153,7 @@ async function runTransition< config: NormalizedConfig, sources: readonly ProvenanceNodeRef[], retracted: boolean, + batchPointRead: BundleVerdictOf, ): Promise> { const uniqueSources = uniqueNodeReferences(sources); for (const source of uniqueSources) { @@ -969,9 +1170,8 @@ async function runTransition< // first read. `store.transaction` itself takes no lock (write operations // acquire it at their own boundaries), but a transition's support // snapshot must not race a concurrent transition or history write, so - // the per-graph write lock is acquired explicitly up front. The token is - // the evidence the currency-sync pipeline steps require. - const lock = await lockRecordedGraphWrite(backend, store.graphId); + // the per-graph write lock is acquired explicitly up front. + await lockRecordedGraphWrite(backend, store.graphId); const rows = await Promise.all( uniqueSources.map((source) => backend.getNode(store.graphId, source.kind, source.id), @@ -989,7 +1189,13 @@ async function runTransition< // premise/derive edges) is identical before and after the transition — only // source availability changes — so the pre- and post-flip snapshots share // this read instead of scanning the whole graph twice per retraction. - const supportGraph = await loadSupportGraph(backend, store.graphId, config); + const supportGraph = await loadSupportGraph( + backend, + store.registry, + store.graphId, + config, + batchPointRead, + ); const availableBefore = availableSourceKeys( supportGraph.sourceRows, config.retractedField, @@ -1012,16 +1218,18 @@ async function runTransition< } } const after = computeSupportSnapshot(supportGraph, availableAfter); - const affected = after.affectedFactKeys(uniqueSources); - await synchronizeFactCurrency( - backend, - store, - transactionNodeOperationHookRunner(tx), + const affected = after.affectedFactKeys( + uniqueSources, + before.supportedFactKeys, + ); + const closing = closingFactRows(after, affected); + await synchronizeFactCurrency(tx, after, affected, closing); + return buildReport( + before, after, affected, - lock, + closing, ); - return buildReport(before, after, affected); }); } @@ -1050,6 +1258,13 @@ export function createRetractionCapability< > { assertRetractionHistoryEnabled(store); const normalized = normalizeConfig(store.graph, config); + assertRequiredPartsOfFactWholesAreFacts(store.registry, normalized); + // The store's OWN verdict, minted once at its construction — never a second + // resolution for the same backend — and BOUND per read against the object + // that read runs on (`bindExtraIfReachable`, inside + // `readLiveCompositionWholes`), so a transaction target that implements less + // than the root still falls back instead of reaching a member it lacks. + const batchPointRead = storeRuntime(store).batchPointRead; // The four retract verbs differ only in one/many source shape and the target // retracted flag; the generics are fixed by the config, so hoist one call. @@ -1062,7 +1277,7 @@ export function createRetractionCapability< SourceKindsFromConfig, FactKindsFromConfig, JustificationKindFromConfig - >(store, normalized, sources, retracted); + >(store, normalized, sources, retracted, batchPointRead); return { retract: (source) => transition([source], true), @@ -1080,8 +1295,10 @@ export function createRetractionCapability< await lockRecordedGraphWrite(backend, store.graphId); const snapshot = await computeSupport( backend, + store.registry, store.graphId, normalized, + batchPointRead, ); return sortedReferences>( snapshot.believedFactKeys, @@ -1091,6 +1308,62 @@ export function createRetractionCapability< }; } +/** + * Refuses a configuration whose fact kinds own required composition parts the + * capability cannot reach. + * + * A required part's belief status follows its whole's, and the close that + * enforces that runs over FACTS. So a fact kind that is the whole of a + * required pair whose part kind is not itself a fact kind is a hole no + * transition can fill: closing the whole would leave a live required part + * hanging from a closed whole, exactly the state this capability exists to + * prevent. Refused at configuration time, where the graph's author can fix it, + * rather than at the first retraction that happens to reach such a whole. + * + * Both sides are read through the registry's composition readers and expanded + * through subsumption: the whole side because a fact kind may be a SUBCLASS of + * the kind a pair declared its whole against, the part side because a subclass + * of a declared required part kind is exactly as required as the declared kind + * (`requiredCompositionPartKinds` expands the same way). + */ +function assertRequiredPartsOfFactWholesAreFacts( + registry: KindRegistry, + config: NormalizedConfig, +): void { + const factKinds = new Set(config.factKinds); + const offenders: Readonly<{ wholeKind: string; partKind: string }>[] = []; + for (const pair of requiredCompositionPairs(registry)) { + for (const wholeKind of config.factKinds) { + if (!registry.isAssignableTo(wholeKind, pair.wholeKind)) continue; + for (const partKind of pair.partKinds) { + if (factKinds.has(partKind)) continue; + offenders.push({ wholeKind, partKind }); + } + } + } + if (offenders.length === 0) return; + + const ordered = offenders.toSorted((left, right) => + compareStringTuples( + [left.wholeKind, left.partKind], + [right.wholeKind, right.partKind], + ), + ); + const first = requireDefined(ordered[0], "a non-empty offender list"); + throw new ConfigurationError( + `Provenance fact kind "${first.wholeKind}" is the whole of required composition part kind "${first.partKind}", which is not a fact kind.`, + { + code: "PROVENANCE_REQUIRED_PART_NOT_A_FACT", + wholeKind: first.wholeKind, + partKind: first.partKind, + requiredParts: ordered, + }, + { + suggestion: `Add "${first.partKind}" to fact.kinds (and give it a \`derives\` endpoint), or declare that composition pair \`existence: "optional"\`.`, + }, + ); +} + function assertRetractionHistoryEnabled( store: Readonly<{ historyEnabled: boolean; graphId: string }>, ): void { diff --git a/packages/typegraph/src/query/ast.ts b/packages/typegraph/src/query/ast.ts index 49934dd47..a774477cc 100644 --- a/packages/typegraph/src/query/ast.ts +++ b/packages/typegraph/src/query/ast.ts @@ -10,6 +10,9 @@ import { type NullCheckOp, type TemporalMode, } from "../core/types"; +import { type RowMembership } from "../registry/row-membership"; +import { type Assert, type Equal } from "../utils/type-assert"; +import { type AliasExpansionAxis } from "./builder/alias-expansion"; import { type DatabaseExpression } from "./expressions"; import { type JsonPointer } from "./json-pointer"; @@ -398,8 +401,15 @@ export type PredicateExpression = */ type QueryStart = Readonly<{ alias: string; - kinds: readonly string[]; // Expanded via ontology if includeSubClasses - includeSubClasses: boolean; + kinds: readonly string[]; // Expanded per `expansion` (subclasses/narrower) + /** + * Which expansion axis produced `kinds` — see + * `src/query/builder/alias-expansion.ts`. Diagnostic only: error details and + * suggestions report it, but no compilation or execution decision may read + * it. `kinds` is the resolved decision; a later recursive stage re-roots on a + * traversal alias and labels it `"exact"` whatever axis resolved that alias. + */ + expansion: AliasExpansionAxis; }>; // ============================================================ @@ -434,6 +444,12 @@ export type VariableLengthSpec = Readonly<{ minDepth: number; /** Maximum number of hops (-1 = unlimited, default: -1) */ maxDepth: number; + /** + * Walk to the end of every path with no hop ceiling: termination rests on + * cycle prevention, so it requires `cyclePolicy: "prevent"`, and it is + * mutually exclusive with a stated `maxDepth` (leave it at -1). + */ + exhaustive?: boolean; /** * Cycle handling mode. * @@ -477,7 +493,28 @@ export type Traversal = Readonly<{ includeIdentityMembers?: boolean; /** Variable-length traversal configuration (for recursive CTEs) */ variableLength?: VariableLengthSpec; -}>; + /** + * Present on a composition navigation (`parts()` / `wholes()`): each walked + * edge kind's composition membership rule. The traversal's edges are then + * read by ownership — a `"openRows"` kind's row binds only while open, an + * `"allRows"` kind's for its whole life — at the read's instant, rather + * than by the read's validity window. + */ + compositionMembership?: readonly Readonly<{ + edgeKind: string; + membership: "allRows" | "openRows"; + }>[]; +}>; + +// Spelled inline above so the public `Traversal` type names no unexported +// symbol; pinned here so it cannot drift from the rule's own type. +// eslint-disable-next-line @typescript-eslint/no-unused-vars -- compile-time assertion +type _compositionMembershipMatchesRowMembership = Assert< + Equal< + NonNullable[number]["membership"], + RowMembership + > +>; /** * Returns the full set of edge kind names for a traversal, merging diff --git a/packages/typegraph/src/query/builder.ts b/packages/typegraph/src/query/builder.ts index 9f4444534..624eebd69 100644 --- a/packages/typegraph/src/query/builder.ts +++ b/packages/typegraph/src/query/builder.ts @@ -10,6 +10,7 @@ import { type GraphDef } from "../core/define-graph"; import { ConfigurationError } from "../errors"; import { type KindRegistry } from "../registry/kind-registry"; +import { resolveDefaultAliasExpansion } from "./builder/alias-expansion"; import { type CreateQueryBuilderOptions, type EmptyAliasMap, @@ -24,7 +25,7 @@ import { type QueryBuilderInternalContext, registerQueryBuilderInternalContext, } from "./builder/internal-context"; -import { createSchemaIntrospector } from "./schema-introspector"; +import { schemaIntrospectorFor } from "./schema-introspector"; // Re-export all classes export { @@ -57,10 +58,13 @@ export { export type { AggregateResult, AliasMap, + AssignableNodeKinds, BatchableQuery, BatchResults, CommonPropertyKeys, CompiledOneStatementRead, + CompositionNavigationOptions, + CompositionWholeKinds, DynamicEdgeAccessor, DynamicEdgeType, DynamicFieldBuilder, @@ -99,6 +103,7 @@ export type { SelectableNode, SelectContext, StreamOptions, + SubsumedNodeType, TraversalExpansion, } from "./builder/index"; @@ -176,10 +181,7 @@ function createQueryBuilderWithContext< registry: KindRegistry, options?: InternalCreateQueryBuilderOptions, ): InitialQueryBuilder { - const schemaIntrospector = createSchemaIntrospector( - registry.nodeKinds, - registry.edgeKinds, - ); + const schemaIntrospector = schemaIntrospectorFor(registry); if (options?.identityEnabled === true && registry.identity === undefined) { throw new ConfigurationError( "Identity-aware query compilation requires an identity-enabled graph registry.", @@ -210,6 +212,10 @@ function createQueryBuilderWithContext< registry, schemaIntrospector, defaultTraversalExpansion: options?.defaultTraversalExpansion ?? "inverse", + defaultExpansion: resolveDefaultAliasExpansion( + options?.defaultExpansion, + "defaultExpansion", + ), identityEnabled: options?.identityEnabled ?? registry.identity !== undefined, identitySameIdAcrossKinds: @@ -244,7 +250,8 @@ function createQueryBuilderWithContext< startAlias: "", currentAlias: "", startKinds: [], - includeSubClasses: false, + startRequestedKinds: [], + startExpansion: "exact", traversals: [], predicates: [], projection: [], diff --git a/packages/typegraph/src/query/builder/alias-expansion.ts b/packages/typegraph/src/query/builder/alias-expansion.ts new file mode 100644 index 000000000..0b99d611d --- /dev/null +++ b/packages/typegraph/src/query/builder/alias-expansion.ts @@ -0,0 +1,492 @@ +/** + * The one owner of "which expansion axis does this alias use". + * + * An alias carries EXACTLY ONE expansion axis, and states it as one option + * value rather than as a set of booleans a caller could combine into a + * contradiction: + * + * - `"exact"` — only the named kind. + * - `"subclasses"` — the kind and every `subClassOf`/`equivalentTo` + * descendant (`registry.expandSubClasses`). The store-wide default: + * a supertype query is polymorphic unless narrowed. + * - `"narrower"` — the kind and every `broader`/`narrower` descendant + * (`registry.expandNarrower`). No schema relationship is claimed, so + * the alias type is untyped. + * + * The axis-unknown overload of `from()` / `to()` / `fromDynamic()` / + * `toDynamic()` covers two call shapes with one rule: a `"narrower"` + * expansion (no schema relationship is claimed, so no per-kind type can be + * promised) and a forwarded options bag whose axis is not one literal — the + * option type itself, or a wrapper's `{ expansion?: "exact" }`. Neither pins + * the axis at compile time, so the alias takes the conservative untyped + * form; state a literal axis at the call site to keep the precise alias type. + * + * `from()` (a single kind or a list) / `to()` / `fromDynamic()` / + * `toDynamic()` and `store.search()` all resolve their expansion through + * this module and nothing else re-derives it — including the refusal of an + * expansion the alias's static type does not admit + * (`expandKindsForAxis`). + */ +import { type GraphDef } from "../../core/define-graph"; +import { ConfigurationError } from "../../errors"; +import { + isSubsumptionMetaEdge, + META_EDGE_SUB_CLASS_OF, +} from "../../ontology/constants"; +import { type OntologyRelation } from "../../ontology/types"; +import { type KindRegistry } from "../../registry/kind-registry"; + +export type AliasExpansionAxis = "exact" | "subclasses" | "narrower"; + +/** + * Every axis a STORE-WIDE default may name. `"narrower"` is excluded on + * purpose: an alias declared with no options is typed as its declared kind + * (or the polymorphic widening of it), and a `broader`/`narrower` expansion + * claims no schema relationship at all — defaulting a whole store onto that + * axis would hand every untyped-by-default alias rows of kinds its type + * never promised. + */ +export type DefaultAliasExpansionAxis = Exclude; + +/** + * The axis an alias takes when neither the call nor the store states one. + * Both places that accept a store-wide default — `createQueryBuilder` and + * `Store`'s `queryDefaults` — resolve it through + * {@link resolveDefaultAliasExpansion}, so a store-issued builder and a + * standalone one cannot drift apart. + */ +export const DEFAULT_ALIAS_EXPANSION_AXIS: DefaultAliasExpansionAxis = + "subclasses"; + +/** + * {@link DefaultAliasExpansionAxis} as a runtime list: what a store-wide + * default may name, and what the `store.search()` facade offers. One list for + * both, because both exclude `"narrower"` for the reason that type states. + */ +export const DEFAULT_ALIAS_EXPANSION_AXES: readonly DefaultAliasExpansionAxis[] = + ["exact", "subclasses"]; + +/** + * `search()`'s default, distinct from {@link DEFAULT_ALIAS_EXPANSION_AXIS}. + * The search facade is not polymorphic. `queryDefaults.expansion` does not + * apply to it. + */ +export const SEARCH_EXPANSION_DEFAULT = + "exact" as const satisfies DefaultAliasExpansionAxis; + +export type AliasExpansionOptions = Readonly<{ + /** + * The alias's expansion axis. Omitted (or `undefined`) takes the store + * default — `"subclasses"` unless the store overrides it through + * `queryDefaults.expansion`. + */ + expansion?: AliasExpansionAxis | undefined; +}>; + +const ALIAS_EXPANSION_AXES: readonly AliasExpansionAxis[] = [ + "exact", + "subclasses", + "narrower", +]; + +/** + * The one refusal for an `expansion` value a surface cannot honor. + * + * `permittedAxes` is the subset the CALLING surface offers: every axis for an + * alias, the `"narrower"`-less pair for the `store.search()` facade. Both + * decisions — "is this a known axis" and "does this surface offer it" — are + * made here, so a second surface cannot re-spell either, and a value this + * surface cannot honor is refused by name instead of being coerced to the + * surface's default. A stated option is applied or refused, never ignored. + * + * Unreachable through the typed options; reachable from JavaScript, and from + * a typed caller that casts. + * + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_INVALID`) when + * `expansion` is not one of `permittedAxes`. + */ +export function assertPermittedExpansionAxis( + expansion: AliasExpansionAxis, + permittedAxes: readonly AliasExpansionAxis[], + surface: string, +): void { + if (permittedAxes.includes(expansion)) return; + throw new ConfigurationError( + ALIAS_EXPANSION_AXES.includes(expansion) ? + `${surface} does not support expansion: "${expansion}".` + : `Unknown ${surface} expansion "${expansion}".`, + { code: "QUERY_ALIAS_EXPANSION_INVALID", expansion, surface }, + { + suggestion: `Pass one of ${permittedAxes.map((axis) => `"${axis}"`).join(", ")}.`, + }, + ); +} + +const REMOVED_INCLUDE_SUBCLASSES_OPTION = "includeSubClasses"; + +/** + * Refuses the removed `includeSubClasses` boolean, which `expansion` replaced. + * + * The option is gone from the types, so only JavaScript (or a cast) can still + * state it, and without this check it would be read as no option at all: a + * caller that wrote `{ includeSubClasses: false }` for an exact alias would + * silently get the polymorphic default. A stated option is applied or + * refused, never ignored. + * + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_INVALID`) when `options` + * states `includeSubClasses`. + */ +export function assertNoRemovedExpansionOption( + options: unknown, + surface: string, +): void { + if (typeof options !== "object" || options === null) return; + if ( + (options as Readonly>)[ + REMOVED_INCLUDE_SUBCLASSES_OPTION + ] === undefined + ) + return; + throw new ConfigurationError( + `${surface} no longer accepts "${REMOVED_INCLUDE_SUBCLASSES_OPTION}"; the \`expansion\` option replaced it.`, + { + code: "QUERY_ALIAS_EXPANSION_INVALID", + option: REMOVED_INCLUDE_SUBCLASSES_OPTION, + surface, + }, + { + suggestion: + 'Pass expansion: "exact" where includeSubClasses was false, and expansion: "subclasses" where it was true.', + }, + ); +} + +/** + * Resolves the store-wide default a `Store` (`queryDefaults.expansion`) or a + * standalone `createQueryBuilder` (`defaultExpansion`) was constructed with: + * the stated axis, or {@link DEFAULT_ALIAS_EXPANSION_AXIS} when none is + * stated. + * + * Called at construction, so a default no alias could resolve is refused + * where it was stated instead of at the first `from()` that falls back to + * it. `surface` names the option for the refusal. + * + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_INVALID`) when the + * stated default is not one of {@link DEFAULT_ALIAS_EXPANSION_AXES} — an + * out-of-domain value, or `"narrower"`. + */ +export function resolveDefaultAliasExpansion( + stated: DefaultAliasExpansionAxis | undefined, + surface: string, +): DefaultAliasExpansionAxis { + if (stated === undefined) return DEFAULT_ALIAS_EXPANSION_AXIS; + assertPermittedExpansionAxis(stated, DEFAULT_ALIAS_EXPANSION_AXES, surface); + return stated; +} + +/** + * Resolves the expansion axis for one alias: the stated `expansion`, or the + * store default when the option is absent or explicitly `undefined` (an + * unstated option, not a stated one — ordinary option forwarding). + * + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_INVALID`) when + * `expansion` names something outside the axis set — an alias offers all + * three, so only an out-of-domain value can fail here — or when the removed + * `includeSubClasses` option is stated. + */ +function resolveAliasExpansion( + options: AliasExpansionOptions | undefined, + storeDefaultExpansion: DefaultAliasExpansionAxis, +): AliasExpansionAxis { + assertNoRemovedExpansionOption(options, "alias"); + const expansion = options?.expansion; + if (expansion === undefined) return storeDefaultExpansion; + assertPermittedExpansionAxis(expansion, ALIAS_EXPANSION_AXES, "alias"); + return expansion; +} + +/** + * Resolves an alias's expansion axis and the kind list it expands `sourceKinds` + * to: {@link resolveAliasExpansion} then {@link expandKindListForAxis}, the + * pair every `from()` / `fromDynamic()` / `to()` / `toDynamic()` runs. + */ +export function resolveAliasKinds( + options: AliasExpansionOptions | undefined, + storeDefaultExpansion: DefaultAliasExpansionAxis, + sourceKinds: readonly string[], + registry: ExpansionRegistry, + typing: AliasStaticTyping, +): Readonly<{ expansion: AliasExpansionAxis; kinds: readonly string[] }> { + const expansion = resolveAliasExpansion(options, storeDefaultExpansion); + return { + expansion, + kinds: expandKindListForAxis(expansion, sourceKinds, registry, typing), + }; +} + +/** + * The surface whose default overload types an alias from the compile-time + * graph, named in a refusal's suggestion so it advises that call. + */ +export type CompileTimeTypedSurface = "from()" | "to()"; + +/** + * What the calling surface computed its alias (or hit) type from: + * + * - `"compileTimeGraph"` — `AliasNodeType`, which widens `kind` and + * `NodeId` only for a kind `G`'s ontology affects: the default overload of + * `from()` / `to()`, including list sources. + * - `"independent"` — a type that never consults `G`'s ontology: the exact + * kind, the unconditional `PolymorphicNodeType` of a stated + * `"subclasses"` alias or search, the untyped `NodeAlias`, or a runtime + * kind (`fromDynamic()` / `toDynamic()`). + */ +export type AliasStaticTyping = + | Readonly<{ + typedFrom: "compileTimeGraph"; + surface: CompileTimeTypedSurface; + }> + | Readonly<{ typedFrom: "independent" }>; + +export const INDEPENDENT_ALIAS_TYPING: AliasStaticTyping = { + typedFrom: "independent", +}; + +/** + * The {@link AliasStaticTyping} the `from()` / `to()` overloads give an + * alias for `options`: an alias that states no `expansion` takes the default + * overload, typed from the compile-time graph; every stated axis selects an + * overload whose type does not depend on it. + */ +export function aliasOverloadTyping( + options: AliasExpansionOptions | undefined, + surface: CompileTimeTypedSurface, +): AliasStaticTyping { + return options?.expansion === undefined ? + { typedFrom: "compileTimeGraph", surface } + : INDEPENDENT_ALIAS_TYPING; +} + +function endpointKind(endpoint: OntologyRelation["from"]): string | undefined { + return typeof endpoint === "string" ? undefined : endpoint.kind; +} + +/** + * The runtime reading of `SubsumptionAffected` (`./types.ts`) for the + * decided arm: the kinds an ontology relation names as a `subClassOf` target + * or as either side of an `equivalentTo` / `sameAs`. An IRI endpoint names + * no kind. The type-level arms that widen because an annotation erased the + * relation literals have no runtime counterpart: the values are identical, + * so such a graph is read as if its literals were intact. + */ +function subsumptionAffectedKinds( + ontology: readonly OntologyRelation[], +): ReadonlySet { + const affected = new Set(); + for (const relation of ontology) { + const name = relation.metaEdge.name; + if (!isSubsumptionMetaEdge(name)) continue; + const named = + name === META_EDGE_SUB_CLASS_OF ? + [relation.to] + : [relation.from, relation.to]; + for (const endpoint of named) { + const kind = endpointKind(endpoint); + if (kind !== undefined) affected.add(kind); + } + } + return affected; +} + +const compileTimeExactAliasKindsByRegistry = new WeakMap< + object, + ReadonlySet +>(); + +/** + * Records, for a store's live registry, the node kinds whose + * `AliasNodeType` the compile-time graph keeps exact: every kind of + * `compileTimeGraph` its ontology does not affect. The live registry may + * have grown past that graph through a persisted extension (`store.evolve()`, + * or `createStoreWithSchema` reloading one); {@link expandKindsForAxis} + * reads this to tell when it has. + * + * A registry with no record — one built straight from a graph by a caller — + * is its own compile-time graph, so no expansion of it can outgrow its type. + */ +export function bindCompileTimeAliasTyping( + registry: KindRegistry, + compileTimeGraph: GraphDef, +): void { + const affected = subsumptionAffectedKinds(compileTimeGraph.ontology); + compileTimeExactAliasKindsByRegistry.set( + registry, + new Set( + Object.keys(compileTimeGraph.nodes).filter((kind) => !affected.has(kind)), + ), + ); +} + +type KindExpansion = Readonly<{ + kind: string; + kinds: readonly string[]; +}>; + +/** + * Refuses an expansion that returns rows its alias type does not admit: the + * alias is typed from the compile-time graph, that graph keeps a source + * `kind` exact, and the live registry expands it to further kinds a + * persisted extension declared. Answering would hand the caller rows whose + * `kind` and `NodeId` contradict their static type, and narrowing silently + * to the exact kind would be a partial answer presented as complete. + * + * For a list source, an extra kind another member of the list's static type + * admits is not refused: a kind the list names itself (typed exactly), or one + * in the expansion of a listed kind the compile-time graph already types + * polymorphically. + * + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`) + */ +function assertExpansionWithinStaticType( + expansions: readonly KindExpansion[], + typing: AliasStaticTyping, + registry: object, +): void { + if (typing.typedFrom !== "compileTimeGraph") return; + const exactKinds = compileTimeExactAliasKindsByRegistry.get(registry); + if (exactKinds === undefined) return; + const admittedKinds = new Set( + expansions.flatMap(({ kind, kinds }) => + exactKinds.has(kind) ? [kind] : kinds, + ), + ); + for (const { kind, kinds } of expansions) { + if (!exactKinds.has(kind)) continue; + const extraKinds = kinds.filter( + (expandedKind) => !admittedKinds.has(expandedKind), + ); + if (extraKinds.length === 0) continue; + throw untypedSubclassesError( + kind, + extraKinds, + typing.surface, + expansions.length > 1, + ); + } +} + +function untypedSubclassesError( + kind: string, + extraKinds: readonly string[], + surface: CompileTimeTypedSurface, + isListSource: boolean, +): ConfigurationError { + const extraKindList = extraKinds + .map((extraKind) => `"${extraKind}"`) + .join(", "); + const staticType = + isListSource ? + `one of the listed kinds, with "${kind}" exactly` + : `"${kind}" exactly`; + const dynamicSurface = surface === "from()" ? "fromDynamic()" : "toDynamic()"; + const suggestion = + isListSource ? + `State the expansion explicitly: expansion: "subclasses" on ${surface} types every listed kind polymorphically, and expansion: "exact" returns rows of the listed kinds only.` + : `State the expansion explicitly: expansion: "subclasses" on ${surface} types the rows polymorphically, and expansion: "exact" returns "${kind}" rows only. Or use ${dynamicSurface}.`; + return new ConfigurationError( + `Expanding "${kind}" through its subclasses would return rows of ${extraKindList}, which the live schema declares under "${kind}" but the compile-time graph does not, so the static type of these rows (${staticType}) would be wrong.`, + { + code: "QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES", + kind, + extraKinds, + }, + { suggestion }, + ); +} + +type ExpansionRegistry = Pick< + KindRegistry, + "expandSubClasses" | "expandNarrower" | "hasNodeType" +>; + +/** + * The concrete kind list an alias resolves to under `axis` — the one place + * `from`/`fromDynamic`/`to`/`toDynamic` and `store.search()` turn an axis + * into `QueryStart.kinds` / `Traversal.kinds` / searched kinds. + * + * The `"narrower"` axis additionally REFUSES (never silently narrows) when + * the expansion names a kind this registry does not recognize as a node + * kind — `broader`/`narrower` accept any `NodeType`, registered or not, so + * this is reachable. `to()`/`toDynamic()` layer an additional + * endpoint-admission check on top of this list; see + * `TraversalBuilder`'s own narrower handling. + * + * `typing` states how the caller typed the alias, so an expansion past what + * that type admits is refused here, for every surface alike. + * + * @throws ConfigurationError (`ONTOLOGY_NARROWER_KIND_NOT_REGISTERED`) when + * `axis === "narrower"` and the expansion names an unregistered kind. + * @throws ConfigurationError (`QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES`) + * when `typing.typedFrom` is `"compileTimeGraph"`, the compile-time graph + * types `kind` exactly, and the live registry expands it to further kinds. + */ +export function expandKindsForAxis( + axis: AliasExpansionAxis, + kind: string, + registry: ExpansionRegistry, + typing: AliasStaticTyping, +): readonly string[] { + return expandKindListForAxis(axis, [kind], registry, typing); +} + +/** + * {@link expandKindsForAxis} for a list source: the deduplicated union of + * every listed kind's expansion, refused only for an extra kind no member of + * the list's static type admits. + */ +function expandKindListForAxis( + axis: AliasExpansionAxis, + sourceKinds: readonly string[], + registry: ExpansionRegistry, + typing: AliasStaticTyping, +): readonly string[] { + const expansions = sourceKinds.map((kind) => ({ + kind, + kinds: kindsForAxis(axis, kind, registry), + })); + assertExpansionWithinStaticType(expansions, typing, registry); + return [...new Set(expansions.flatMap(({ kinds }) => kinds))]; +} + +function kindsForAxis( + axis: AliasExpansionAxis, + kind: string, + registry: ExpansionRegistry, +): readonly string[] { + switch (axis) { + case "exact": { + return [kind]; + } + case "subclasses": { + return registry.expandSubClasses(kind); + } + case "narrower": { + const kinds = registry.expandNarrower(kind); + for (const narrowerKind of kinds) { + if (registry.hasNodeType(narrowerKind)) continue; + throw new ConfigurationError( + `expansion: "narrower" on "${kind}" includes "${narrowerKind}", which is not a registered node kind.`, + { + code: "ONTOLOGY_NARROWER_KIND_NOT_REGISTERED", + rootKind: kind, + narrowerKind, + }, + { + suggestion: `Register "${narrowerKind}" as a node kind, or remove its broader/narrower relation to "${kind}".`, + }, + ); + } + return kinds; + } + } +} diff --git a/packages/typegraph/src/query/builder/ast-builder.ts b/packages/typegraph/src/query/builder/ast-builder.ts index 8f7c93e93..5a6565e14 100644 --- a/packages/typegraph/src/query/builder/ast-builder.ts +++ b/packages/typegraph/src/query/builder/ast-builder.ts @@ -41,7 +41,7 @@ export function buildQueryAst( start: { alias: state.startAlias, kinds: state.startKinds, - includeSubClasses: state.includeSubClasses, + expansion: state.startExpansion, }, traversals: state.traversals, predicates: state.predicates, diff --git a/packages/typegraph/src/query/builder/executable-query.ts b/packages/typegraph/src/query/builder/executable-query.ts index e13520210..39bc56713 100644 --- a/packages/typegraph/src/query/builder/executable-query.ts +++ b/packages/typegraph/src/query/builder/executable-query.ts @@ -19,7 +19,9 @@ import { withRecordedRelationsPrecondition } from "../../utils/sql-errors"; import { type FieldRef, mergeEdgeKinds, + type NodePredicate, type OrderSpec, + type PredicateExpression, type QueryAst, type SelectiveField, type SortDirection, @@ -36,7 +38,7 @@ import { import { type SqlDialect } from "../dialect/types"; import { adjustOrderByForDirection, - buildCursorPredicate, + applyCursorFilters, buildPaginatedResult, buildPaginatedResultFromRows, buildSelectContext, @@ -93,7 +95,11 @@ import { type StreamOptions, } from "./types"; import { type UnionableQuery } from "./unionable-query"; -import { validatePaginationOptions, validateQueryRange } from "./validation"; +import { + refuseRecursivePagination, + validatePaginationOptions, + validateQueryRange, +} from "./validation"; const NOT_COMPUTED = Symbol("NOT_COMPUTED"); @@ -870,13 +876,20 @@ export class ExecutableQuery< }, ); } - if (ast.start.kinds.length !== 1 || ast.start.includeSubClasses) { + if (ast.start.kinds.length !== 1) { throw new ConfigurationError( "A set-update candidate query must select one concrete node kind.", { + code: "SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED", operation: "updateWhere", candidateKinds: ast.start.kinds, - includeSubClasses: ast.start.includeSubClasses, + expansion: ast.start.expansion, + }, + { + suggestion: + this.#state.startRequestedKinds.length === 1 ? + 'The candidate kind has subclasses or equivalent kinds; pass { expansion: "exact" } to from() to select only the named kind.' + : "Run one updateWhere() per node kind, each with a single-kind candidate query.", }, ); } @@ -950,10 +963,25 @@ export class ExecutableQuery< projection: { fields: [ ...ast.projection.fields, - ...(ast.orderBy ?? []).map((order, index) => ({ - outputName: oneStatementBatchOrderColumn(index), - source: order.field, - })), + ...(ast.orderBy ?? []).map((order, index) => { + // An edge alias's columns live in its traversal's node CTE, + // as they do for an edge field a select() projects. + const edgeTraversal = + order.field.__type === "field_ref" ? + ast.traversals.find( + (traversal) => + traversal.edgeAlias === + requireCursorField(order.field).alias, + ) + : undefined; + return { + outputName: oneStatementBatchOrderColumn(index), + source: order.field, + ...(edgeTraversal === undefined ? + {} + : { cteAlias: `cte_${edgeTraversal.nodeAlias}` }), + }; + }), ], }, }; @@ -1489,16 +1517,38 @@ export class ExecutableQuery< return this.#state.traversals.find((t) => t.nodeAlias === alias)?.nodeKinds; } + /** + * Whether this query's keyset identifies, and is positioned against, the + * completed MATCH ROW rather than the start node alone: it has a traversal. + * Pagination refuses a recursive traversal (`refuseRecursivePagination`), so + * every traversal that reaches here matches one edge per row. + * + * The one decision both halves of the keyset read: the tiebreaker + * ({@link #paginationOrderBy}) and the cursor's placement + * ({@link #cursorFilters}). + */ + #keysetIdentifiesMatchRows(): boolean { + return this.#state.traversals.length > 0; + } + /** * The ORDER BY used for keyset pagination: the caller's ORDER BY plus a final - * identity tiebreaker on the start alias. Without a unique final key, a + * tiebreaker that identifies the RESULT ROW. Without a unique final key, a * non-unique sort (e.g. `orderBy("p", "age")` with many equal ages) makes the * keyset predicate `age > lastAge` skip every not-yet-returned equal-age row, - * silently losing data across pages. For a multi-kind start, identity is - * `(kind, id)`; each missing component is appended after the caller's order. - * For a single-kind start, `id` alone remains sufficient. The emitted sort, - * cursor predicate, cursor encoding, and cursor validation all use this order, - * including the selective-field-optimized path. + * silently losing data across pages. + * + * A row is one start node together with one match per traversal, so its + * identity is the start node's plus, per traversal, the matched edge's id — + * an edge id is unique in the graph and determines the node it leads to, and + * two parallel edges between one pair of nodes are two rows. The start + * node's identity is `(kind, id)` when the start alias spans more than one + * kind and `id` alone otherwise. Each component the caller's order does not + * already name is appended after it, in traversal order. A query with no + * traversal keeps the start node's identity alone. + * + * The emitted sort, cursor predicate, cursor encoding, and cursor validation + * all use this order, including the selective-field-optimized path. */ #paginationOrderBy(): readonly (Omit & { field: FieldRef; @@ -1507,39 +1557,57 @@ export class ExecutableQuery< ...order, field: requireCursorField(order.field), })); - const startAlias = this.#state.startAlias; - function hasSystemOrder(fieldName: "kind" | "id"): boolean { - return orderBy.some( + function missingIdentityKey( + alias: string, + fieldName: "kind" | "id", + nullable: boolean, + ): readonly (OrderSpec & { field: FieldRef })[] { + const named = orderBy.some( (spec) => - spec.field.alias === startAlias && + spec.field.alias === alias && spec.field.path.length === 1 && spec.field.path[0] === fieldName && spec.field.jsonPointer === undefined, ); - } - const missingKind = - this.#state.startKinds.length > 1 && !hasSystemOrder("kind"); - const missingId = !hasSystemOrder("id"); - if (!missingKind && !missingId) return orderBy; - function tiebreaker(fieldName: "kind" | "id"): OrderSpec & { - field: FieldRef; - } { - return { - field: { - __type: "field_ref", - alias: startAlias, - nullable: false, - path: [fieldName], - valueType: "string", + if (named) return []; + return [ + { + field: { + __type: "field_ref", + alias, + nullable, + path: [fieldName], + valueType: "string", + }, + direction: "asc", }, - direction: "asc", - }; + ]; } - return [ - ...orderBy, - ...(missingKind ? [tiebreaker("kind")] : []), - ...(missingId ? [tiebreaker("id")] : []), + function missingNodeIdentity( + alias: string, + kinds: readonly string[], + nullable: boolean, + ): readonly (OrderSpec & { field: FieldRef })[] { + return [ + ...(kinds.length > 1 ? + missingIdentityKey(alias, "kind", nullable) + : []), + ...missingIdentityKey(alias, "id", nullable), + ]; + } + const tiebreaker = [ + ...missingNodeIdentity( + this.#state.startAlias, + this.#state.startKinds, + false, + ), + ...(this.#keysetIdentifiesMatchRows() ? + this.#state.traversals.flatMap((traversal) => + missingIdentityKey(traversal.edgeAlias, "id", traversal.optional), + ) + : []), ]; + return tiebreaker.length === 0 ? orderBy : [...orderBy, ...tiebreaker]; } /** @@ -1554,7 +1622,7 @@ export class ExecutableQuery< */ async paginate(options: PaginateOptions): Promise> { this.#refuseCheckedReadSurface("paginate"); - validatePaginationOptions(this.#state, options); + validatePaginationOptions(this.#state, options, "paginate"); if (this.#hasParameterReferences()) throw new ConfigurationError( "Cursor pagination requires bound values, not param() references.", @@ -1664,7 +1732,7 @@ export class ExecutableQuery< Required>, "execute">> { this.#refuseCheckedReadSurface("page"); const pageOptions = { ...options }; - validatePaginationOptions(this.#state, pageOptions); + validatePaginationOptions(this.#state, pageOptions, "page"); if (this.#hasParameterReferences()) throw new ConfigurationError( "Cursor pagination requires bound values, not param() references.", @@ -1704,23 +1772,11 @@ export class ExecutableQuery< validateCursorColumns(cursorData, paginationOrderBy); const direction = isBackward ? "backward" : "forward"; const orderBy = adjustOrderByForDirection(paginationOrderBy, direction); - const predicates = - cursorData === undefined ? - this.#state.predicates - : [ - ...this.#state.predicates, - buildCursorPredicate( - cursorData, - paginationOrderBy, - direction, - this.#state.startAlias, - ), - ]; const pagedQuery = new ExecutableQuery( this.#config, { ...this.#state, - predicates, + ...this.#cursorFilters(cursorData, paginationOrderBy, direction), orderBy, limit: pageLimit + 1, offset: undefined, @@ -1781,6 +1837,7 @@ export class ExecutableQuery< */ stream(options?: StreamOptions): AsyncIterable { this.#refuseCheckedReadSurface("stream"); + refuseRecursivePagination(this.#state, "stream"); // Validate ORDER BY is present if (this.#state.orderBy.length === 0) { throw new ValidationError( @@ -1815,6 +1872,40 @@ export class ExecutableQuery< ); } + /** + * This query's filters for one page: unchanged for the first page, with the + * cursor applied (`applyCursorFilters`) for every later one. Spread over + * the state by `page()` and over the AST by `#executeWithCursor`, so the + * two page builders place the cursor identically. + */ + #cursorFilters( + cursorData: CursorData | undefined, + paginationOrderBy: readonly OrderSpec[], + direction: "forward" | "backward", + ): Readonly<{ + predicates: readonly NodePredicate[]; + resultPredicate?: PredicateExpression; + }> { + const { predicates, resultPredicate } = + cursorData === undefined ? + this.#state + : applyCursorFilters( + { + startAlias: this.#state.startAlias, + positionsCompletedMatch: this.#keysetIdentifiesMatchRows(), + predicates: this.#state.predicates, + resultPredicate: this.#state.resultPredicate, + }, + cursorData, + paginationOrderBy, + direction, + ); + return { + predicates, + ...(resultPredicate === undefined ? {} : { resultPredicate }), + }; + } + /** * Executes a query with cursor conditions applied. */ @@ -1831,23 +1922,11 @@ export class ExecutableQuery< direction, ); - // Build cursor predicates if we have cursor data - let predicates = [...this.#state.predicates]; - if (cursorData) { - const cursorPredicate = buildCursorPredicate( - cursorData, - this.#paginationOrderBy(), - direction, - this.#state.startAlias, - ); - predicates = [...predicates, cursorPredicate]; - } - - // Apply modified ORDER BY, predicates, and limit to AST (discard offset) + // Apply modified ORDER BY, cursor filters, and limit to AST (discard offset) const { offset: _discarded, ...astWithoutOffset } = ast; const modifiedAst = { ...astWithoutOffset, - predicates, + ...this.#cursorFilters(cursorData, this.#paginationOrderBy(), direction), orderBy, limit, ...(options?.selectiveFields !== undefined && { diff --git a/packages/typegraph/src/query/builder/index.ts b/packages/typegraph/src/query/builder/index.ts index 2cd6f5078..90dbea1a1 100644 --- a/packages/typegraph/src/query/builder/index.ts +++ b/packages/typegraph/src/query/builder/index.ts @@ -25,7 +25,11 @@ export { export { ExecutableQuery } from "./executable-query"; export { executeOneStatementBatch } from "./one-statement-batch"; export { PreparedQuery } from "./prepared-query"; -export { type IdentityTraversalOption, QueryBuilder } from "./query-builder"; +export { + type CompositionNavigationOptions, + type IdentityTraversalOption, + QueryBuilder, +} from "./query-builder"; export { createExecutableRelation, createProjectionRelation, @@ -79,12 +83,14 @@ export { buildQueryAst } from "./ast-builder"; export { type AliasMap, type ArrayFieldAccessor, + type AssignableNodeKinds, type BaseFieldAccessor, type BatchableQuery, type BatchResults, type BooleanFieldAccessor, type CommonPropertyKeys, type CompiledOneStatementRead, + type CompositionWholeKinds, type CreateQueryBuilderOptions, type DateFieldAccessor, type EdgeAccessor, @@ -127,6 +133,7 @@ export { type SelectContext, type StreamOptions, type StringFieldAccessor, + type SubsumedNodeType, type TraversalExpansion, type UniqueAlias, type ValidEdgeTargets, diff --git a/packages/typegraph/src/query/builder/query-builder.ts b/packages/typegraph/src/query/builder/query-builder.ts index e931010c6..896121f35 100644 --- a/packages/typegraph/src/query/builder/query-builder.ts +++ b/packages/typegraph/src/query/builder/query-builder.ts @@ -19,11 +19,21 @@ import { resolveReadCoordinate, } from "../../core/temporal"; import { + type AnyEdgeType, type EdgeType, type NodeType, type TemporalMode, } from "../../core/types"; import { ConfigurationError, KindNotFoundError } from "../../errors"; +import { type PolymorphicNodeType } from "../../ontology/types"; +import { + compositionEdgeKindMembers, + compositionReadersTowards, + compositionViaKind, + compositionViaReach, + type CompositionViaRef, + partitionCompositionEdgeKindsByDirection, +} from "../../registry/composition-relation"; import { isInteropProbeKey } from "../../utils/object"; import { type AggregateExpr, @@ -54,6 +64,12 @@ import { type FieldTypeInfo, type SchemaIntrospector, } from "../schema-introspector"; +import { + type AliasExpansionOptions, + aliasOverloadTyping, + INDEPENDENT_ALIAS_TYPING, + resolveAliasKinds, +} from "./alias-expansion"; import { buildQueryAst } from "./ast-builder"; import { createDynamicFieldBuilder, @@ -86,22 +102,31 @@ import { } from "./order-by-field"; import { withRecursiveStopExpansion } from "./recursive-stop"; import { executeQueryTerminal } from "./terminal-query"; -import { TraversalBuilder } from "./traversal-builder"; +import { + EXHAUSTIVE_VARIABLE_LENGTH_STATE, + TraversalBuilder, +} from "./traversal-builder"; import { type AliasMap, + type AliasNodeType, type BaseFieldAccessor, + type BuildRecursiveAliases, type EdgeAccessor, type EdgeAlias, type EdgeAliasMap, type EmptyAliasMap, type EmptyEdgeAliasMap, type EmptyRecursiveAliasMap, + type ListAliasNodeType, + type ListPolymorphicNodeType, type NodeAccessor, type NodeAlias, + type QualifiedRecursivePathOption, type QueryBuilderConfig, type QueryBuilderState, type QueryCoordinateState, type RecursiveAliasMap, + type RecursiveTraversalOptions, type SelectContext, type UniqueAlias, } from "./types"; @@ -173,9 +198,87 @@ export type IdentityTraversalOption = type DynamicNodeTypeFor = T extends RuntimeNodeKind ? RuntimeNodeTypeFor : DynamicNodeType; +/** A nonempty list of compile-time node kinds, as `from()` accepts it. */ +type SourceKindList = readonly [ + keyof G["nodes"] & string, + ...(keyof G["nodes"] & string)[], +]; + +/** + * Options shared by `parts()` and `wholes()`. There is deliberately no + * `expand`: `expand` means "same relation, more members" (ontology + * implying/inverse expansion), and the composition edge-kind set these + * two steps traverse is derived from the registry's composition relation, + * not from an expansion mode. + */ +export type CompositionNavigationOptions = Readonly<{ + /** Alias to navigate from (defaults to current/last traversal target). */ + from?: keyof Aliases & string; + /** + * Maximum recursion depth, applied exactly as stated (`1` reaches only the + * direct level). Omitted, the walk reaches the complete closure at any depth. + */ + maxHops?: number; + /** + * Include recursion depth in output, as `.recursive({ depth })` takes it: + * `true` for the default alias or a string to customize it. + */ + depth?: RecursiveTraversalOptions["depth"]; + /** + * Include the traversal path in output, as `.recursive({ path })` takes it: + * `true` or an alias string for node ids, or `{ format: "qualified" }` for + * node and edge references with each hop's direction. + */ + path?: RecursiveTraversalOptions["path"]; + /** + * Realize the walk through this edge only, reaching the kinds this edge + * alone reaches from the source alias. An edge TYPE is checked at compile + * time; a kind string is checked at runtime. A kind that realizes no + * composition pair at the source alias itself is refused + * (`COMPOSITION_VIA_NOT_DECLARED`), never silently dropped. + */ + via?: CompositionViaRef; +}>; + type DynamicEdgeTypeFor = T extends RuntimeEdgeKind ? RuntimeEdgeTypeFor : DynamicEdgeType; +/** The edge type `via` narrows the edge alias to, else `DynamicEdgeType`. */ +type CompositionNavigationEdge = + O extends { via: infer V extends AnyEdgeType } ? V : DynamicEdgeType; + +/** + * The `QueryBuilder` shape `parts()`/`wholes()` return: one alias definition + * consumed by both methods' public signatures and their internal casts, so + * the same generic expansion is never re-spelled four times over. + */ +type CompositionNavigationResult< + G extends GraphDef, + Aliases extends AliasMap, + EdgeAliases extends EdgeAliasMap, + RecursiveAliases extends RecursiveAliasMap, + CoordinateState extends QueryCoordinateState, + NA extends string, + O, +> = QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record<`${NA}_edge`, EdgeAlias>>, + RecursiveAliases & + BuildRecursiveAliases< + O extends { depth: infer D extends boolean | string } ? D : false, + O extends ( + { + path: infer P extends boolean | string | QualifiedRecursivePathOption; + } + ) ? + P + : false, + NA + >, + CoordinateState +>; + /** * Builds projected fields for a node alias (including all metadata columns). */ @@ -381,21 +484,44 @@ export class QueryBuilder< /** * Starts a query from one kind or a nonempty explicit list of kinds. - * Lists scan exactly the requested kinds; duplicates are normalized. - * Properties used in predicates and expressions must be shared by all kinds. + * A list expands each listed kind under the same axis a single kind takes, + * and scans the union; duplicates are normalized. Properties used in + * predicates and expressions must be shared by all listed kinds. + * + * The alias's expansion axis is one option, `expansion` (default + * `"subclasses"` — a supertype query is polymorphic unless + * narrowed). `"exact"` restores the exact-kind reading; `"narrower"` + * expands through `broader`/`narrower` instead (untyped alias — no + * schema relationship is claimed). Omitting the option, passing `{}`, or + * passing an explicit `undefined` all take the store default — which is + * why the default overload's parameter spells `expansion?: undefined` + * rather than a bare optional (`exactOptionalPropertyTypes` rejects a + * stated `undefined` against a bare optional) and why it cannot spell the + * whole `AliasExpansionAxis` union (it would then also capture the + * `"exact"` and `"subclasses"` calls the overloads below type). A bag that + * can carry an axis the call site does not pin — `AliasExpansionOptions` + * itself, or a wrapper's `{ expansion?: "exact" }` — goes to the + * axis-unknown overload, which types the alias conservatively. * * @param kind - The node kind to start from * @param alias - A unique alias for this node (compile-time error if duplicate) */ - from< - const Kinds extends readonly [ - keyof G["nodes"] & string, - ...(keyof G["nodes"] & string)[], - ], - A extends string, - >( + from, A extends string>( kinds: Kinds, alias: UniqueAlias, + options?: { expansion?: undefined }, + ): QueryBuilder< + G, + Aliases & Record>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + + from, A extends string>( + kinds: Kinds, + alias: UniqueAlias, + options: { expansion: "exact" }, ): QueryBuilder< G, Aliases & Record>, @@ -404,10 +530,47 @@ export class QueryBuilder< CoordinateState >; + from, A extends string>( + kinds: Kinds, + alias: UniqueAlias, + options: { expansion: "subclasses" }, + ): QueryBuilder< + G, + Aliases & Record>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + + /** The axis-unknown overload. See {@link AliasExpansionOptions}. */ + from, A extends string>( + kinds: Kinds, + alias: UniqueAlias, + options: AliasExpansionOptions, + ): QueryBuilder< + G, + Aliases & Record, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + from( kind: K, alias: UniqueAlias, - options?: { includeSubClasses?: false }, + options?: { expansion?: undefined }, + ): QueryBuilder< + G, + Aliases & Record>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + + from( + kind: K, + alias: UniqueAlias, + options: { expansion: "exact" }, ): QueryBuilder< G, Aliases & Record>, @@ -419,35 +582,65 @@ export class QueryBuilder< from( kind: K, alias: UniqueAlias, - options: { includeSubClasses: true }, + options: { expansion: "subclasses" }, ): QueryBuilder< G, - Aliases & Record, + Aliases & Record>>, EdgeAliases, RecursiveAliases, CoordinateState >; + /** The axis-unknown overload. See {@link AliasExpansionOptions}. */ from( - kind: K | readonly K[], + kind: K, alias: UniqueAlias, - options?: { includeSubClasses?: boolean }, + options: AliasExpansionOptions, ): QueryBuilder< G, Aliases & Record, EdgeAliases, RecursiveAliases, CoordinateState - > { + >; + + from( + kind: K | readonly K[], + alias: UniqueAlias, + options?: AliasExpansionOptions, + ): + | QueryBuilder< + G, + Aliases & Record, + EdgeAliases, + RecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & Record>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & Record>, + EdgeAliases, + RecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & + Record>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + > { validateQuerySource(this.#state, true); // Validate alias to prevent SQL injection validateSqlIdentifier(alias); - const explicitKinds = typeof kind !== "string"; - if (explicitKinds && options !== undefined) { - throw new ConfigurationError( - "An explicit source kind list does not accept from() options.", - ); - } const requestedKinds = typeof kind === "string" ? [kind] : [...new Set(kind)]; if (requestedKinds.length === 0) { @@ -462,18 +655,21 @@ export class QueryBuilder< }); } } - const includeSubClasses = options?.includeSubClasses ?? false; - const kinds = - typeof kind === "string" && includeSubClasses ? - this.#config.registry.expandSubClasses(kind) - : requestedKinds; + const { expansion, kinds } = resolveAliasKinds( + options, + this.#config.defaultExpansion, + requestedKinds, + this.#config.registry, + aliasOverloadTyping(options, "from()"), + ); const newState: QueryBuilderState = { ...this.#state, startAlias: alias, currentAlias: alias, startKinds: kinds, - includeSubClasses, + startRequestedKinds: requestedKinds, + startExpansion: expansion, }; return new QueryBuilder(this.#config, newState); @@ -483,17 +679,61 @@ export class QueryBuilder< * Runtime-kind sibling of `from`; accepts a kind name or Store-issued token. * Throws `KindNotFoundError` if the kind is not registered. String-keyed * predicates use the `n.field("name").number().gte(...)` discriminator. + * + * The runtime kind may not appear in `G["ontology"]` at all, so — unlike + * `from()` — this always widens to {@link PolymorphicNodeType} whenever the + * axis is not `"exact"`, rather than computing `SubsumptionAffected`. + * `expansion: "narrower"` types the alias as an untyped {@link NodeAlias}, + * the same way `from()` does. Omitting the option, passing `{}`, and + * passing an explicit `undefined` all take the store default. */ fromDynamic( kind: T, alias: UniqueAlias, - options?: { includeSubClasses?: boolean }, + options: { expansion: "exact" }, ): QueryBuilder< G, Aliases & Record>>, EdgeAliases, RecursiveAliases, CoordinateState + >; + + fromDynamic( + kind: T, + alias: UniqueAlias, + options?: { expansion?: "subclasses" | undefined }, + ): QueryBuilder< + G, + Aliases & Record>>>, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + + /** The axis-unknown overload. See {@link AliasExpansionOptions}. */ + fromDynamic( + kind: T, + alias: UniqueAlias, + options: AliasExpansionOptions, + ): QueryBuilder< + G, + Aliases & Record, + EdgeAliases, + RecursiveAliases, + CoordinateState + >; + + fromDynamic( + kind: T, + alias: UniqueAlias, + options?: AliasExpansionOptions, + ): QueryBuilder< + G, + Aliases & Record, + EdgeAliases, + RecursiveAliases, + CoordinateState > { validateQuerySource(this.#state, true); validateSqlIdentifier(alias); @@ -508,18 +748,21 @@ export class QueryBuilder< }); } - const includeSubClasses = options?.includeSubClasses ?? false; - const kinds = - includeSubClasses ? - this.#config.registry.expandSubClasses(kindName) - : [kindName]; + const { expansion, kinds } = resolveAliasKinds( + options, + this.#config.defaultExpansion, + [kindName], + this.#config.registry, + INDEPENDENT_ALIAS_TYPING, + ); const newState: QueryBuilderState = { ...this.#state, startAlias: alias, currentAlias: alias, startKinds: kinds, - includeSubClasses, + startRequestedKinds: [kindName], + startExpansion: expansion, dynamicNodeAliases: new Set([...this.#state.dynamicNodeAliases, alias]), }; @@ -790,6 +1033,325 @@ export class QueryBuilder< ); } + /** + * Navigates to every composition PART transitively under the source + * alias's kind — the declared-structure alternative to spelling + * `.traverse(edgeKind, ...).recursive(...)` by hand over the realizing + * edge kinds, and the one way to cross a composition relation realized by + * more than one edge kind in a single step (a Podcast whose Episodes are + * `episodeOf` and whose Segments are `segmentOf` still reads as one + * `.parts(...)` call). + * + * Direction is derived per realizing edge kind from + * `registry.compositionPartSide`, not fixed: a `part -> whole` edge + * (`partSide: "from"`) is followed reversed (`"in"`) to reach parts, and a + * `whole -> part` edge (`partSide: "to"`, the `has_*` convention) is + * followed in its own direction (`"out"`). A relation realized by both + * orientations compiles to one traversal step that unions them — the same + * `inverseEdgeKinds` mechanism `{ expand: "inverse" }` already compiles to + * a `UNION ALL` of both directions at every recursion round — so a mixed + * relation costs nothing extra to declare and the uniform case (the common + * one) pays nothing extra to compile. + * + * Recurses by default — the difference from `traverse`, which reaches only + * the direct level — to the full transitive parts closure at any depth, + * with no hop ceiling, rather than a bare `.recursive()`'s implicit cap; + * a stated `maxHops` bounds the walk exactly as given (`maxHops: 1` for + * direct parts only). + * `via` narrows the walk to one realizing edge and to the kinds that edge + * alone reaches. The result alias stays `DynamicNodeType`: a + * realizing edge can admit more than one part kind, and a conditional node + * type on this alias is not assignable back onto `QueryBuilder`. `via` as + * an edge type does type the edge alias. + * + * Refuses rather than silently returning zero rows: an alias whose kind + * declares no composition parts throws `ConfigurationError` with code + * `COMPOSITION_NO_PARTS_DECLARED`; an `{ from }` naming an alias this + * query does not have throws `COMPOSITION_UNKNOWN_ALIAS` instead — not the + * former, which would misdiagnose a typo'd alias as a composition + * problem. + */ + parts< + NA extends string, + const O extends CompositionNavigationOptions = Record< + string, + never + >, + >( + nodeAlias: UniqueAlias, + options?: O, + ): CompositionNavigationResult< + G, + Aliases, + EdgeAliases, + RecursiveAliases, + CoordinateState, + NA, + O + > { + return this.#navigateComposition("parts", nodeAlias, options); + } + + /** + * Navigates to every composition WHOLE transitively over the source + * alias's kind — the mirror of {@link QueryBuilder.parts}. Direction is + * derived the same way, flipped: a `part -> whole` edge is followed in its + * own direction (`"out"`) and a `whole -> part` (`has_*`) edge is followed + * reversed (`"in"`). See {@link QueryBuilder.parts} for the recursion, + * typing, and refusal rules, which are otherwise identical. + * + * Refuses with `ConfigurationError` (`COMPOSITION_NO_WHOLES_DECLARED`) on + * an alias whose kind declares no composition wholes. + */ + wholes< + NA extends string, + const O extends CompositionNavigationOptions = Record< + string, + never + >, + >( + nodeAlias: UniqueAlias, + options?: O, + ): CompositionNavigationResult< + G, + Aliases, + EdgeAliases, + RecursiveAliases, + CoordinateState, + NA, + O + > { + return this.#navigateComposition("wholes", nodeAlias, options); + } + + /** + * Shared body for `parts()`/`wholes()`. `relation` selects which registry + * readers and which orientation-to-direction mapping apply; everything + * else — refusal, edge-kind partition, the union construction, recursion — + * is one code path so the two steps cannot drift. + */ + #navigateComposition< + NA extends string, + const O extends CompositionNavigationOptions, + >( + relation: "parts" | "wholes", + nodeAlias: UniqueAlias, + options: O | undefined, + ): CompositionNavigationResult< + G, + Aliases, + EdgeAliases, + RecursiveAliases, + CoordinateState, + NA, + O + > { + validateSqlIdentifier(nodeAlias); + const registry = this.#config.registry; + const fromAlias = options?.from ?? this.#state.currentAlias; + const sourceKinds = this.#getKindNamesForAlias(fromAlias); + if (sourceKinds === undefined) { + const knownAliases = [ + this.#state.startAlias, + ...this.#state.traversals.map((traversal) => traversal.nodeAlias), + ]; + throw new ConfigurationError( + `.${relation}("${nodeAlias}", { from: "${fromAlias}" }) was called, but this query has no alias "${fromAlias}". ` + + `Known aliases: ${knownAliases.map((known) => `"${known}"`).join(", ")}.`, + { + code: "COMPOSITION_UNKNOWN_ALIAS", + relation, + alias: fromAlias, + knownAliases, + }, + { + suggestion: `Pass the alias of a node already in this query as { from: ... }, e.g. one of ${knownAliases.map((known) => `"${known}"`).join(", ")}.`, + }, + ); + } + + const { edgeKindsFrom, targetKindsFrom } = compositionReadersTowards( + registry, + relation, + ); + + const edgeKinds = new Set(); + for (const kind of sourceKinds) { + for (const edgeKind of edgeKindsFrom(kind)) edgeKinds.add(edgeKind); + } + const viaKind = + options?.via === undefined ? undefined : compositionViaKind(options.via); + // A walk narrowed to one edge reaches only what that edge reaches from + // the source alias, so its target kinds are that edge's own closure, not + // the whole composition closure. The registry readers answer in concrete + // kinds, subclasses of every declared endpoint included, so a live row + // whose kind is an undeclared subclass of a declared target is returned + // rather than silently dropped. + const viaReach = + viaKind === undefined ? undefined : ( + compositionViaReach(registry, relation, sourceKinds, viaKind) + ); + if (viaReach?.realized === false) { + throw new ConfigurationError( + `.${relation}("${nodeAlias}") via "${viaKind}" realizes no composition pair for alias "${fromAlias}".`, + { + code: "COMPOSITION_VIA_NOT_DECLARED", + relation, + alias: fromAlias, + via: viaKind, + declaredVia: viaReach.declaredVia, + }, + { + suggestion: `Pass via naming one of the declared realizing edges, or omit it to walk every composition edge of this alias.`, + }, + ); + } + const targetKinds = new Set( + viaReach === undefined ? + sourceKinds.flatMap((kind) => targetKindsFrom(kind)) + : viaReach.targetKinds, + ); + if (viaKind !== undefined) { + edgeKinds.clear(); + edgeKinds.add(viaKind); + } + if (edgeKinds.size === 0) { + const kindsLabel = + sourceKinds.length > 0 ? + sourceKinds.map((kind) => `"${kind}"`).join(", ") + : "(unknown)"; + throw new ConfigurationError( + `.${relation}("${nodeAlias}") was called on alias "${fromAlias}" (kind${sourceKinds.length === 1 ? "" : "s"} ${kindsLabel}), which declares no composition ${relation}.`, + { + code: + relation === "parts" ? + "COMPOSITION_NO_PARTS_DECLARED" + : "COMPOSITION_NO_WHOLES_DECLARED", + alias: fromAlias, + kinds: sourceKinds, + }, + { + suggestion: + relation === "parts" ? + `Declare a partOf/hasPart relation naming ${kindsLabel} as the whole, or call .traverse(...) directly for a non-composition relationship.` + : `Declare a partOf/hasPart relation naming ${kindsLabel} as the part, or call .traverse(...) directly for a non-composition relationship.`, + }, + ); + } + + // The orientation table, derived through the one shared partition + // (`partitionCompositionEdgeKindsByDirection`) `subgraph({ composition: + // true })` also uses, so the two navigators cannot drift on which way + // an edge is walked or on what happens when an edge kind has no + // recorded part side: a `part -> whole` edge ("from") reaches + // its parts reversed ("in") and its wholes forward ("out"); a + // `whole -> part` edge ("to") is the mirror. + const { outEdgeKinds, inEdgeKinds } = + partitionCompositionEdgeKindsByDirection(registry, edgeKinds, relation); + + // Uniform orientation (either set empty) needs no `inverseEdgeKinds` — + // the direct-only compiled branch — and pays nothing beyond a plain + // traversal; mixed orientation folds the other set in as the existing + // union machinery's inverse branch. + const [direction, directEdgeKinds, inverseEdgeKinds]: readonly [ + TraversalDirection, + readonly string[], + readonly string[], + ] = + outEdgeKinds.length > 0 ? + ["out", outEdgeKinds, inEdgeKinds] + : ["in", inEdgeKinds, []]; + + const edgeAlias = `${nodeAlias}_edge`; + validateSqlIdentifier(edgeAlias); + + // The derived edge alias is not caller-chosen the way `.traverse()`'s + // is, so a collision is invisible to the caller until it silently + // merges two different edge types under one alias — refuse + // rather than let `whereEdge(edgeAlias, ...)` later target an + // ambiguous traversal. + if (this.#getEdgeKindNamesForAlias(edgeAlias) !== undefined) { + throw new ConfigurationError( + `.${relation}("${nodeAlias}") would derive the edge alias "${edgeAlias}", which this query already uses for another traversal. Choose a different alias for .${relation}("${nodeAlias}") or for the conflicting traversal.`, + { alias: edgeAlias, relation, nodeAlias }, + ); + } + + const newState: QueryBuilderState = { + ...this.#state, + dynamicEdgeAliases: new Set([ + ...this.#state.dynamicEdgeAliases, + edgeAlias, + ]), + }; + + const traversalBuilder = new TraversalBuilder< + G, + AliasMap, + EdgeAliasMap, + string, + string, + TraversalDirection, + false, + false, + false, + RecursiveAliasMap, + "open", + DynamicEdgeType + >( + this.#config, + newState, + directEdgeKinds, + edgeAlias, + direction, + fromAlias, + inverseEdgeKinds, + false, + // Exhaustive until `recursive()` is given a bound to apply. + EXHAUSTIVE_VARIABLE_LENGTH_STATE, + undefined, // pendingEdgePredicates — default + false, + // A composition walk follows the rows that bind a part to its whole, + // the same membership the cascade deletes by, not the read's window. + compositionEdgeKindMembers(registry, edgeKinds), + ); + + // Sorted for deterministic output. + const targetKindList = [...targetKinds].toSorted(); + + // Recurse by default (the value proposition versus `traverse`), to the + // closure rather than a bare `.recursive()`'s implicit cap: skip only + // when the caller both asked for exactly one hop and requested neither a + // depth nor a path column, so no accepted option is ever silently + // dropped by the optimization. + const wantsRecursiveOutput = + (options?.depth ?? false) !== false || (options?.path ?? false) !== false; + const willRecurse = !(options?.maxHops === 1 && !wantsRecursiveOutput); + + return (willRecurse ? + traversalBuilder + .recursive({ + ...(options?.maxHops === undefined ? + {} + : { maxHops: options.maxHops }), + ...(options?.depth === undefined ? {} : { depth: options.depth }), + ...(options?.path === undefined ? {} : { path: options.path }), + }) + .toKindSet(targetKindList, nodeAlias) + : traversalBuilder.toKindSet( + targetKindList, + nodeAlias, + )) as unknown as CompositionNavigationResult< + G, + Aliases, + EdgeAliases, + RecursiveAliases, + CoordinateState, + NA, + O + >; + } + /** * Runtime-kind sibling of `traverse`; accepts a kind name or Store-issued * token. Throws `KindNotFoundError` if the edge kind is not registered. @@ -1142,6 +1704,7 @@ export class QueryBuilder< startAlias: "", currentAlias: "", startKinds: [], + startRequestedKinds: [], traversals: [], predicates: [], projection: [], @@ -1805,8 +2368,12 @@ export class QueryBuilder< const kindAccessor = stringField( fieldRef(alias, ["kind"], { valueType: "string" }), ); - const fulltextAccessor = createFulltextAccessor(alias, () => - this.#hasSearchableField(kindNames), + const fulltextAccessor = createFulltextAccessor( + alias, + kindNames === undefined ? undefined : ( + () => + this.#config.schemaIntrospector.kindsWithoutSearchableField(kindNames) + ), ); if (this.#state.dynamicNodeAliases.has(alias)) { @@ -1860,11 +2427,6 @@ export class QueryBuilder< }); } - #hasSearchableField(kindNames: readonly string[] | undefined): boolean { - if (!kindNames) return false; - return this.#config.schemaIntrospector.hasSearchableField(kindNames); - } - /** * Guards `includeIdentityMembers` against a non-identity-enabled builder. */ diff --git a/packages/typegraph/src/query/builder/traversal-builder.ts b/packages/typegraph/src/query/builder/traversal-builder.ts index 399ee08a4..c367f318e 100644 --- a/packages/typegraph/src/query/builder/traversal-builder.ts +++ b/packages/typegraph/src/query/builder/traversal-builder.ts @@ -2,14 +2,18 @@ * TraversalBuilder - Intermediate builder for edge traversals. */ import { type GraphDef } from "../../core/define-graph"; -import { isEdgeTargetMap } from "../../core/edge-endpoints"; import { resolveRuntimeKindInput, type RuntimeNodeKind, type RuntimeNodeTypeFor, } from "../../core/runtime-kind"; import { type AnyEdgeType, type NodeType } from "../../core/types"; -import { EndpointError, KindNotFoundError } from "../../errors"; +import { + ConfigurationError, + EndpointError, + KindNotFoundError, +} from "../../errors"; +import { type PolymorphicNodeType } from "../../ontology/types"; import { isInteropProbeKey } from "../../utils/object"; import { type NodePredicate, @@ -29,6 +33,13 @@ import { type Predicate, stringField, } from "../predicates"; +import { + type AliasExpansionOptions, + aliasOverloadTyping, + type AliasStaticTyping, + INDEPENDENT_ALIAS_TYPING, + resolveAliasKinds, +} from "./alias-expansion"; // Type-only import to get the QueryBuilder type without runtime circular dependency import { createDynamicFieldBuilder, @@ -39,6 +50,7 @@ import { getQueryBuilderInternalContext } from "./internal-context"; import { type QueryBuilder } from "./query-builder"; import { type AliasMap, + type AliasNodeType, type BaseFieldAccessor, type BuildRecursiveAliases, type EdgeAccessor, @@ -99,6 +111,7 @@ interface VariableLengthState { enabled: boolean; minDepth: number; maxDepth: number; + exhaustive?: boolean; cyclePolicy: RecursiveCyclePolicy; pathEnabled: boolean; pathAlias?: string; @@ -119,6 +132,17 @@ const DEFAULT_VARIABLE_LENGTH_STATE: VariableLengthState = { depthEnabled: false, }; +/** + * Variable-length state for a recursion that must reach the end of every path + * unless the caller bounds it (`parts()` / `wholes()`). `recursive()` is the + * only place that decides: a stated `maxHops` turns `exhaustive` off and applies + * that bound exactly. + */ +export const EXHAUSTIVE_VARIABLE_LENGTH_STATE: VariableLengthState = { + ...DEFAULT_VARIABLE_LENGTH_STATE, + exhaustive: true, +}; + function validateMaxHops(max: number): void { if (!Number.isFinite(max) || !Number.isInteger(max)) { throw new TypeError("maxHops must be a finite integer"); @@ -236,6 +260,7 @@ export class TraversalBuilder< readonly #fromAlias: string; readonly #optional: Optional; readonly #includeIdentityMembers: boolean; + readonly #compositionMembership: Traversal["compositionMembership"]; readonly #variableLength: VariableLengthState; readonly #pendingEdgePredicates: readonly NodePredicate[]; @@ -253,6 +278,7 @@ export class TraversalBuilder< // Appended, never inserted: TraversalBuilder is public, so every // pre-existing positional slot must keep its meaning. includeIdentityMembers = false, + compositionMembership?: Traversal["compositionMembership"], ) { this.#config = config; this.#state = state; @@ -263,6 +289,7 @@ export class TraversalBuilder< this.#fromAlias = fromAlias; this.#optional = optional; this.#includeIdentityMembers = includeIdentityMembers; + this.#compositionMembership = compositionMembership; this.#variableLength = variableLength; this.#pendingEdgePredicates = pendingEdgePredicates; } @@ -349,6 +376,9 @@ export class TraversalBuilder< enabled: true, minDepth, maxDepth, + exhaustive: + options?.maxHops === undefined && + this.#variableLength.exhaustive === true, cyclePolicy, ...(pathOption === undefined ? {} @@ -366,6 +396,7 @@ export class TraversalBuilder< }, this.#pendingEdgePredicates, this.#includeIdentityMembers, + this.#compositionMembership, ); } @@ -426,6 +457,7 @@ export class TraversalBuilder< this.#variableLength, [...this.#pendingEdgePredicates, newPredicate], this.#includeIdentityMembers, + this.#compositionMembership, ); } @@ -554,13 +586,32 @@ export class TraversalBuilder< * - "out" direction: kind must be in the edge's "to" array * - "in" direction: kind must be in the edge's "from" array * + * The alias's expansion axis is the same one option `from()` states, + * resolved through the same owner (`./alias-expansion.ts`): `expansion`, + * taking the store default when the option is omitted, `{}`, or an + * explicit `undefined`, and accepting a forwarded bag whose axis is not + * one literal (the axis-unknown overload). A `"narrower"` expansion + * additionally admits each expanded kind as an endpoint of this edge. + * * @param kind - The target node kind * @param alias - A unique alias for this node (compile-time error if duplicate) */ to, A extends string>( kind: K, alias: UniqueAlias, - options?: { includeSubClasses?: false }, + options?: { expansion?: undefined }, + ): QueryBuilder< + G, + Aliases & Record, Optional>>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + >; + + to, A extends string>( + kind: K, + alias: UniqueAlias, + options: { expansion: "exact" }, ): QueryBuilder< G, Aliases & Record>, @@ -572,31 +623,76 @@ export class TraversalBuilder< to, A extends string>( kind: K, alias: UniqueAlias, - options: { includeSubClasses: true }, + options: { expansion: "subclasses" }, ): QueryBuilder< G, - Aliases & Record>, + Aliases & + Record< + A, + NodeAlias, Optional> + >, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState >; + /** The axis-unknown overload. See {@link AliasExpansionOptions}. */ to, A extends string>( kind: K, alias: UniqueAlias, - options?: { includeSubClasses?: boolean }, + options: AliasExpansionOptions, ): QueryBuilder< G, Aliases & Record>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState - > { + >; + + to, A extends string>( + kind: K, + alias: UniqueAlias, + options?: AliasExpansionOptions, + ): + | QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & Record, Optional>>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + > + | QueryBuilder< + G, + Aliases & + Record< + A, + NodeAlias, Optional> + >, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + > { validateSqlIdentifier(alias); - const includeSubClasses = options?.includeSubClasses ?? false; - const kinds = - includeSubClasses ? this.#config.registry.expandSubClasses(kind) : [kind]; + const kinds = this.#resolveTargetKinds( + kind, + options, + aliasOverloadTyping(options, "to()"), + ); const newState = this.#stateWithTraversal(alias, kinds); @@ -614,17 +710,63 @@ export class TraversalBuilder< /** * Runtime-kind sibling of `to`; accepts a kind name or Store-issued token. * Throws `KindNotFoundError` if the kind is not registered. + * + * Like `fromDynamic`, the runtime kind may not appear in `G["ontology"]`, + * so any axis other than `"exact"` widens to {@link PolymorphicNodeType}. + * Omitting the option, passing `{}`, and passing an explicit `undefined` + * all take the store default. */ toDynamic( kind: T, alias: UniqueAlias, - options?: { includeSubClasses?: boolean }, + options: { expansion: "exact" }, ): QueryBuilder< G, Aliases & Record, Optional>>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState + >; + + toDynamic( + kind: T, + alias: UniqueAlias, + options?: { expansion?: "subclasses" | undefined }, + ): QueryBuilder< + G, + Aliases & + Record< + A, + NodeAlias>, Optional> + >, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + >; + + /** The axis-unknown overload. See {@link AliasExpansionOptions}. */ + toDynamic( + kind: T, + alias: UniqueAlias, + options: AliasExpansionOptions, + ): QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + >; + + toDynamic( + kind: T, + alias: UniqueAlias, + options?: AliasExpansionOptions, + ): QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState > { validateSqlIdentifier(alias); const kindName = resolveRuntimeKindInput( @@ -639,11 +781,11 @@ export class TraversalBuilder< } this.#assertValidEndpoint(kindName); - const includeSubClasses = options?.includeSubClasses ?? false; - const kinds = - includeSubClasses ? - this.#config.registry.expandSubClasses(kindName) - : [kindName]; + const kinds = this.#resolveTargetKinds( + kindName, + options, + INDEPENDENT_ALIAS_TYPING, + ); const baseState = this.#stateWithTraversal(alias, kinds); const newState: QueryBuilderState = { @@ -653,7 +795,137 @@ export class TraversalBuilder< return new QueryBuilderClass(this.#config, newState) as QueryBuilder< G, - Aliases & Record, Optional>>, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + >; + } + + #resolveTargetKinds( + kind: string, + options: AliasExpansionOptions | undefined, + typing: AliasStaticTyping, + ): readonly string[] { + const { expansion, kinds } = resolveAliasKinds( + options, + this.#config.defaultExpansion, + [kind], + this.#config.registry, + typing, + ); + if (expansion === "narrower") { + this.#assertNarrowerEndpointsAdmitted(kind, kinds); + } + return kinds; + } + + /** + * Endpoint admission for a `to()`/`toDynamic()` alias expanded through + * `expansion: "narrower"`. `broader`/`narrower` is NOT an assignability axis — + * unlike subclass expansion, a narrower kind is not automatically admitted + * by `expandEdgeEndpointAllowance` — so each expanded kind is checked + * individually through the existing `#assertValidEndpoint` owner, and a + * failure is re-raised as the narrower-naming error rather + * than the generic `EndpointError`. + * + * @throws ConfigurationError (`ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED`) + * naming the offending kind, the root kind, and the edge kind. + */ + #assertNarrowerEndpointsAdmitted( + rootKind: string, + expandedKinds: readonly string[], + ): void { + const edgeKind = this.#edgeKinds[0] ?? "(unknown)"; + for (const kind of expandedKinds) { + try { + this.#assertValidEndpoint(kind); + } catch (error) { + if (!(error instanceof EndpointError)) throw error; + throw new ConfigurationError( + `expansion: "narrower" on "${rootKind}" includes "${kind}", ` + + `which is not an admitted endpoint of edge "${edgeKind}".`, + { + code: "ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED", + rootKind, + narrowerKind: kind, + edgeKind, + }, + { + suggestion: `Widen edge "${edgeKind}"'s declared endpoints to admit "${kind}", or drop its broader/narrower relation to "${rootKind}".`, + cause: error, + }, + ); + } + } + } + + /** + * Finalizes the traversal against an explicit, already-resolved set of + * target node kinds, rather than one compile-time-known kind (`to`) or one + * runtime-resolved kind (`toDynamic`). + * + * `parts()`/`wholes()` (`QueryBuilder`) are today's one caller: the + * composition part/whole closure under a kind can itself span several node + * kinds with different schemas, computed from the registry rather than + * named by the query author. The alias is dynamic, like `toDynamic`, for + * the same reason — a heterogeneous closure has no single schema to type + * the accessor against. + * + * `alias` is a bare `A`, not `UniqueAlias`: `parts()`/`wholes()` + * build the `TraversalBuilder` this runs on over the generic `AliasMap` + * upper bound rather than the calling query's actual alias map (the + * composition closure has already been reduced to a flat kind array by + * then, so there is no specific `Aliases` left to check against), and + * `keyof AliasMap` is `string` — every `A` would fail the collision check + * vacuously. The real duplicate-alias guard already ran, against the real + * `Aliases`, on `QueryBuilder.parts`/`.wholes`'s own public signature. + * + * Validates like `toDynamic`, not merely `to`'s compile-time endpoint + * check: an empty `kinds` array refuses rather than silently compiling to + * a filter no row can match, each kind must be registered + * (`KindNotFoundError` otherwise), and each runs `#assertValidEndpoint`. + * `parts()`/`wholes()`'s own registry-derived `targetKindList` pays + * nothing for this — it is already registered and endpoint-valid by + * construction — but `toKindSet` is a public finalizer like its siblings, + * so it cannot skip the checks they apply. + */ + toKindSet( + kinds: readonly string[], + alias: A, + ): QueryBuilder< + G, + Aliases & Record>, + EdgeAliases & Record>, + RecAliases & BuildRecursiveAliases, + CoordinateState + > { + validateSqlIdentifier(alias); + + if (kinds.length === 0) { + throw new ConfigurationError( + `toKindSet(alias: "${alias}") requires at least one kind; an empty kind set would compile to a filter no row can match instead of refusing the invalid traversal.`, + { code: "EMPTY_KIND_SET", alias }, + ); + } + for (const kind of kinds) { + if (!this.#config.registry.hasNodeType(kind)) { + throw new KindNotFoundError(kind, "node", { + graphId: this.#config.graphId, + }); + } + this.#assertValidEndpoint(kind); + } + + const baseState = this.#stateWithTraversal(alias, kinds); + const newState: QueryBuilderState = { + ...baseState, + dynamicNodeAliases: new Set([...baseState.dynamicNodeAliases, alias]), + }; + + return new QueryBuilderClass(this.#config, newState) as QueryBuilder< + G, + Aliases & Record>, EdgeAliases & Record>, RecAliases & BuildRecursiveAliases, CoordinateState @@ -686,6 +958,9 @@ export class TraversalBuilder< joinEdgeField: this.#direction === "out" ? "from_id" : "to_id", optional: this.#optional, ...(this.#includeIdentityMembers ? { includeIdentityMembers: true } : {}), + ...(this.#compositionMembership === undefined ? + {} + : { compositionMembership: this.#compositionMembership }), }; const baseTraversal: Traversal = @@ -700,6 +975,9 @@ export class TraversalBuilder< variableLength: { minDepth: this.#variableLength.minDepth, maxDepth: this.#variableLength.maxDepth, + ...(this.#variableLength.exhaustive === true ? + { exhaustive: true } + : {}), cyclePolicy: this.#variableLength.cyclePolicy, ...(this.#variableLength.pathEnabled && { pathAlias: this.#variableLength.pathAlias ?? `${alias}_path`, @@ -738,23 +1016,13 @@ export class TraversalBuilder< */ #assertValidEndpoint(targetKind: string): void { const expectedKinds = new Set(); + // The registry's edge-kind facts, not the edge type's own `from`/`to`: + // the facts are the registration's endpoints wherever they were declared + // (on the type, or on the graph's `{ type, from, to }` entry), and are + // what every other endpoint decision reads. const collectFromEdge = (edgeName: string, side: "to" | "from"): void => { - const edgeType = this.#config.registry.getEdgeType(edgeName); - const endpoints = edgeType?.[side]; - if (!endpoints) return; - if (side === "to" && isEdgeTargetMap(endpoints)) { - for (const targets of Object.values( - endpoints, - ) as (readonly NodeType[])[]) { - for (const endpoint of targets) { - expectedKinds.add(endpoint.kind); - } - } - } else if (Array.isArray(endpoints)) { - for (const endpoint of endpoints as readonly NodeType[]) { - expectedKinds.add(endpoint.kind); - } - } + const facts = this.#config.registry.edgeKindFacts(edgeName); + for (const kind of facts?.[side] ?? []) expectedKinds.add(kind); }; const forwardSide = this.#direction === "out" ? "to" : "from"; const inverseSide = this.#direction === "out" ? "from" : "to"; @@ -765,8 +1033,8 @@ export class TraversalBuilder< collectFromEdge(edgeName, inverseSide); } - // No declared endpoints anywhere — edge kinds in the registry can - // legally have undefined `to` / `from`; nothing to validate against. + // No endpoint facts for any traversed edge kind — nothing to validate + // against. if (expectedKinds.size === 0) return; for (const expected of expectedKinds) { diff --git a/packages/typegraph/src/query/builder/types.ts b/packages/typegraph/src/query/builder/types.ts index 399da4741..70bbec3d9 100644 --- a/packages/typegraph/src/query/builder/types.ts +++ b/packages/typegraph/src/query/builder/types.ts @@ -21,6 +21,7 @@ import { type NodeType, type TemporalMode, } from "../../core/types"; +import { type PolymorphicNodeType } from "../../ontology/types"; import { type KindRegistry } from "../../registry/kind-registry"; import { type AggregateOrderSpec, @@ -47,6 +48,10 @@ import type { } from "../predicates"; import { type SchemaIntrospector } from "../schema-introspector"; import type { CompiledSelectSql } from "../sql-intent"; +import { + type AliasExpansionAxis, + type DefaultAliasExpansionAxis, +} from "./alias-expansion"; import { type DynamicEdgeAccessor, type DynamicNodeAccessor, @@ -202,6 +207,429 @@ export type NodeAlias< optional: Optional; }>; +/** + * True when `G["ontology"]` has lost its `const`-inferred tuple shape — its + * `length` is the general `number` rather than a tuple's fixed literal + * count. `defineGraph`'s `const TOntology` normally keeps the array a tuple + * of positionally typed relations (so {@link SubsumptionAffected} can + * `Extract` a `to: { kind: K }` literal out of it), but a caller can lose + * that shape — most commonly by building the relations in a variable + * annotated `readonly OntologyRelation[]` before passing it to + * `defineGraph({ ontology })`, the exact pattern the changeset blesses + * ("code that annotates a relation's result as `OntologyRelation` still + * compiles unchanged"). `defineGraph`'s own omitted-`ontology` default is + * the literal empty tuple `readonly []` (`length: 0`), which is NOT erased + * by this test — only a genuinely unbounded array is. + */ +type OntologyTypeErased = + number extends G["ontology"]["length"] ? true : false; + +/** + * True when an ontology endpoint carries no `kind` literal for + * {@link SubsumptionElementNamesKind}'s `Extract` to match or rule out. An + * endpoint is one of three things: + * + * - a `NodeType`/`AnyEdgeType` with a LITERAL `kind` — decidable: the + * `Extract` either matches this kind or proves the endpoint does not name + * it. + * - an IRI `string` — never a registered node kind, so the `{ kind: K }` + * `Extract` rules it out soundly. A second kind co-registered on the same + * IRI carries its OWN literal endpoint and widens through that element. + * - anything whose `kind` is the general `string` — a kind literal an + * annotation ERASED. Nothing can be ruled out. + * + * Distributes over an endpoint UNION (`typeof Audio | typeof Video`), so a + * union is erased exactly when one of its members is. + */ +type EndpointKindErased = + Endpoint extends string ? false + : Endpoint extends { kind: infer Kind extends string } ? + string extends Kind ? + true + : false + : true; + +/** + * The endpoint positions that decide whether an ontology element affects a + * kind, for an element whose meta-edge name is `Name`: a `subClassOf` is + * decided by its `to` alone (the supertype; which subtype declares itself + * under `K` never changes whether `K` is affected), an + * `equivalentTo`/`sameAs` by both sides. `never` for a name that is none of + * the three: no endpoint of such a relation can affect any kind. + * + * The SOLE owner of that position mapping: both per-element tests — + * {@link SubsumptionLiteralsErased} (can this element be decided at all?) + * and {@link SubsumptionElementNamesKind} (does it name `K`?) — read their + * endpoints from here, so the two cannot drift. + */ +type MatchedEndpoints = + | ([Extract] extends [never] ? never : To) + | ([Extract] extends [never] ? never + : From | To); + +/** + * True for an ontology-tuple element whose literals are too weak for + * {@link SubsumptionElementNamesKind} to rule it out — the conservative + * arm's per-element test. + * + * An element is undecidable when its `metaEdge.name` is the general `string` + * (the degenerate case: a caller's `const relation: OntologyRelation = + * subClassOf(Child, Parent)` annotation erases the name along with + * everything else), or when the name can be a subsumption name while a + * {@link MatchedEndpoints} position has lost its kind literal — what a + * caller helper annotated `(child: NodeType, parent: NodeType) => + * TypedOntologyRelation<"subClassOf", NodeType, NodeType>` produces. Both + * are assignable to `OntologyRelation` and neither is mutually assignable + * WITH it, so an assignability test sees only the first. + * + * Distributes over the element union, so `true extends + * SubsumptionLiteralsErased<...>` is "at least one element is undecidable". + */ +type SubsumptionLiteralsErased = + Relation extends unknown ? + Relation extends ( + { + metaEdge: { name: infer Name extends string }; + from: infer From; + to: infer To; + } + ) ? + string extends Name ? true + : true extends EndpointKindErased> ? true + : false + : true + : never; + +type EndpointKinds = Extract["kind"]; + +/** + * True for an ontology-tuple element that NAMES kind `K` at one of the + * endpoint positions {@link MatchedEndpoints} selects — the decided arm's + * per-element test. + * + * The match reduces the element's selected endpoints to the set of kind + * LITERALS they can hold and asks whether `K` is one of them, rather than + * asking whether the element is assignable to a single-kind shape. That is + * what makes an endpoint kind UNION decidable, in both shapes a caller can + * write it: + * + * - a union of endpoint TYPES, from a helper that declares a subclass of + * either media root (`(root: typeof Audio | typeof Video) => + * subClassOf(Episode, root)`), leaving `to` as `Audio | Video`. The + * endpoint filter distributes over it. + * - a union inside ONE endpoint's `kind`, from a helper annotated + * `NodeType<"Audio" | "Video">`, leaving `to` as a single object typed + * `{ kind: "Audio" | "Video" }`. The indexed access collects it. + * + * An `Extract` over the element itself — `Extract` — matches NEITHER shape for EITHER kind, because neither + * `{ to: Audio | Video }` nor `{ to: { kind: "Audio" | "Video" } }` is + * assignable to a single-kind shape. Both roots would then keep an exact + * alias while either one really can return the subclass's rows: an unsound + * under-widening, not the conservative over-widening the erased arms above + * deliberately take. + * + * Distributes over the element union, so `true extends + * SubsumptionElementNamesKind<...>` is "at least one element names `K`". + * Endpoints the position mapping does not select (`never`) contribute no + * kinds, and an IRI `string` endpoint carries no `kind` at all, so the + * endpoint filter drops it — exactly the soundness + * {@link EndpointKindErased} describes. + */ +type SubsumptionElementNamesKind = + Relation extends unknown ? + Relation extends ( + { + metaEdge: { name: infer Name extends string }; + from: infer From; + to: infer To; + } + ) ? + [Extract>, K>] extends ( + [never] + ) ? + false + : true + : false + : never; + +/** + * Whether kind `K` in graph `G` participates in a `subClassOf`/`equivalentTo` + * relation that could hand a polymorphic-default query a row of a DIFFERENT + * concrete kind: `K` is a `subClassOf` target, or `K` is either side of an + * `equivalentTo`/`sameAs` pair. Direct participation is enough — a kind with + * a transitive descendant necessarily has a direct one — so this is a single + * non-recursive per-element `Extract` over `G["ontology"]`, computable with + * no transitive-closure type engine. `false` (a graph with `ontology: []`, or + * a kind no relation touches) costs zero type churn. + * + * The `Extract` reads the IRI-routed `equivalentTo(Kind, iri)` form too: + * every meta-edge factory returns a `TypedOntologyRelation` carrying + * its meta-edge name literal, the IRI overload included + * (`from: Kind, to: string`), so a kind whose only equivalence is to an + * external vocabulary term still widens — the registry folds an IRI-routed + * equivalence class into `subClassAncestors`/`subClassDescendants` exactly + * like a two-kind one, so rows of the co-registered kind really can come + * back. + * + * **Two arms widen conservatively rather than answer `false`.** An + * {@link OntologyTypeErased} ontology has lost its tuple length, so no + * per-element `Extract` can rule out a `subClassOf` targeting `K`; and a + * single {@link SubsumptionLiteralsErased} element has lost the very + * literals the `Extract` matches on — its own annotation erased either the + * meta-edge name or the endpoint kind the match reads. Either arm answering + * `false` would be an unsound under-widening: the alias would stay narrow + * while the runtime alias is genuinely polymorphic. + * + * The two arms are conservative, not exhaustive: an element that keeps every + * literal the `Extract` reads is decided by {@link + * SubsumptionElementNamesKind}, and that decision is sound for the relations + * the ontology DECLARES — including one whose endpoint is a UNION of node + * types, which that helper distributes over so every kind the union can + * hold is decided. A relation added at runtime is a separate, documented + * limitation — see {@link AliasNodeType}. + */ +type SubsumptionAffected = + OntologyTypeErased extends true ? true + : true extends SubsumptionLiteralsErased ? true + : true extends SubsumptionElementNamesKind ? true + : false; + +/** + * Node type `N` as graph `G`'s subsumption lets it read back: the + * {@link PolymorphicNodeType} of `N` when its kind is + * {@link SubsumptionAffected}, `N` itself otherwise. Distributes over an + * endpoint union, deciding each member on its own kind. + * + * The one owner of "the type a declared kind's row carries": a polymorphic + * alias ({@link AliasNodeType}), an expanded search hit, and an edge's + * declared endpoint all read through it, so a row typed one way by a query + * is typed the same way by the edge that points at it. + */ +export type SubsumedNodeType = + N extends NodeType ? + SubsumptionAffected extends true ? + PolymorphicNodeType + : N + : never; + +declare const IRI_ENDPOINT: unique symbol; + +type IriEndpointKey = Readonly<{ [IRI_ENDPOINT]: true }>; + +/** + * The closure key an ontology endpoint contributes to + * {@link AssignableNodeKinds}: a node or edge type's `kind` literal, or one + * shared marker for every IRI endpoint. The IRI overload of `equivalentTo` + * types its IRI as the general `string`, so two IRI endpoints cannot be told + * apart; folding them all onto one marker over-admits (every kind mapped to + * any IRI joins one class) rather than missing a kind two relations really + * route through the same IRI. + */ +type SubsumptionEndpointKeys = + Endpoint extends string ? IriEndpointKey + : Endpoint extends { kind: infer Kind extends string } ? Kind + : never; + +/** + * The closure keys one step of {@link AssignableNodeKinds} adds from the + * ontology-tuple elements whose matched endpoints ({@link MatchedEndpoints}) + * name a key already in `Reached`: a `subClassOf` contributes its child, an + * `equivalentTo`/`sameAs` both sides. + * + * Distributes over the element union. + */ +type DirectlyAssignableKeys = + Relation extends unknown ? + Relation extends ( + { + metaEdge: { name: infer Name extends string }; + from: infer From; + to: infer To; + } + ) ? + [ + Extract< + SubsumptionEndpointKeys>, + Reached + >, + ] extends [never] ? + never + : SubsumptionEndpointKeys + : never + : never; + +type AssignableKeyClosure = + [Exclude, Reached>] extends ( + [never] + ) ? + Reached + : AssignableKeyClosure< + Relation, + Reached | DirectlyAssignableKeys + >; + +/** + * The node kinds of graph `G` an edge endpoint declared as kind `K` admits at + * runtime (`KindRegistry.isAssignableTo(kind, K)`): `K` itself, its + * transitive `subClassOf` descendants, and every kind folded into the same + * class by `equivalentTo`, followed through any mix of the two. + * + * Takes the same conservative arms as {@link SubsumptionAffected}: an + * {@link OntologyTypeErased} ontology, or one element whose literals are + * {@link SubsumptionLiteralsErased}, admits every node kind of `G` rather + * than under-admitting a kind the runtime accepts. Kinds mapped to an IRI + * join one class (see {@link SubsumptionEndpointKeys}). The runtime check + * stays authoritative for every kind this admits. + */ +export type AssignableNodeKinds = + OntologyTypeErased extends true ? K | (keyof G["nodes"] & string) + : true extends SubsumptionLiteralsErased ? + K | (keyof G["nodes"] & string) + : | K + | Extract< + AssignableKeyClosure, + keyof G["nodes"] & string + >; + +/** + * The meta-edge names that make one kind assignable to another at runtime + * (`KindRegistry.isAssignableTo`): a declared composition pair then also + * admits every kind assignable to its part or whole endpoint. + */ +type SubsumptionMetaEdgeName = "subClassOf" | "equivalentTo" | "sameAs"; + +type CompositionMetaEdgeName = "partOf" | "hasPart"; + +/** + * True for an ontology-tuple element that stops {@link CompositionWholeKinds} + * from proving its restriction, so the whole kind falls back to any node kind + * of the graph: + * + * - its meta-edge name is the general `string` (an annotation erased it), so + * it may be a composition pair this type cannot read; + * - it is, or may be, a subsumption relation. A declared pair then also + * admits kinds reached through the subclass/equivalence closure, which no + * single-element test computes — widening is the sound answer; + * - it is a composition pair whose endpoint lost its kind literal. + * + * Distributes over the element union. + */ +type CompositionWholeKindsUndecidable = + Relation extends unknown ? + Relation extends ( + { + metaEdge: { name: infer Name extends string }; + from: infer From; + to: infer To; + } + ) ? + string extends Name ? true + : [Extract] extends [never] ? + [Extract] extends [never] ? false + : true extends EndpointKindErased ? true + : false + : true + : true + : never; + +/** + * The whole kinds one ontology-tuple element declares for part kind `K`: + * `partOf(Part, Whole)` reads part → whole, `hasPart(Whole, Part)` the + * mirror. An endpoint union contributes every kind it can hold. + * + * Distributes over the element union. + */ +type DeclaredCompositionWholeKinds = + Relation extends unknown ? + Relation extends ( + { + metaEdge: { name: infer Name extends string }; + from: infer From; + to: infer To; + } + ) ? + | ([Extract] extends [never] ? never + : [Extract, K>] extends [never] ? never + : EndpointKinds) + | ([Extract] extends [never] ? never + : [Extract, K>] extends [never] ? never + : EndpointKinds) + : never + : never; + +/** + * The whole kinds a part of kind `K` may be attached to in graph `G` — what + * `partOf.whole.kind` accepts on `store.nodes.`. + * + * Exactly the kinds `G`'s ontology declares through `partOf`/`hasPart` when + * that is provable from the ontology tuple; `never` for a kind that is no + * declared part, so stating an attachment for it does not compile. Falls + * back to every node kind of `G` whenever the restriction cannot be proven + * sound: an ontology that lost its tuple shape ({@link OntologyTypeErased}), + * or any element {@link CompositionWholeKindsUndecidable} flags — notably + * any subclass or equivalence relation, since a declared pair then admits + * kinds only the registry's closure can enumerate. + * + * A composition pair added at runtime through `store.evolve()` is invisible + * here, as it is to every type computed from `G`; reach such a pair through + * `store.getNodeCollection(kind)`, whose whole kind is any string. The + * runtime refusal (`COMPOSITION_WHOLE_NOT_DECLARED`) is authoritative either + * way. + */ +export type CompositionWholeKinds = + OntologyTypeErased extends true ? keyof G["nodes"] & string + : true extends CompositionWholeKindsUndecidable ? + keyof G["nodes"] & string + : DeclaredCompositionWholeKinds; + +/** + * The alias type a `from(kind, alias)` call with NO explicit + * `expansion` resolves to, under the polymorphic-by-default + * axis. `PolymorphicNodeType` only when `K` is actually + * {@link SubsumptionAffected} — the structural-subtype check on `subClassOf` + * covers the kind's PROPERTIES, never its `kind` discriminant or `NodeId` + * brand, so a row may come back as a narrower concrete kind whenever the + * axis can expand at all. + * + * **A `subClassOf` (or registered-kind `equivalentTo`/`sameAs`) added at + * runtime is invisible to this type, so the runtime refuses the expansion it + * would mistype.** `store.evolve()` merges the extension into the LIVE + * registry (and `createStoreWithSchema` folds a persisted one back in on + * every later boot) but returns `Store` with the compile-time `G` + * unchanged, so `SubsumptionAffected` still evaluates against the + * graph as it was declared. When that keeps `K` exact while the live + * registry expands it to further kinds, `expandKindsForAxis` + * (`./alias-expansion.ts`) throws `QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES` + * rather than return rows whose `kind` and `NodeId` contradict this type. + * State `{ expansion: "subclasses" }` (typed {@link PolymorphicNodeType} + * unconditionally) or `{ expansion: "exact" }`, or use `fromDynamic()`. + * The refusal reads the graph's ontology values, which cannot show a widened + * `G` (`GraphDef`, or relation literals an `OntologyRelation` annotation + * erased), so it also fires where this type is already polymorphic. + */ +export type AliasNodeType< + G extends GraphDef, + K extends string, +> = SubsumedNodeType; + +/** + * The alias type of a list source that states no `expansion`: each listed + * kind typed as {@link AliasNodeType} types a `from(kind, alias)` over it, + * as one union. Distributed explicitly, because `SubsumptionAffected` asked + * of the whole union would widen every listed kind once any one is affected. + */ +export type ListAliasNodeType = + K extends unknown ? AliasNodeType : never; + +/** + * The alias type of a list source stated `expansion: "subclasses"`: each + * listed kind widened to {@link PolymorphicNodeType}, as one union — what a + * `from(kind, alias, { expansion: "subclasses" })` over each kind types. + */ +export type ListPolymorphicNodeType = + K extends unknown ? PolymorphicNodeType : never; + /** * A map of alias names to their node aliases. */ @@ -790,6 +1218,13 @@ export type QueryBuilderConfig = Readonly<{ schemaIntrospector: SchemaIntrospector; /** Default traversal ontology expansion mode. */ defaultTraversalExpansion: TraversalExpansion; + /** + * Store-level default for the `from`/`to`/`fromDynamic`/`toDynamic` + * expansion axis when an alias states no `expansion`. + * `"subclasses"` (the default everywhere a store doesn't override it) + * makes a supertype query polymorphic. + */ + defaultExpansion: DefaultAliasExpansionAxis; /** Whether this builder's graph enables Operational Identity. */ identityEnabled: boolean; /** Equal-id behavior used by historical identity traversal compilation. */ @@ -806,9 +1241,12 @@ export type QueryBuilderConfig = Readonly<{ export type QueryBuilderState = Readonly<{ startAlias: string; startKinds: readonly string[]; + /** The kinds the source named, before `startExpansion` expanded them. */ + startRequestedKinds: readonly string[]; /** The current alias (last traversal target, or startAlias if no traversals) */ currentAlias: string; - includeSubClasses: boolean; + /** The start alias's resolved expansion axis — see `alias-expansion.ts`. */ + startExpansion: AliasExpansionAxis; traversals: readonly Traversal[]; predicates: readonly NodePredicate[]; /** Filters completed match rows without restricting optional or recursive expansion. */ @@ -847,6 +1285,17 @@ export type CreateQueryBuilderOptions = Readonly<{ schema?: SqlSchema; /** Default traversal ontology expansion mode (default: "inverse"). */ defaultTraversalExpansion?: TraversalExpansion; + /** + * Default expansion axis for `from`/`to`/`fromDynamic`/`toDynamic` when an + * alias states no `expansion` (default: `"subclasses"`). A + * store-issued builder threads its own `queryDefaults.expansion`; a + * standalone `createQueryBuilder` defaults to `"subclasses"` too, so a + * store-less builder and a store-issued one agree. + * + * Spelled `| undefined` so a stated `undefined` forwards from a caller's + * own options bag, the same way the per-alias `expansion` option does. + */ + defaultExpansion?: DefaultAliasExpansionAxis | undefined; /** * Overrides whether a builder may compile identity-aware traversals * (`traverse(..., { includeIdentityMembers: true })`). diff --git a/packages/typegraph/src/query/builder/validation.ts b/packages/typegraph/src/query/builder/validation.ts index fac029bb2..2ab44ba34 100644 --- a/packages/typegraph/src/query/builder/validation.ts +++ b/packages/typegraph/src/query/builder/validation.ts @@ -446,11 +446,56 @@ export function validateTraversalOptions( }); } +/** The surfaces that page a query with a keyset cursor. */ +export type CursorPaginationSurface = "paginate" | "page" | "stream"; + +/** + * Refuses keyset pagination over a query with a recursive traversal. + * + * A keyset cursor must identify the row it follows. A recursive traversal + * fans one start node out into rows that have no single edge to extend the + * start node's keys with, so the cursor can only name the start node. The next + * page then resumes strictly after that node and loses every row of it not yet + * returned. A `recursive({ maxHops: 1 })` step is refused too: the compiler's + * lowering of that shape to a fixed hop is an engine detail the cursor does + * not see. `parts()` / `wholes()` with `maxHops: 1` and neither `depth` nor + * `path` are different: the builder emits a fixed step, never a recursive one, + * so this refusal does not apply and the cursor carries that step's edge key + * like any `traverse()`. + * + * Every pagination surface calls this before building or executing anything, so + * the refusal is the same on every backend and costs no statement. + */ +export function refuseRecursivePagination( + state: QueryBuilderState, + surface: CursorPaginationSurface, +): void { + if ( + !state.traversals.some( + (traversal) => traversal.variableLength !== undefined, + ) + ) + return; + throw new ConfigurationError( + `${surface}() cannot page a query with a recursive traversal (recursive(), parts() or wholes()): ` + + "a cursor cannot position itself inside the rows one start node fans out into, " + + "so the rows past a page boundary would be silently dropped.", + { code: "RECURSIVE_PAGINATION_UNSUPPORTED", operation: surface }, + { + suggestion: + "Read the walk with execute() and an explicit limit() or recursive({ maxHops }), " + + "or page the start nodes in a query without the recursive traversal and run the recursive walk for each page.", + }, + ); +} + /** Cursor pagination owns its bounds; conflicting or invalid options must not be ignored. */ export function validatePaginationOptions( state: QueryBuilderState, options: PaginateOptions, + surface: Exclude, ): void { + refuseRecursivePagination(state, surface); if (state.limit !== undefined || state.offset !== undefined) { throw new ValidationError( "Cursor pagination cannot honor query limit/offset; use first/after or last/before instead.", diff --git a/packages/typegraph/src/query/compiler/emitter/standard-builders.ts b/packages/typegraph/src/query/compiler/emitter/standard-builders.ts index 7119e9a6b..74617a322 100644 --- a/packages/typegraph/src/query/compiler/emitter/standard-builders.ts +++ b/packages/typegraph/src/query/compiler/emitter/standard-builders.ts @@ -322,7 +322,7 @@ export function buildStandardTraversalCte( const nodeKinds = traversal.nodeKinds; const nodeKindFilter = compileKindFilter(sql.raw("n.kind"), nodeKinds); - const edgeTemporalFilter = temporalFilterPass.forAlias("e"); + const edgeRowFilter = temporalFilterPass.forTraversalEdges(traversal, "e"); const nodeTemporalFilter = temporalFilterPass.forAlias("n"); const nodeCteContext: PredicateCompilerContext = { @@ -346,7 +346,7 @@ export function buildStandardTraversalCte( const baseWhereClauses = [ sql`e.graph_id = ${graphId}`, nodeKindFilter, - edgeTemporalFilter, + edgeRowFilter, nodeTemporalFilter, ...nodePredicateClauses, ...edgePredicateClauses, @@ -392,6 +392,21 @@ export function buildStandardTraversalCte( : ctx.dialect.capabilities.emitNotMaterializedHint ? sql`NOT MATERIALIZED ` : sql``; + // A traversal CTE holds one row per edge matched from its frontier, and the + // outer query joins it back on the frontier node's (id, kind). The start CTE + // holds each node once, but an earlier traversal's CTE holds a node once per + // path into it; expanding each of those rows and then joining on the node + // would return every path once per path. So unless this CTE carries the + // whole path forward itself, it expands each frontier node exactly once. The + // derived table keeps the CTE's name, so the frontier reads the same either + // way. + const frontierHoldsOneRowPerNode = + carryForwardPreviousColumns || previousAlias === ast.start.alias; + const frontierSource = + frontierHoldsOneRowPerNode ? + sql`cte_${sql.raw(previousAlias)}` + : sql`(SELECT DISTINCT ${sql.raw(previousAlias)}_id, ${sql.raw(previousAlias)}_kind FROM cte_${sql.raw(previousAlias)}) AS cte_${sql.raw(previousAlias)}`; + const previousIdColumn = sql`cte_${sql.raw(previousAlias)}.${sql.raw(`${previousAlias}_id`)}`; const previousKindColumn = sql`cte_${sql.raw(previousAlias)}.${sql.raw(`${previousAlias}_kind`)}`; const identityFrontierExpansion = @@ -490,7 +505,7 @@ export function buildStandardTraversalCte( if (pinFrontierAheadOfEdges) { return sql` SELECT ${sql.join(selectColumns, sql`, `)} - FROM cte_${sql.raw(previousAlias)} + FROM ${frontierSource} ${frontierJoin} CROSS JOIN ${ctx.schema.edgesTable} e JOIN ${ctx.schema.nodesTable} n ON n.graph_id = e.graph_id @@ -502,7 +517,7 @@ export function buildStandardTraversalCte( return sql` SELECT ${sql.join(selectColumns, sql`, `)} - FROM cte_${sql.raw(previousAlias)} + FROM ${frontierSource} ${frontierJoin} JOIN ${ctx.schema.edgesTable} e ON ${sourceJoin} JOIN ${ctx.schema.nodesTable} n ON n.graph_id = e.graph_id @@ -1269,8 +1284,8 @@ export function buildStandardEmbeddingsCte( const tableName = vectorStrategy.tableName(graphId, kind, fieldPath); const slotDescriptor = vectorSlots?.get(vectorSlotKey(kind, fieldPath)); // Use the predicate's explicit metric if given, else this kind's DECLARED - // metric (the one its ANN index was built for). Resolving per kind keeps an - // includeSubClasses union correct when subkinds declare different metrics. + // metric (the one its ANN index was built for). Resolving per kind keeps a + // subclass-expanded union correct when subkinds declare different metrics. const branchMetric = metric ?? slotDescriptor?.metric ?? "cosine"; // Approximate opt-in: retrieve this kind's candidates via the diff --git a/packages/typegraph/src/query/compiler/index.ts b/packages/typegraph/src/query/compiler/index.ts index 82e338b22..6bbfdf4d3 100644 --- a/packages/typegraph/src/query/compiler/index.ts +++ b/packages/typegraph/src/query/compiler/index.ts @@ -479,7 +479,11 @@ function tryLowerSingleHopRecursiveTraversal( return undefined; } - if (variableLength.minDepth !== 1 || variableLength.maxDepth !== 1) { + if ( + variableLength.exhaustive === true || + variableLength.minDepth !== 1 || + variableLength.maxDepth !== 1 + ) { return undefined; } if ( @@ -837,7 +841,7 @@ function compileCountAggregateFastPath( const edgeKinds = [...new Set(traversal.edgeKinds)]; const nodeKinds = traversal.nodeKinds; - const edgeTemporalFilter = temporalFilterPass.forAlias("e"); + const edgeRowFilter = temporalFilterPass.forTraversalEdges(traversal, "e"); const nodeTemporalFilter = temporalFilterPass.forAlias("n"); const nodePredicateContext: PredicateCompilerContext = { @@ -896,7 +900,7 @@ function compileCountAggregateFastPath( compileKindFilter(sql.raw("e.kind"), edgeKinds), compileKindFilter(sql.raw(`e.${joinKindField}`), previousNodeKinds), compileKindFilter(sql.raw(`e.${targetKindField}`), nodeKinds), - edgeTemporalFilter, + edgeRowFilter, ...edgePredicateClauses, // INNER JOIN mode: node-side filters in WHERE constrain every aggregate. ...(requiresNodeJoin && useInnerJoin ? diff --git a/packages/typegraph/src/query/compiler/passes/recursive.ts b/packages/typegraph/src/query/compiler/passes/recursive.ts index 21a1a12cd..0c485f2eb 100644 --- a/packages/typegraph/src/query/compiler/passes/recursive.ts +++ b/packages/typegraph/src/query/compiler/passes/recursive.ts @@ -14,9 +14,11 @@ export type VariableLengthTraversal = Traversal & { /** * Selects and validates the variable-length traversal target for recursive compilation. * - * Invariants: - * - Query must contain exactly one variable-length traversal. - * - Recursive mode currently supports only a single traversal in the query. + * Invariant: the AST compiled here holds exactly one traversal, and it is the + * variable-length one. A query with several traversals, recursive or fixed, + * compiles through `compileMultiStageRecursiveQuery` (`recursive-chain.ts`), + * which isolates each traversal into a single-traversal stage AST before it + * reaches this pass; the refusal below guards that contract. */ export function runRecursiveTraversalSelectionPass( ast: QueryAst, diff --git a/packages/typegraph/src/query/compiler/passes/temporal.ts b/packages/typegraph/src/query/compiler/passes/temporal.ts index cccccdcb5..72c6da7f6 100644 --- a/packages/typegraph/src/query/compiler/passes/temporal.ts +++ b/packages/typegraph/src/query/compiler/passes/temporal.ts @@ -1,15 +1,27 @@ -import type { QueryAst } from "../../ast"; -import { type SqlFragment } from "../../sql-fragment"; +import type { QueryAst, Traversal } from "../../ast"; +import { sql, type SqlFragment } from "../../sql-fragment"; import { type RecordedReadBinding } from "../schema"; +import type { RECORDED_TEMPORAL_COLUMNS } from "../temporal"; import { + compileEdgeMembershipFilters, compileTemporalFilter, currentReadInstantFor, extractTemporalOptions, type ReadInstantMode, + recordedIntervalColumns, } from "../temporal"; export type TemporalFilterPass = Readonly<{ forAlias: (tableAlias?: string) => SqlFragment; + /** + * The row filter a traversal's edges read through: the composition + * membership a `parts()`/`wholes()` traversal carries + * (`Traversal.compositionMembership`), evaluated at this read's coordinate, + * or {@link forAlias}'s validity window for every other traversal. Every + * emitter of a traversal's edge rows calls this, so a composition walk can + * never fall back to the window on one path. + */ + forTraversalEdges: (traversal: Traversal, tableAlias: string) => SqlFragment; /** * The bound "current" valid-time read instant, sampled once when this pass is * created — the same value {@link forAlias} compares against. Exposed so @@ -18,6 +30,11 @@ export type TemporalFilterPass = Readonly<{ * of resampling the clock, which would break the single-snapshot invariant. */ currentInstant: SqlFragment; + /** + * The extra columns {@link forAlias}'s filter references + * ({@link recordedIntervalColumns}). + */ + recordedColumns: readonly (typeof RECORDED_TEMPORAL_COLUMNS)[number][]; }>; /** @@ -35,14 +52,39 @@ export function createTemporalFilterPass( recordedReadBinding?: RecordedReadBinding, ): TemporalFilterPass { const currentTimestamp = currentReadInstantFor(readInstant); + const recordedColumns = recordedIntervalColumns( + ast.recordedAsOf, + recordedReadBinding, + ); + function forAlias(tableAlias?: string): SqlFragment { + return compileTemporalFilter({ + ...extractTemporalOptions(ast, tableAlias), + currentTimestamp, + recordedReadBinding, + }); + } return { - forAlias(tableAlias?: string): SqlFragment { - return compileTemporalFilter({ - ...extractTemporalOptions(ast, tableAlias), - currentTimestamp, - recordedReadBinding, - }); + forAlias, + forTraversalEdges(traversal, tableAlias): SqlFragment { + if (traversal.compositionMembership === undefined) { + return forAlias(tableAlias); + } + const { mode, asOf, recordedAsOf } = extractTemporalOptions(ast); + return sql.join( + [ + ...compileEdgeMembershipFilters({ + members: traversal.compositionMembership, + tableAlias, + mode, + asOf, + recordedAsOf, + recordedReadBinding, + }), + ], + sql` AND `, + ); }, currentInstant: currentTimestamp, + recordedColumns, }; } diff --git a/packages/typegraph/src/query/compiler/predicates.ts b/packages/typegraph/src/query/compiler/predicates.ts index f8419a166..d3ce30afb 100644 --- a/packages/typegraph/src/query/compiler/predicates.ts +++ b/packages/typegraph/src/query/compiler/predicates.ts @@ -550,8 +550,13 @@ export type PredicateCompilerContext = Readonly<{ */ recursiveTraversal?: RecursiveTraversalVerdict; databaseExpressionAggregates?: boolean; - /** Resolves completed-match fields to the CTE carrying their columns. */ - resolveFieldCteAlias?: (field: FieldRef) => string | undefined; + /** + * Resolves completed-match fields to the CTE carrying their columns. Read + * by database expressions and by the comparison, tuple-comparison and + * null-check predicates a keyset cursor is built from; the other predicate + * forms are never placed in a completed-match filter. + */ + resolveFieldCteAlias?: FieldCteAliasResolver; /** Final recursive CTE qualifier used by completed-result expressions. */ recursiveResultAlias?: string; }>; @@ -603,6 +608,8 @@ export function assertRecursiveTraversalSupported( ); } +type FieldCteAliasResolver = (field: FieldRef) => string | undefined; + /** * Compiles a predicate expression to SQL. */ @@ -616,11 +623,21 @@ export function compilePredicateExpression( switch (expr.__type) { case "comparison": { - return compileComparisonPredicate(expr, dialect, cteColumnPrefix); + return compileComparisonPredicate( + expr, + dialect, + cteColumnPrefix, + ctx.resolveFieldCteAlias, + ); } case "tuple_comparison": { - return compileTupleComparisonPredicate(expr, dialect, cteColumnPrefix); + return compileTupleComparisonPredicate( + expr, + dialect, + cteColumnPrefix, + ctx.resolveFieldCteAlias, + ); } case "string_op": { @@ -669,7 +686,7 @@ export function compilePredicateExpression( const field = compileFieldTextValue( expr.field, dialect, - undefined, + ctx.resolveFieldCteAlias?.(expr.field), undefined, cteColumnPrefix, ); @@ -806,6 +823,7 @@ function compileComparisonPredicate( }, dialect: DialectAdapter, cteColumnPrefix?: string, + resolveFieldCteAlias?: FieldCteAliasResolver, ): SqlFragment { // Handle ParameterRef on the right side if (isParameterRef(expr.right)) { @@ -814,7 +832,7 @@ function compileComparisonPredicate( expr.left, dialect, parameterValueType, - undefined, + resolveFieldCteAlias?.(expr.left), undefined, cteColumnPrefix, ); @@ -848,7 +866,7 @@ function compileComparisonPredicate( expr.left, dialect, valueType, - undefined, + resolveFieldCteAlias?.(expr.left), undefined, cteColumnPrefix, ); @@ -877,7 +895,7 @@ function compileComparisonPredicate( right, dialect, valueType, - undefined, + resolveFieldCteAlias?.(right), undefined, cteColumnPrefix, ); @@ -907,6 +925,7 @@ function compileTupleComparisonPredicate( expr: Extract, dialect: DialectAdapter, cteColumnPrefix?: string, + resolveFieldCteAlias?: FieldCteAliasResolver, ): SqlFragment { if (expr.fields.length !== expr.values.length) throw new UnsupportedPredicateError( @@ -917,7 +936,7 @@ function compileTupleComparisonPredicate( field, dialect, resolveComparisonValueType(field, requireDefined(expr.values[index])), - undefined, + resolveFieldCteAlias?.(field), undefined, cteColumnPrefix, ), diff --git a/packages/typegraph/src/query/compiler/recursive-chain.ts b/packages/typegraph/src/query/compiler/recursive-chain.ts index e29444f0a..0b3bc4b03 100644 --- a/packages/typegraph/src/query/compiler/recursive-chain.ts +++ b/packages/typegraph/src/query/compiler/recursive-chain.ts @@ -52,10 +52,10 @@ function stageAst( start: { alias: traversal.joinFromAlias, kinds: getNodeKindsForAlias(ast, traversal.joinFromAlias), - includeSubClasses: + expansion: index === 0 && traversal.joinFromAlias === ast.start.alias ? - ast.start.includeSubClasses - : false, + ast.start.expansion + : "exact", }, traversals: [traversal], predicates: ast.predicates.filter((predicate) => diff --git a/packages/typegraph/src/query/compiler/recursive.ts b/packages/typegraph/src/query/compiler/recursive.ts index 5d7fa1409..64099bdf6 100644 --- a/packages/typegraph/src/query/compiler/recursive.ts +++ b/packages/typegraph/src/query/compiler/recursive.ts @@ -9,7 +9,12 @@ import { UnsupportedPredicateError, } from "../../errors"; import { KIND_QUALIFIED_PATH_TOKEN_SEPARATOR } from "../../utils/path"; -import { type FieldRef, type QueryAst, type SelectiveField } from "../ast"; +import { + type FieldRef, + type QueryAst, + type SelectiveField, + type TraversalDirection, +} from "../ast"; import { type DialectAdapter, type DialectRecursiveQueryStrategy, @@ -89,6 +94,67 @@ export const MAX_EXPLICIT_RECURSIVE_DEPTH = 1000; const NO_ALWAYS_REQUIRED_COLUMNS = new Set(); +type EdgeJoinField = "from_id" | "to_id"; +type EdgeKindField = "from_kind" | "to_kind"; + +/** The normalizing CTE's uniform endpoint columns a mixed traversal reads. */ +type DirectedEdgeJoinField = EdgeJoinField | "tg_source_id" | "tg_target_id"; +type DirectedEdgeKindField = + EdgeKindField | "tg_source_kind" | "tg_target_kind"; + +type EdgeEndpointColumns = Readonly<{ + joinField: EdgeJoinField; + targetField: EdgeJoinField; + joinKindField: EdgeKindField; + targetKindField: EdgeKindField; +}>; + +/** The edge endpoint columns a walk entering an edge in `direction` joins and exits on. */ +function traversalEndpointColumns( + direction: TraversalDirection, +): EdgeEndpointColumns { + return direction === "out" ? + { + joinField: "from_id", + targetField: "to_id", + joinKindField: "from_kind", + targetKindField: "to_kind", + } + : { + joinField: "to_id", + targetField: "from_id", + joinKindField: "to_kind", + targetKindField: "from_kind", + }; +} + +function reversedEndpointColumns( + columns: EdgeEndpointColumns, +): EdgeEndpointColumns { + return { + joinField: columns.targetField, + targetField: columns.joinField, + joinKindField: columns.targetKindField, + targetKindField: columns.joinKindField, + }; +} + +/** + * The column a mixed-orientation traversal's normalizing CTE carries each + * arm's qualified-path direction in, since its uniform `tg_source_id` join + * no longer says which physical endpoint the walk entered the edge from. + */ +const QUALIFIED_PATH_DIRECTION_COLUMN = "tg_path_direction"; + +/** + * The qualified-path direction of an edge the walk joined on `joinField`: + * entering at `from_id` follows the edge as stored (`"out"`), entering at + * `to_id` walks it reversed (`"in"`). + */ +function qualifiedPathDirection(joinField: EdgeJoinField): "out" | "in" { + return joinField === "from_id" ? "out" : "in"; +} + /** * The per-step cycle/path token for a node table alias, in the shape * {@link recursivePathTokenShape} chose: the bare id, or the composite @@ -115,10 +181,25 @@ function compilePathToken( * keeps same-id nodes of different kinds distinct on a recursive walk's path. */ export function compileKindQualifiedPathToken(tableAlias: string): SqlFragment { + return compileKindQualifiedToken( + sql.raw(`${tableAlias}.kind`), + sql.raw(`${tableAlias}.id`), + ); +} + +/** + * The composite `kind || SEP || id` token over explicit kind and id columns: + * the SQL spelling of `kindQualifiedToken` (`utils/path.ts`), so a row's + * token compares equal to the one JavaScript builds for the same (kind, id). + */ +export function compileKindQualifiedToken( + kindColumn: SqlFragment, + idColumn: SqlFragment, +): SqlFragment { // Parenthesized so dialect path builders that splice this fragment into a // larger `||` chain keep it one text operand — PostgreSQL's `text[] || x` // would otherwise re-associate and coerce the separator as an array literal. - return sql`(${sql.raw(`${tableAlias}.kind`)} || ${KIND_QUALIFIED_PATH_TOKEN_SEPARATOR} || ${sql.raw(`${tableAlias}.id`)})`; + return sql`(${kindColumn} || ${KIND_QUALIFIED_PATH_TOKEN_SEPARATOR} || ${idColumn})`; } type RecursiveQueryPassState = Readonly<{ @@ -346,15 +427,16 @@ function compileVariableLengthQueryWithRecursiveCteStrategy( } // Build the recursive CTE - const recursiveCte = compileRecursiveCte( - ast, - vlTraversal, - graphId, - ctx, - requiredColumnsByAlias, - temporalFilterPass, - seedQuery, - ); + const { cte: recursiveCte, precedingCte: directedEdgesCte } = + compileRecursiveCte( + ast, + vlTraversal, + graphId, + ctx, + requiredColumnsByAlias, + temporalFilterPass, + seedQuery, + ); // A historical read reconstructs its class relation from outside the recursive // term, so the ledger fixed point is evaluated once for the whole traversal @@ -392,14 +474,17 @@ function compileVariableLengthQueryWithRecursiveCteStrategy( const orderBy = compileRecursiveOrderBy(ast, ctx); const limitOffset = compileLimitOffset(ast, dialect); + const precedingCtes = [ + ...(directedEdgesCte === undefined ? [] : [directedEdgesCte]), + ...(identityClassCte === undefined ? [] : [identityClassCte]), + ]; + return emitRecursiveQuerySql({ depthFilter, ...(limitOffset === undefined ? {} : { limitOffset }), logicalPlan, ...(orderBy === undefined ? {} : { orderBy }), - ...(identityClassCte === undefined ? - {} - : { precedingCtes: [identityClassCte] }), + ...(precedingCtes.length === 0 ? {} : { precedingCtes }), projection, recursiveCte, ...(ctx.recursiveResultAlias === undefined ? @@ -419,6 +504,13 @@ export function hasVariableLengthTraversal(ast: QueryAst): boolean { // Recursive CTE Generation // ============================================================ +/** {@link compileRecursiveCte}'s result: the recursive CTE, plus a normalizing helper CTE it reads when the traversal mixes edge orientations. */ +type RecursiveCteResult = Readonly<{ + cte: SqlFragment; + /** Present only when the traversal has a non-empty `inverseEdgeKinds`. */ + precedingCte?: SqlFragment; +}>; + /** * Compiles the recursive CTE for variable-length traversal. */ @@ -430,7 +522,7 @@ function compileRecursiveCte( requiredColumnsByAlias: RequiredColumnsByAlias | undefined, temporalFilterPass: TemporalFilterPass, seedQuery?: SqlFragment, -): SqlFragment { +): RecursiveCteResult { const { dialect } = ctx; const startAlias = ast.start.alias; const startKinds = ast.start.kinds; @@ -491,7 +583,7 @@ function compileRecursiveCte( const nodeKindFilter = compileKindFilter(nodeKinds, "n.kind"); const startTemporalFilter = temporalFilterPass.forAlias("n0"); - const edgeTemporalFilter = temporalFilterPass.forAlias("e"); + const edgeRowFilter = temporalFilterPass.forTraversalEdges(traversal, "e"); const nodeTemporalFilter = temporalFilterPass.forAlias("n"); // Start predicates (with cteColumnPrefix "" for raw n0 columns) @@ -529,13 +621,28 @@ function compileRecursiveCte( // Max depth condition: // - unlimited traversals are capped at MAX_RECURSIVE_DEPTH // - explicit maxDepth traversals must not exceed MAX_EXPLICIT_RECURSIVE_DEPTH + // - exhaustive traversals carry no ceiling, so only the per-path cycle check + // ends them + if (vl.exhaustive === true) { + if (!shouldEnforceCycleCheck) { + throw new UnsupportedPredicateError( + 'An exhaustive recursive traversal requires cyclePolicy "prevent": without a hop ceiling, cycle prevention is its only termination guarantee', + ); + } + if (vl.maxDepth > 0) { + throw new UnsupportedPredicateError( + `An exhaustive recursive traversal cannot also state maxDepth(${vl.maxDepth}); state one or the other`, + ); + } + } if (vl.maxDepth > MAX_EXPLICIT_RECURSIVE_DEPTH) { throw new UnsupportedPredicateError( `Recursive traversal maxHops(${vl.maxDepth}) exceeds maximum explicit depth of ${MAX_EXPLICIT_RECURSIVE_DEPTH}`, ); } const effectiveMaxDepth = vl.maxDepth > 0 ? vl.maxDepth : MAX_RECURSIVE_DEPTH; - const maxDepthCondition = sql`r.depth < ${effectiveMaxDepth}`; + const maxDepthCondition = + vl.exhaustive === true ? undefined : sql`r.depth < ${effectiveMaxDepth}`; const baseToken = compilePathToken("n0", pathTokenShape); const stepToken = compilePathToken("n", pathTokenShape); @@ -581,9 +688,9 @@ function compileRecursiveCte( const recursiveBaseWhereClauses: SqlFragment[] = [ sql`e.graph_id = ${graphId}`, nodeKindFilter, - edgeTemporalFilter, + edgeRowFilter, nodeTemporalFilter, - maxDepthCondition, + ...(maxDepthCondition === undefined ? [] : [maxDepthCondition]), ...(stopExpression === undefined ? [] : [ @@ -607,8 +714,8 @@ function compileRecursiveCte( */ function compileWorktableJoinClauses( branch: Readonly<{ - joinField: "from_id" | "to_id"; - joinKindField: "from_kind" | "to_kind"; + joinField: DirectedEdgeJoinField; + joinKindField: DirectedEdgeKindField; }>, ): SqlFragment[] { const edgeId = sql`e.${sql.raw(branch.joinField)}`; @@ -628,14 +735,29 @@ function compileRecursiveCte( function compileRecursiveBranch( branch: Readonly<{ - joinField: "from_id" | "to_id"; - targetField: "from_id" | "to_id"; - joinKindField: "from_kind" | "to_kind"; - targetKindField: "from_kind" | "to_kind"; + joinField: DirectedEdgeJoinField; + targetField: DirectedEdgeJoinField; + joinKindField: DirectedEdgeKindField; + targetKindField: DirectedEdgeKindField; edgeKinds: readonly string[]; duplicateGuard?: SqlFragment | undefined; + /** + * The qualified-path direction label of the edge this branch walks: + * a literal for a single-orientation branch, or the normalizing CTE's + * per-arm {@link QUALIFIED_PATH_DIRECTION_COLUMN} for a mixed one. + */ + pathDirection: SqlFragment; + /** + * The relation `e` reads from. Defaults to the real edges table; + * a mixed-orientation traversal (see + * {@link compileRecursiveDirectedEdgesCte}) passes the normalizing + * CTE instead, so `joinField`/`targetField` name that CTE's uniform + * `tg_source_*`/`tg_target_*` columns rather than `from_id`/`to_id`. + */ + edgeSource?: SqlFragment; }>, ): SqlFragment { + const edgeSource = branch.edgeSource ?? ctx.schema.edgesTable; const recursiveFilterClauses = [ ...recursiveBaseWhereClauses, compileKindFilter(branch.edgeKinds, "e.kind"), @@ -668,7 +790,7 @@ function compileRecursiveCte( const qualifiedPath = dialect.appendTextJsonArray(sql`r.qualified_path`, [ sql`e.kind`, sql`e.id`, - sql`${branch.joinField === "from_id" ? "out" : "in"}`, + branch.pathDirection, sql`n.kind`, sql`n.id`, ]); @@ -687,7 +809,7 @@ function compileRecursiveCte( SELECT ${sql.join(recursiveSelectColumns, sql`, `)} FROM recursive_cte r ${frontierJoin} - CROSS JOIN ${ctx.schema.edgesTable} e + CROSS JOIN ${edgeSource} e JOIN ${ctx.schema.nodesTable} n ON n.graph_id = e.graph_id AND n.id = e.${sql.raw(branch.targetField)} AND n.kind = e.${sql.raw(branch.targetKindField)} @@ -699,7 +821,7 @@ function compileRecursiveCte( SELECT ${sql.join(recursiveSelectColumns, sql`, `)} FROM recursive_cte r ${frontierJoin} - JOIN ${ctx.schema.edgesTable} e ON ${sql.join(recursiveJoinClauses, sql` AND `)} + JOIN ${edgeSource} e ON ${sql.join(recursiveJoinClauses, sql` AND `)} JOIN ${ctx.schema.nodesTable} n ON n.graph_id = e.graph_id AND n.id = e.${sql.raw(branch.targetField)} AND n.kind = e.${sql.raw(branch.targetKindField)} @@ -707,51 +829,120 @@ function compileRecursiveCte( `; } - const directJoinField = direction === "out" ? "from_id" : "to_id"; - const directTargetField = direction === "out" ? "to_id" : "from_id"; - const directJoinKindField = direction === "out" ? "from_kind" : "to_kind"; - const directTargetKindField = direction === "out" ? "to_kind" : "from_kind"; - - const directBranch = compileRecursiveBranch({ - joinField: directJoinField, - targetField: directTargetField, - joinKindField: directJoinKindField, - targetKindField: directTargetKindField, - edgeKinds: directEdgeKinds, - }); + const directColumns = traversalEndpointColumns(direction); - function compileInverseRecursiveBranch(): SqlFragment { - const inverseJoinField = direction === "out" ? "to_id" : "from_id"; - const inverseTargetField = direction === "out" ? "from_id" : "to_id"; - const inverseJoinKindField = direction === "out" ? "to_kind" : "from_kind"; - const inverseTargetKindField = - direction === "out" ? "from_kind" : "to_kind"; + // PostgreSQL allows one recursive reference per recursive term, so both + // orientations are normalized into this single CTE before recursion starts. + const mixesOrientations = inverseEdgeKinds.length > 0; + const directedEdgesCteName = `${traversal.edgeAlias}_directed_edges`; + + function compileRecursiveDirectedEdgesCte(): SqlFragment { + // A self-loop of a kind in both lists would yield two identical rows; the + // guard drops the inverse arm's copy. const duplicateGuard = compileInverseTraversalDuplicateGuard( directEdgeKinds, inverseEdgeKinds, (overlappingKinds) => compileKindFilter(overlappingKinds, "e.kind"), ); - const inverseBranch = compileRecursiveBranch({ - joinField: inverseJoinField, - targetField: inverseTargetField, - joinKindField: inverseJoinKindField, - targetKindField: inverseTargetKindField, - edgeKinds: inverseEdgeKinds, + // `e.*` materializes every edge column (props included) into this CTE + // on every execution — measured to switch SQLite from a covering-index + // seek to a full MATERIALIZE + automatic index build. The recursive term + // only ever reads graph_id/kind (structural filters), the temporal + // columns — including `recorded_from`/`recorded_to` on a recorded-pinned + // read, which `temporalFilterPass.recordedColumns` is the one owner of, + // so that `edgeRowFilter`'s references to them below always have a + // column to read — and the four tg_source_*/tg_target_* aliases derived + // below — UNLESS a predicate on this edge alias or the identity + // frontier widening can reference an arbitrary column (e.g. `e.props` + // via `whereEdge`), in which case only `e.*` is guaranteed to carry + // whatever they need. Narrow only in the provably safe case; keep the + // wildcard whenever either is present. + const requiresFullEdgeProjection = + edgePredicates.length > 0 || identityFrontierExpansion !== undefined; + // A qualified path reads the edge id and the direction each arm walked + // the edge in, so both are projected whenever that path is requested. + const qualifiedPath = vl.pathFormat === "qualified"; + const directedEdgeBaseColumns: SqlFragment = + requiresFullEdgeProjection ? + sql`e.*` + : sql.join( + [ + sql`e.graph_id, e.kind, e.valid_from, e.valid_to, e.deleted_at`, + ...(qualifiedPath ? [sql`e.id`] : []), + ...temporalFilterPass.recordedColumns.map( + (column) => sql`e.${sql.raw(column)}`, + ), + ], + sql`, `, + ); + function armDirectionColumn(joinField: EdgeJoinField): SqlFragment { + return qualifiedPath ? + sql`, ${sql.raw(`'${qualifiedPathDirection(joinField)}'`)} AS ${sql.raw(QUALIFIED_PATH_DIRECTION_COLUMN)}` + : sql``; + } + + // Both arms also filter on `e.graph_id` and on the edge row filter — + // not only the recursive term downstream — so this CTE normalizes just the + // current graph's currently-visible edge versions of these kinds instead + // of every graph's every version, which SQLite would MATERIALIZE and index + // on every execution. The result is unchanged either way (the recursive + // term applies both again, and `edgeRowFilter` is row-local over + // `e`'s own temporal columns); this only bounds what gets normalized + // before those filters run. + function compileDirectedEdgeArm( + columns: EdgeEndpointColumns, + edgeKinds: readonly string[], + duplicateGuard?: SqlFragment, + ): SqlFragment { + return sql` + SELECT ${directedEdgeBaseColumns}, + e.${sql.raw(columns.joinField)} AS tg_source_id, + e.${sql.raw(columns.joinKindField)} AS tg_source_kind, + e.${sql.raw(columns.targetField)} AS tg_target_id, + e.${sql.raw(columns.targetKindField)} AS tg_target_kind${armDirectionColumn(columns.joinField)} + FROM ${ctx.schema.edgesTable} e + WHERE e.graph_id = ${graphId} AND ${compileKindFilter(edgeKinds, "e.kind")} AND ${edgeRowFilter}${ + duplicateGuard === undefined ? sql`` : sql` AND ${duplicateGuard}` + } + `; + } + const directArm = compileDirectedEdgeArm(directColumns, directEdgeKinds); + const inverseArm = compileDirectedEdgeArm( + reversedEndpointColumns(directColumns), + inverseEdgeKinds, duplicateGuard, - }); + ); return sql` - ${directBranch} - UNION ALL - ${inverseBranch} + ${sql.raw(directedEdgesCteName)} AS ( + ${directArm} + UNION ALL + ${inverseArm} + ) `; } const recursiveBranchSql = - inverseEdgeKinds.length === 0 ? - directBranch - : compileInverseRecursiveBranch(); + mixesOrientations ? + compileRecursiveBranch({ + joinField: "tg_source_id", + targetField: "tg_target_id", + joinKindField: "tg_source_kind", + targetKindField: "tg_target_kind", + // Both inputs are already deduped individually (above); the union + // is not automatically, so a kind present in both — the ordinary + // `direction: "both"` case on one symmetric kind — would otherwise + // bind a duplicate parameter in the emitted `IN` list. + edgeKinds: [...new Set([...directEdgeKinds, ...inverseEdgeKinds])], + pathDirection: sql.raw(`e.${QUALIFIED_PATH_DIRECTION_COLUMN}`), + edgeSource: sql.raw(directedEdgesCteName), + }) + : compileRecursiveBranch({ + ...directColumns, + edgeKinds: directEdgeKinds, + pathDirection: sql`${qualifiedPathDirection(directColumns.joinField)}`, + }); const baseSelectColumns = [ ...startColumnsFromBase, ...nodeColumnsFromBase, @@ -772,19 +963,24 @@ function compileRecursiveCte( ); } - return sql` - recursive_cte AS ( - -- Base case: starting nodes - SELECT ${sql.join(baseSelectColumns, sql`, `)} - FROM ${ctx.schema.nodesTable} n0 - WHERE ${sql.join(baseWhereClauses, sql` AND `)} + return { + cte: sql` + recursive_cte AS ( + -- Base case: starting nodes + SELECT ${sql.join(baseSelectColumns, sql`, `)} + FROM ${ctx.schema.nodesTable} n0 + WHERE ${sql.join(baseWhereClauses, sql` AND `)} - UNION ALL + UNION ALL - -- Recursive case: follow edges - ${recursiveBranchSql} - ) - `; + -- Recursive case: follow edges + ${recursiveBranchSql} + ) + `, + ...(mixesOrientations ? + { precedingCte: compileRecursiveDirectedEdgesCte() } + : {}), + }; } // ============================================================ diff --git a/packages/typegraph/src/query/compiler/schema.ts b/packages/typegraph/src/query/compiler/schema.ts index 286fe35de..613a39b87 100644 --- a/packages/typegraph/src/query/compiler/schema.ts +++ b/packages/typegraph/src/query/compiler/schema.ts @@ -58,6 +58,10 @@ export type SqlTableNames = Readonly<{ identityClosure?: string | undefined; /** Derived separation relation over identity classes */ identitySeparation?: string | undefined; + /** Identity transition log — append-only annotation on the recorded axis (default: "typegraph_identity_transitions") */ + identityTransitions?: string | undefined; + /** Per-graph identity-transition retention watermark (default: "typegraph_identity_transition_retention") */ + identityTransitionRetention?: string | undefined; /** Node fulltext table name (default: "typegraph_node_fulltext") */ fulltext: string; /** Node uniques table name (default: "typegraph_node_uniques") */ @@ -101,6 +105,8 @@ export type ResolvedSqlTableNames = Readonly<{ recordedIdentityAssertions: string; identityClosure: string; identitySeparation: string; + identityTransitions: string; + identityTransitionRetention: string; /** Node fulltext table name */ fulltext: string; /** Node uniques table name */ @@ -142,6 +148,10 @@ type SqlSchemaFields = Readonly<{ identityClosureTable: SqlFragment; /** Get a `SqlFragment` reference to the derived identity separation relation. */ identitySeparationTable: SqlFragment; + /** Get a `SqlFragment` reference to the identity transition log. */ + identityTransitionsTable: SqlFragment; + /** Get a `SqlFragment` reference to the identity-transition retention watermark. */ + identityTransitionRetentionTable: SqlFragment; /** Get a `SqlFragment` reference to the fulltext table. */ fulltextTable: SqlFragment; }>; @@ -166,6 +176,8 @@ export abstract class SqlSchema implements SqlSchemaFields { abstract readonly recordedIdentityAssertionsTable: SqlFragment; abstract readonly identityClosureTable: SqlFragment; abstract readonly identitySeparationTable: SqlFragment; + abstract readonly identityTransitionsTable: SqlFragment; + abstract readonly identityTransitionRetentionTable: SqlFragment; abstract readonly fulltextTable: SqlFragment; } @@ -182,6 +194,8 @@ class SqlSchemaDescriptor extends SqlSchema { readonly recordedIdentityAssertionsTable: SqlFragment; readonly identityClosureTable: SqlFragment; readonly identitySeparationTable: SqlFragment; + readonly identityTransitionsTable: SqlFragment; + readonly identityTransitionRetentionTable: SqlFragment; readonly fulltextTable: SqlFragment; constructor(fields: SqlSchemaFields) { @@ -204,6 +218,9 @@ class SqlSchemaDescriptor extends SqlSchema { fields.recordedIdentityAssertionsTable; this.identityClosureTable = fields.identityClosureTable; this.identitySeparationTable = fields.identitySeparationTable; + this.identityTransitionsTable = fields.identityTransitionsTable; + this.identityTransitionRetentionTable = + fields.identityTransitionRetentionTable; this.fulltextTable = fields.fulltextTable; Object.freeze(this); } @@ -225,6 +242,8 @@ const DEFAULT_TABLE_NAMES = { recordedIdentityAssertions: "typegraph_recorded_identity_assertions", identityClosure: "typegraph_identity_closure", identitySeparation: "typegraph_identity_separation", + identityTransitions: "typegraph_identity_transitions", + identityTransitionRetention: "typegraph_identity_transition_retention", fulltext: "typegraph_node_fulltext", uniques: "typegraph_node_uniques", edgeClaims: "typegraph_edge_claims", @@ -263,6 +282,11 @@ function resolveTableNames( names.identityClosure ?? DEFAULT_TABLE_NAMES.identityClosure, identitySeparation: names.identitySeparation ?? DEFAULT_TABLE_NAMES.identitySeparation, + identityTransitions: + names.identityTransitions ?? DEFAULT_TABLE_NAMES.identityTransitions, + identityTransitionRetention: + names.identityTransitionRetention ?? + DEFAULT_TABLE_NAMES.identityTransitionRetention, fulltext: names.fulltext ?? DEFAULT_TABLE_NAMES.fulltext, uniques: names.uniques ?? DEFAULT_TABLE_NAMES.uniques, edgeClaims: names.edgeClaims ?? DEFAULT_TABLE_NAMES.edgeClaims, @@ -394,6 +418,11 @@ export function createSqlSchema(names: Partial = {}): SqlSchema { ); validateTableName(tables.identityClosure, "identityClosure"); validateTableName(tables.identitySeparation, "identitySeparation"); + validateTableName(tables.identityTransitions, "identityTransitions"); + validateTableName( + tables.identityTransitionRetention, + "identityTransitionRetention", + ); validateTableName(tables.fulltext, "fulltext"); validateTableName(tables.uniques, "uniques"); validateTableName(tables.edgeClaims, "edgeClaims"); @@ -420,6 +449,10 @@ export function createSqlSchema(names: Partial = {}): SqlSchema { ), identityClosureTable: sql.identifier(tables.identityClosure), identitySeparationTable: sql.identifier(tables.identitySeparation), + identityTransitionsTable: sql.identifier(tables.identityTransitions), + identityTransitionRetentionTable: sql.identifier( + tables.identityTransitionRetention, + ), fulltextTable: sql.identifier(tables.fulltext), }); } @@ -788,6 +821,8 @@ export function recordedReadSqlSchema( recordedIdentityAssertionsTable: schema.recordedIdentityAssertionsTable, identityClosureTable: schema.identityClosureTable, identitySeparationTable: schema.identitySeparationTable, + identityTransitionsTable: schema.identityTransitionsTable, + identityTransitionRetentionTable: schema.identityTransitionRetentionTable, fulltextTable: schema.fulltextTable, }); } @@ -836,7 +871,7 @@ export type VectorSlotDescriptor = Readonly<{ * fieldPath)` that declares an embedding field, so the compiler's * `field.similarTo(...)` CTE can UNION ALL the per-field tables for the * kinds in an alias that actually declare the field (only - * `includeSubClasses` yields more than one). + * `expansion: "subclasses"` yields more than one). */ export type VectorSlotMap = ReadonlyMap; diff --git a/packages/typegraph/src/query/compiler/temporal.ts b/packages/typegraph/src/query/compiler/temporal.ts index 84b438bbc..a56ea091c 100644 --- a/packages/typegraph/src/query/compiler/temporal.ts +++ b/packages/typegraph/src/query/compiler/temporal.ts @@ -7,8 +7,10 @@ */ import { optionalRecordedInstantParts } from "../../core/temporal"; import { type TemporalMode } from "../../core/types"; +import { type RowMembership } from "../../registry/row-membership"; import { nowIso } from "../../utils/date"; import { sql, type SqlFragment } from "../sql-fragment"; +import { compileKindFilter } from "./predicate-utils"; import { type RecordedReadBinding, requireRecordedReadBinding } from "./schema"; /** @@ -128,6 +130,37 @@ export type TemporalFilterOptions = Readonly<{ recordedReadBinding?: RecordedReadBinding | undefined; }>; +/** + * The columns {@link compileTemporalFilter} reads beyond + * `valid_from`/`valid_to`/`deleted_at` when `recordedAsOf` is set. A narrowed + * column projection (e.g. the recursive compiler's `_directed_edges` + * CTE) builds its column list from this via `TemporalFilterPass.recordedColumns` + * rather than re-deriving whether the read is recorded-pinned. + */ +export const RECORDED_TEMPORAL_COLUMNS = [ + "recorded_from", + "recorded_to", +] as const; + +/** + * The recorded columns a read's temporal filter references, so a narrowed + * projection of a recorded relation keeps them: {@link RECORDED_TEMPORAL_COLUMNS} + * when the read is recorded-pinned to a source that carries them + * (`RecordedReadSource.carriesInterval`), otherwise none. An engine-native + * `AS OF` source has no recorded interval, and its filter reads none. + */ +export function recordedIntervalColumns( + recordedAsOf: string | undefined, + recordedReadBinding: RecordedReadBinding | undefined, +): readonly (typeof RECORDED_TEMPORAL_COLUMNS)[number][] { + return ( + recordedAsOf !== undefined && + recordedReadBinding?.carriesInterval === true + ) ? + RECORDED_TEMPORAL_COLUMNS + : []; +} + /** * Compiles a temporal filter to SQL. * @@ -189,12 +222,7 @@ export function compileTemporalFilter( } case "asOf": { - if (asOf === undefined) { - throw new Error( - `asOf timestamp is required for temporal mode "asOf"`, - ); - } - const timestamp = asOf; + const timestamp = requireAsOf(asOf); return sql`${deletedAt} IS NULL AND (${validFrom} IS NULL OR ${validFrom} <= ${timestamp}) AND (${validTo} IS NULL OR ${validTo} > ${timestamp})`; } @@ -210,15 +238,112 @@ export function compileTemporalFilter( } })(); - if (recorded === undefined) return validFilter; + const recordedPredicate = compileRecordedPredicate( + prefix, + recordedAsOf, + recordedReadBinding, + ); + if (recordedPredicate === undefined) return validFilter; + return sql`(${validFilter}) AND ${recordedPredicate}`; +} +/** + * The binding's narrowing of a recorded relation to the revision `recordedAsOf` + * pins, or `undefined` when the read is not recorded-pinned or the binding's + * source already reads exactly that revision (an engine-native `AS OF`). + */ +function compileRecordedPredicate( + prefix: SqlFragment, + recordedAsOf: string | undefined, + recordedReadBinding: RecordedReadBinding | undefined, +): SqlFragment | undefined { + const recorded = optionalRecordedInstantParts(recordedAsOf, "recordedAsOf"); + if (recorded === undefined) return undefined; const binding = requireRecordedReadBinding( recordedReadBinding, "recorded-temporal-filter", ); - const recordedPredicate = binding.predicate(prefix, recorded); - if (recordedPredicate === undefined) return validFilter; - return sql`(${validFilter}) AND ${recordedPredicate}`; + return binding.predicate(prefix, recorded); +} + +/** One edge kind and the rule deciding which of its rows are members. */ +export type EdgeKindMembership = Readonly<{ + edgeKind: string; + membership: RowMembership; +}>; + +/** + * THE SQL rendering of `membershipCountsRow` + * (`src/store/claims/edge-claims.ts`) for rows of `members`' edge kinds, each + * under its own rule, at a read coordinate: a non-deleted row is a member; an + * `"openRows"` kind's row only while its window is open. Every reader of a + * membership relation in stored rows calls this — the acyclicity walk, the + * `subgraph({ composition })` closure and the query compiler's composition + * navigation — so none can disagree with the cascade, the claims or each other + * about which rows bind. + * + * A membership is not a validity window: an `"allRows"` row binds for its + * whole life, ended or not. So the coordinate moves the evaluation INSTANT, + * never the rule: + * + * - `"asOf"` evaluates membership at `asOf` — the row existed by then + * (`valid_from <= asOf`), and an `"openRows"` row had not yet ended. + * - Every other mode evaluates it now. `includeEnded`/`includeTombstones` + * widen what a READ may see, not what a membership relation contains, so + * they do not admit an ended `"openRows"` row or a deleted one. Composition + * refuses a window that starts or ends later than its write, so "now" needs + * no instant comparison: an open row is current and an ended one is history. + * + * A recorded-pinned read narrows to its revision through the binding's own + * predicate, so a source that carries no recorded interval is never asked for + * one. Empty-window rules render no window term: a relation whose members all + * count every row reads `deleted_at IS NULL` alone. + */ +export function compileEdgeMembershipFilters( + options: Readonly<{ + members: readonly EdgeKindMembership[]; + tableAlias: string; + mode: TemporalMode; + asOf?: string | undefined; + recordedAsOf?: string | undefined; + recordedReadBinding?: RecordedReadBinding | undefined; + }>, +): readonly SqlFragment[] { + const column = (name: string): SqlFragment => + sql.raw(`${options.tableAlias}.${name}`); + const instant = + options.mode === "asOf" ? requireAsOf(options.asOf) : undefined; + const filters: SqlFragment[] = [sql`${column("deleted_at")} IS NULL`]; + if (instant !== undefined) { + filters.push( + sql`(${column("valid_from")} IS NULL OR ${column("valid_from")} <= ${instant})`, + ); + } + const openRowsKinds = options.members + .filter((member) => member.membership === "openRows") + .map((member) => member.edgeKind); + if (openRowsKinds.length > 0) { + const isOpenRowsKind = compileKindFilter(column("kind"), openRowsKinds); + const stillOpen = + instant === undefined ? + sql`${column("valid_to")} IS NULL` + : sql`${column("valid_to")} IS NULL OR ${column("valid_to")} > ${instant}`; + filters.push(sql`(NOT (${isOpenRowsKind}) OR ${stillOpen})`); + } + const recordedPredicate = compileRecordedPredicate( + sql.raw(`${options.tableAlias}.`), + options.recordedAsOf, + options.recordedReadBinding, + ); + if (recordedPredicate !== undefined) filters.push(recordedPredicate); + return filters; +} + +function requireAsOf(asOf: string | undefined): string { + if (asOf === undefined) { + throw new Error(`asOf timestamp is required for temporal mode "asOf"`); + } + return asOf; } /** diff --git a/packages/typegraph/src/query/dialect/vector-strategy.ts b/packages/typegraph/src/query/dialect/vector-strategy.ts index f5c44274e..2d149d593 100644 --- a/packages/typegraph/src/query/dialect/vector-strategy.ts +++ b/packages/typegraph/src/query/dialect/vector-strategy.ts @@ -44,7 +44,7 @@ import { ConfigurationError, UnsupportedBackendCapabilityError, } from "../../errors"; -import { requireDefined } from "../../utils/presence"; +import { shortHash } from "../../utils/hash"; import { sql, type SqlFragment } from "../sql-fragment"; /** @@ -611,30 +611,6 @@ function sanitizeIdentifierPart(value: string): string { return value.toLowerCase().replaceAll(/[^a-z0-9_]/g, "_"); } -/** - * Deterministic 8-char hash for collision-safe truncation of over-long - * identifiers. Self-contained so the strategy layer has no backend-runtime - * dependency. - */ -export function shortHash(input: string): string { - let h1 = 0xde_ad_be_ef; - let h2 = 0x41_c6_ce_57; - for (let index = 0; index < input.length; index++) { - const ch = requireDefined(input.codePointAt(index)); - h1 = Math.imul(h1 ^ ch, 0x9e_37_79_b1); - h2 = Math.imul(h2 ^ ch, 0x5f_35_64_95); - } - h1 = - Math.imul(h1 ^ (h1 >>> 16), 0x85_eb_ca_6b) ^ - Math.imul(h2 ^ (h2 >>> 13), 0xc2_b2_ae_35); - h2 = - Math.imul(h2 ^ (h2 >>> 16), 0x85_eb_ca_6b) ^ - Math.imul(h1 ^ (h1 >>> 13), 0xc2_b2_ae_35); - const hi = (h2 >>> 0).toString(16).padStart(8, "0"); - const lo = (h1 >>> 0).toString(16).padStart(8, "0"); - return `${hi}${lo}`.slice(0, 8); -} - /** * Deterministic per-`(graphId, nodeKind, fieldPath)` physical name. Shared by * every strategy's `tableName`, by index naming, and by the compiler's diff --git a/packages/typegraph/src/query/dialect/vector/pgvector-strategy.ts b/packages/typegraph/src/query/dialect/vector/pgvector-strategy.ts index 63a9b4bfc..a1d4498a3 100644 --- a/packages/typegraph/src/query/dialect/vector/pgvector-strategy.ts +++ b/packages/typegraph/src/query/dialect/vector/pgvector-strategy.ts @@ -30,11 +30,11 @@ import { type VectorMetric, type VectorSearchParams, } from "../../../backend/types"; +import { shortHash } from "../../../utils/hash"; import { sql, type SqlFragment } from "../../sql-fragment"; import { assertFiniteEmbedding, quoteIdentifier, - shortHash, VECTOR_CONTRIBUTION_PREFIX, vectorMinScoreCondition, vectorPhysicalName, diff --git a/packages/typegraph/src/query/execution/index.ts b/packages/typegraph/src/query/execution/index.ts index d08882d67..89b88117b 100644 --- a/packages/typegraph/src/query/execution/index.ts +++ b/packages/typegraph/src/query/execution/index.ts @@ -16,6 +16,7 @@ export { // Pagination and streaming export { adjustOrderByForDirection, + applyCursorFilters, buildCursorFromContext, buildCursorPredicate, buildPaginatedResult, diff --git a/packages/typegraph/src/query/execution/pagination.ts b/packages/typegraph/src/query/execution/pagination.ts index 43f46d86a..799e187cb 100644 --- a/packages/typegraph/src/query/execution/pagination.ts +++ b/packages/typegraph/src/query/execution/pagination.ts @@ -156,6 +156,116 @@ export function buildCursorPredicate( }; } +/** The parts of a query a cursor is applied to. */ +type CursorFilterTarget = Readonly<{ + startAlias: string; + /** + * Whether the cursor is a property of the completed match row rather than + * of the start node alone — see {@link applyCursorFilters}. + */ + positionsCompletedMatch: boolean; + predicates: readonly NodePredicate[]; + resultPredicate?: PredicateExpression | undefined; +}>; + +/** A query's filters with one page's cursor applied. */ +type CursorFilters = Readonly<{ + predicates: readonly NodePredicate[]; + resultPredicate: PredicateExpression | undefined; +}>; + +/** + * Applies a cursor to a query: WHERE the keyset position is tested. + * + * A node predicate is compiled against its target alias alone, so it can + * only carry a cursor whose every key reads that alias — the start alias of a + * query with no traversal. Once a traversal is present the keyset names + * traversal aliases too (the caller's own order keys, and the row-identity + * tiebreaker), and the position is a property of the COMPLETED match row. It + * is then applied as the completed-match predicate, which resolves every + * alias, ANDed with any `where()` the caller stated. + * + * For a traversal query the leading keys that read the start alias are also + * pushed into the start alias's own filter, non-strictly ("at or after the + * cursor's start row"): every row strictly after the cursor satisfies it, and + * it keeps a page from re-reading every start row before the cursor. + */ +export function applyCursorFilters( + target: CursorFilterTarget, + cursorData: CursorData, + orderBy: readonly OrderSpec[], + direction: "forward" | "backward", +): CursorFilters { + const position = buildCursorPredicate( + cursorData, + orderBy, + direction, + target.startAlias, + ); + if (!target.positionsCompletedMatch) { + return { + predicates: [...target.predicates, position], + resultPredicate: target.resultPredicate, + }; + } + const startPrefix = buildStartPrefixPredicate( + cursorData, + orderBy, + direction, + target.startAlias, + ); + return { + predicates: + startPrefix === undefined ? + target.predicates + : [...target.predicates, startPrefix], + resultPredicate: + target.resultPredicate === undefined ? + position.expression + : { + __type: "and", + predicates: [target.resultPredicate, position.expression], + }, + }; +} + +/** + * "At or after the cursor" over the leading order keys that read the start + * alias: strictly after on that prefix, or equal on all of it. `undefined` + * when the first key reads another alias, which leaves nothing to push down. + */ +function buildStartPrefixPredicate( + cursorData: CursorData, + orderBy: readonly OrderSpec[], + direction: "forward" | "backward", + startAlias: string, +): NodePredicate | undefined { + const firstOtherAlias = orderBy.findIndex( + (spec) => requireCursorField(spec.field).alias !== startAlias, + ); + const prefixLength = + firstOtherAlias === -1 ? orderBy.length : firstOtherAlias; + if (prefixLength === 0) return undefined; + const prefixOrderBy = orderBy.slice(0, prefixLength); + const prefixValues = cursorData.vals.slice(0, prefixLength); + const strictlyAfter = buildCursorPredicate( + { ...cursorData, vals: prefixValues }, + prefixOrderBy, + direction, + startAlias, + ).expression; + const equalities = prefixOrderBy.map((spec, index) => + buildEqualityPredicate(requireCursorField(spec.field), prefixValues[index]), + ); + return { + targetAlias: startAlias, + expression: { + __type: "or", + predicates: [strictlyAfter, { __type: "and", predicates: equalities }], + }, + }; +} + /** Uses row values only when SQL NULL semantics cannot change the ordering. */ function buildTupleComparisonPredicate( values: readonly unknown[], diff --git a/packages/typegraph/src/query/index.ts b/packages/typegraph/src/query/index.ts index a5788d7fb..4e63cce51 100644 --- a/packages/typegraph/src/query/index.ts +++ b/packages/typegraph/src/query/index.ts @@ -49,10 +49,13 @@ export type { // Builder types users need export type { AliasMap, + AssignableNodeKinds, BatchableQuery, BatchResults, CommonPropertyKeys, CompiledOneStatementRead, + CompositionNavigationOptions, + CompositionWholeKinds, DynamicEdgeAccessor, DynamicEdgeType, DynamicFieldBuilder, @@ -91,6 +94,7 @@ export type { SelectableNode, SelectContext, StreamOptions, + SubsumedNodeType, TraversalExpansion, } from "./builder"; @@ -126,6 +130,12 @@ export { createQueryBuilder } from "./builder"; export { type AggregateResult, ExecutableAggregateQuery } from "./builder"; export { ExecutableQuery } from "./builder"; export { QueryBuilder } from "./builder"; +export { + type AliasExpansionAxis, + DEFAULT_ALIAS_EXPANSION_AXIS, + type DefaultAliasExpansionAxis, + SEARCH_EXPANSION_DEFAULT, +} from "./builder/alias-expansion"; export type { PreparedBindings, PreparedParameterDeclaration, diff --git a/packages/typegraph/src/query/json-pointer.ts b/packages/typegraph/src/query/json-pointer.ts index d4b3ef27f..64222803a 100644 --- a/packages/typegraph/src/query/json-pointer.ts +++ b/packages/typegraph/src/query/json-pointer.ts @@ -266,7 +266,8 @@ export function encodeJsonPointerSegment(segment: string): string { return segment.replaceAll("~", "~0").replaceAll("/", "~1"); } -function decodeJsonPointerSegment(segment: string): string { +/** Unescape a single JSON Pointer reference segment per RFC 6901: `~1` then `~0`. */ +export function decodeJsonPointerSegment(segment: string): string { return segment.replaceAll("~1", "/").replaceAll("~0", "~"); } diff --git a/packages/typegraph/src/query/predicates.ts b/packages/typegraph/src/query/predicates.ts index 4d4408d5a..b393819b1 100644 --- a/packages/typegraph/src/query/predicates.ts +++ b/packages/typegraph/src/query/predicates.ts @@ -775,20 +775,35 @@ export type FulltextAccessor = Readonly<{ /** * Creates a node-level fulltext accessor. The runtime check throws for - * aliases whose node kind has no `searchable()` content, catching the - * case where the field tag was dropped (e.g. by a schema refactor) - * after the query was written. + * aliases with no `searchable()` content, catching the case where the field + * tag was dropped (e.g. by a schema refactor) after the query was written. + * + * `kindsWithoutSearchableField` names the kinds that leave the alias without + * searchable content; empty means `$fulltext` is available. It is absent for + * an alias that resolves to no node kind. */ export function createFulltextAccessor( alias: string, - hasSearchableField: () => boolean, + kindsWithoutSearchableField: (() => readonly string[]) | undefined, ): FulltextAccessor { return { matches: (query, k, options) => { - if (!hasSearchableField()) { + if (kindsWithoutSearchableField === undefined) { + throw new UnsupportedPredicateError( + `Cannot call .$fulltext.matches() on alias "${alias}" — the alias ` + + `resolves to no node kind in this query.`, + ); + } + const lackingKinds = kindsWithoutSearchableField(); + if (lackingKinds.length > 0) { + const kindList = lackingKinds + .map((kindName) => `"${kindName}"`) + .join(", "); throw new UnsupportedPredicateError( - `Cannot call .$fulltext.matches() on alias "${alias}" — its ` + - `node kind has no fields declared with searchable(). Add at ` + + `Cannot call .$fulltext.matches() on alias "${alias}" — ` + + `${lackingKinds.length === 1 ? "kind" : "kinds"} ${kindList} ` + + `${lackingKinds.length === 1 ? "has" : "have"} no fields ` + + `declared with searchable(). Add at ` + `least one: \`title: searchable({ language: "english" })\`.`, ); } diff --git a/packages/typegraph/src/query/schema-introspector.ts b/packages/typegraph/src/query/schema-introspector.ts index 184a56308..b549670b1 100644 --- a/packages/typegraph/src/query/schema-introspector.ts +++ b/packages/typegraph/src/query/schema-introspector.ts @@ -5,6 +5,7 @@ import { getSearchableMetadata, type SearchableMetadata, } from "../core/searchable"; +import { type KindRegistry } from "../registry/kind-registry"; import { hasOwnKey } from "../utils/object"; import { requireDefined } from "../utils/presence"; import { type ValueType } from "./ast"; @@ -51,11 +52,35 @@ export type FieldTypeInfo = Readonly<{ searchable?: SearchableMetadata | undefined; }>; +/** + * Answers whether every row of `childKind` satisfies `parentKind`'s schema — + * the ontology's assignability relation, which carries the structural + * contract. + */ +export type NodeKindAssignability = ( + childKind: string, + parentKind: string, +) => boolean; + export type SchemaIntrospector = Readonly<{ getFieldTypeInfo: ( kindName: string, fieldName: string, ) => FieldTypeInfo | undefined; + /** + * The type of `fieldName` across every kind an alias resolves to. + * + * The kinds no other kind of `kindNames` subsumes — one for a kind + * expanded through its subclasses, one per listed kind for an expanded + * list source — decide: the answer is their agreeing declaration, and the + * structural contract makes every row beneath them satisfy it, so a + * subclass that omits a parent-optional property or narrows its type does + * not make the field unusable. When no kind subsumes another, that is the + * strict answer: `undefined` unless every kind declares the field with an + * agreeing type. A kind that redeclares an `embedding()` field untagged (or + * a `searchable()` field untagged) also returns the strict answer, since + * the parent's index would not reach it. + */ getSharedFieldTypeInfo: ( kindNames: readonly string[], fieldName: string, @@ -81,8 +106,7 @@ export type SchemaIntrospector = Readonly<{ * Deliberately ANY rather than every: a name one kind of a polymorphic alias * declares is a field access, not a prototype access. Whether the field has a * usable type across all of them is a separate question, answered by - * {@link SchemaIntrospector.getSharedFieldTypeInfo}, which stays `undefined` - * unless every kind agrees. + * {@link SchemaIntrospector.getSharedFieldTypeInfo}. */ hasDeclaredField: ( kindNames: readonly string[], @@ -94,12 +118,30 @@ export type SchemaIntrospector = Readonly<{ fieldName: string, ) => boolean; /** - * True iff every kind in `kindNames` has at least one `searchable()` - * field. For polymorphic aliases, `$fulltext` is available only when - * every resolved kind has searchable content — otherwise `.matches()` - * would silently miss some kinds. + * The kinds that leave the alias `kindNames` resolves to without searchable + * content, empty when `$fulltext` is available: each kind no other kind + * subsumes that declares no `searchable()` field, or else any kind beneath + * one of those that redeclares one of its `searchable()` fields untagged — + * `.matches()` would otherwise silently miss some of them. */ - hasSearchableField: (kindNames: readonly string[]) => boolean; + kindsWithoutSearchableField: ( + kindNames: readonly string[], + ) => readonly string[]; + /** + * The kinds of `kindNames` whose declaration of `fieldName` loses an + * indexing tag (`searchable()` / `embedding()`) the declaration of a kind + * subsuming it carries, or carries one it lacks. A subclass may legally + * redeclare such a field differently (the tags are annotations, not + * constraints), and its rows then reach no index the subsuming-kind accessor + * reads. This is the very decision the shared-field accessor makes before it + * trusts the subsuming declaration, so the query builder and `store.search()` + * refuse the same kinds. A kind that omits the field holds no value there + * and is not named. + */ + kindsDivergingFromSubsumingDeclaration: ( + kindNames: readonly string[], + fieldName: string, + ) => readonly string[]; }>; function sharedCacheKey( @@ -112,6 +154,7 @@ function sharedCacheKey( export function createSchemaIntrospector( nodeKinds: ReadonlyMap, edgeKinds?: ReadonlyMap, + isAssignableTo?: NodeKindAssignability, ): SchemaIntrospector { const nodeShapeCache = new Map< string, @@ -130,7 +173,7 @@ export function createSchemaIntrospector( string, FieldTypeInfo | undefined >(); - const searchableCache = new Map(); + const searchableGapCache = new Map(); const declaredFieldCache = new Map(); const declaredEdgeFieldCache = new Map(); @@ -144,6 +187,104 @@ export function createSchemaIntrospector( : undefined; } + /** + * The kinds of `kindNames` no other kind of `kindNames` strictly subsumes. + * Every other kind sits beneath at least one of them. One root for a kind + * expanded through its subclasses, several for a list of unrelated kinds + * (each expanded or not), and several mutually equivalent ones, which are + * structurally interchangeable. Without an assignability relation every + * kind is a root. + */ + function rootKinds(kindNames: readonly string[]): readonly string[] { + if (isAssignableTo === undefined) return kindNames; + return kindNames.filter( + (candidate) => + !kindNames.some( + (other) => + other !== candidate && + isAssignableTo(candidate, other) && + !isAssignableTo(other, candidate), + ), + ); + } + + /** The roots, other than `kindName` itself, that `kindName` is assignable to. */ + function rootsAbove( + kindName: string, + roots: readonly string[], + ): readonly string[] { + if (isAssignableTo === undefined) return []; + return roots.filter( + (root) => root !== kindName && isAssignableTo(kindName, root), + ); + } + + function mergeDeclaredFieldTypeInfos( + kindNames: readonly string[], + fieldName: string, + ): FieldTypeInfo | undefined { + const infos = kindNames + .map((kindName) => getFieldTypeInfo(kindName, fieldName)) + .filter((info): info is FieldTypeInfo => info !== undefined); + return infos.length !== kindNames.length || infos.length === 0 ? + undefined + : mergeFieldTypeInfos(infos); + } + + /** + * The roots' declaration of the field, and the kinds whose declaration + * differs in its indexing tags from that of a root above them. The + * structural contract does not see the tags (`searchable()` and + * `embedding()` are annotations, not constraints), so a subclass may legally + * redeclare a tagged field untagged (or tag an untagged one) — and its rows + * then never reach the fulltext or vector index the root-typed accessor + * reads. + */ + function subsumingDeclaration( + kindNames: readonly string[], + fieldName: string, + ): Readonly<{ + resolved: FieldTypeInfo | undefined; + divergingKinds: readonly string[]; + }> { + const roots = rootKinds(kindNames); + const divergingKinds = kindNames.filter((kindName) => { + const info = getFieldTypeInfo(kindName, fieldName); + if (info === undefined) return false; + return rootsAbove(kindName, roots).some((root) => { + const reference = getFieldTypeInfo(root, fieldName); + return reference !== undefined && !keepsIndexingTags(reference, info); + }); + }); + return { + resolved: mergeDeclaredFieldTypeInfos(roots, fieldName), + divergingKinds, + }; + } + + /** + * The field as the roots declare it, unless a kind diverges from a root's + * declaration's indexing tags. Those aliases fall back to the strict + * all-kinds merge rather than silently omit rows. + */ + function resolveFromSubsumingKinds( + kindNames: readonly string[], + fieldName: string, + ): FieldTypeInfo | undefined { + const { resolved, divergingKinds } = subsumingDeclaration( + kindNames, + fieldName, + ); + return divergingKinds.length === 0 ? resolved : undefined; + } + + function kindsDivergingFromSubsumingDeclaration( + kindNames: readonly string[], + fieldName: string, + ): readonly string[] { + return subsumingDeclaration(kindNames, fieldName).divergingKinds; + } + function getSharedFieldTypeInfo( kindNames: readonly string[], fieldName: string, @@ -153,16 +294,11 @@ export function createSchemaIntrospector( return sharedFieldTypeInfoCache.get(cacheKey); } - const infos = kindNames - .map((kindName) => getFieldTypeInfo(kindName, fieldName)) - .filter((info): info is FieldTypeInfo => info !== undefined); - - const merged = - infos.length !== kindNames.length || infos.length === 0 ? - undefined - : mergeFieldTypeInfos(infos); - sharedFieldTypeInfoCache.set(cacheKey, merged); - return merged; + const resolved = + resolveFromSubsumingKinds(kindNames, fieldName) ?? + mergeDeclaredFieldTypeInfos(kindNames, fieldName); + sharedFieldTypeInfoCache.set(cacheKey, resolved); + return resolved; } function getEdgeFieldTypeInfo( @@ -294,21 +430,52 @@ export function createSchemaIntrospector( return declared; } - function hasSearchableField(kindNames: readonly string[]): boolean { + function hasSearchableContent(kindName: string): boolean { + const shape = getShapeForKind(kindName); + return ( + shape !== undefined && + Object.values(shape).some((info) => info.searchable !== undefined) + ); + } + + /** + * Every root must declare searchable content, and its declaration decides + * for the kinds beneath it: a kind that redeclares one of a root's + * searchable fields untagged still holds that text, unindexed, so it is + * named too. A kind that omits the field holds no text there. + */ + function computeKindsWithoutSearchableField( + kindNames: readonly string[], + ): readonly string[] { + const roots = rootKinds(kindNames); + const rootsWithoutContent = roots.filter( + (kindName) => !hasSearchableContent(kindName), + ); + if (rootsWithoutContent.length > 0) return rootsWithoutContent; + + return kindNames.filter( + (kindName) => + !roots.includes(kindName) && + rootsAbove(kindName, roots).some((root) => + Object.entries(getShapeForKind(root) ?? {}).some( + ([fieldName, rootInfo]) => { + const info = getFieldTypeInfo(kindName, fieldName); + return info !== undefined && dropsSearchableTag(rootInfo, info); + }, + ), + ), + ); + } + + function kindsWithoutSearchableField( + kindNames: readonly string[], + ): readonly string[] { const cacheKey = [...kindNames].toSorted().join("|"); - const cached = searchableCache.get(cacheKey); + const cached = searchableGapCache.get(cacheKey); if (cached !== undefined) return cached; - const result = - kindNames.length > 0 && - kindNames.every((kindName) => { - const shape = getShapeForKind(kindName); - if (!shape) return false; - return Object.values(shape).some( - (info) => info.searchable !== undefined, - ); - }); - searchableCache.set(cacheKey, result); + const result = computeKindsWithoutSearchableField(kindNames); + searchableGapCache.set(cacheKey, result); return result; } @@ -319,10 +486,50 @@ export function createSchemaIntrospector( getSharedEdgeFieldTypeInfo, hasDeclaredField, hasDeclaredEdgeField, - hasSearchableField, + kindsWithoutSearchableField, + kindsDivergingFromSubsumingDeclaration, }; } +const registryIntrospectors = new WeakMap(); + +/** + * The schema introspector over a registry's kinds and assignability relation, + * built once per registry. The query builder and the `store.search()` facade + * both ask it, so a decision about a kind set — which kinds a text or vector + * index would miss — has one owner and one answer. + */ +export function schemaIntrospectorFor( + registry: KindRegistry, +): SchemaIntrospector { + const cached = registryIntrospectors.get(registry); + if (cached !== undefined) return cached; + const introspector = createSchemaIntrospector( + registry.nodeKinds, + registry.edgeKinds, + (childKind, parentKind) => registry.isAssignableTo(childKind, parentKind), + ); + registryIntrospectors.set(registry, introspector); + return introspector; +} + +function keepsIndexingTags( + resolved: FieldTypeInfo, + info: FieldTypeInfo, +): boolean { + return ( + (info.valueType === "embedding") === (resolved.valueType === "embedding") && + !dropsSearchableTag(resolved, info) + ); +} + +function dropsSearchableTag( + resolved: FieldTypeInfo, + info: FieldTypeInfo, +): boolean { + return resolved.searchable !== undefined && info.searchable === undefined; +} + function schemaAllowsAbsence(schema: z.ZodType): boolean { if ( schema.type === "optional" || diff --git a/packages/typegraph/src/query/sql-fragment.ts b/packages/typegraph/src/query/sql-fragment.ts index c75f31e88..f30e94534 100644 --- a/packages/typegraph/src/query/sql-fragment.ts +++ b/packages/typegraph/src/query/sql-fragment.ts @@ -145,12 +145,21 @@ function placeholderNode(value: Placeholder): SqlPlaceholderChunk { } /** - * Appends a fragment's chunks one at a time. A spread (`push(...chunks)`) - * passes every chunk as a call argument, and a statement composed from many - * reads holds more chunks than the engine's argument limit. + * Appends a fragment's chunks one at a time. Never `push(...chunks)`: a + * fragment holding a large `VALUES` list has more chunks than an engine + * accepts as call arguments, and the spread overflows the stack. */ -function appendChunks(target: SqlChunk[], fragment: SqlFragment): void { - for (const chunk of getChunks(fragment)) target.push(chunk); +function appendChunks(chunks: SqlChunk[], fragment: SqlFragment): void { + for (const chunk of getChunks(fragment)) chunks.push(chunk); +} + +/** How many bound parameters a fragment renders, placeholders included. */ +export function countSqlParameters(fragment: SqlFragment): number { + let count = 0; + for (const chunk of getChunks(fragment)) { + if (chunk.kind === "parameter" || chunk.kind === "placeholder") count += 1; + } + return count; } function templateSql( diff --git a/packages/typegraph/src/registry/build-validated.ts b/packages/typegraph/src/registry/build-validated.ts index c8b7548df..0ee83e28d 100644 --- a/packages/typegraph/src/registry/build-validated.ts +++ b/packages/typegraph/src/registry/build-validated.ts @@ -1,43 +1,262 @@ import { type GraphIdentityConfig } from "../core/define-graph"; import { type AnyEdgeType, type NodeType } from "../core/types"; import { ConfigurationError } from "../errors"; +import { + META_EDGE_EQUIVALENT_TO, + META_EDGE_SAME_AS, + META_EDGE_SUB_CLASS_OF, +} from "../ontology/constants"; import { type NamedOntologyRelation, + type OntologyKindClassification, validateOntologyRelations, } from "../ontology/validation"; +import { type JsonSchema } from "../schema/types"; import { requireDefined } from "../utils/presence"; +import { encodeTupleKey } from "../utils/tuple-key"; +import { + buildCompositionRelation, + EMPTY_COMPOSITION_RELATION, +} from "./composition-relation"; +import { type EdgeKindFacts } from "./edge-kind-facts"; import { computeClosuresFromNamedOntology, createEmptyClosures, KindRegistry, + type RegistryClosures, } from "./kind-registry"; -import { - type EdgeEndpointKinds, - validateImpliesEndpointCompatibility, -} from "./validate-implies"; +import { validateImpliesEndpointCompatibility } from "./validate-implies"; import { validateInverseEndpointCompatibility } from "./validate-inverse"; +import { + findStructuralSubsumptionViolations, + type StructuralSubsumptionViolation, +} from "./validate-structural-subsumption"; + +/** + * How `buildValidatedKindRegistry` treats the registry it is about to build: + * + * - `"enforce"` — the default, and the only mode a store actually reads or + * writes through. An incompatible `subClassOf`/`equivalentTo` pair throws, + * and so does every composition (`partOf`/`hasPart`) declaration defect. + * - `"unenforced-baseline"` — for a registry built ONLY to diff against a + * proposal (the BEFORE side of `classifyOntologyChanges`, + * `src/schema/ontology-change.ts`). Enforcing the before-side would wedge + * the fix-forward migration that repairs a persisted document today's + * validation refuses — removing or correcting the offending relation is + * itself a relation change, so the diff would rebuild the (still-refused) + * BEFORE registry and throw before it could ever classify the fix. Two + * contracts are relaxed, because both postdate documents already stored: + * structural subsumption, and the composition declaration rules (a + * `partOf`/`hasPart` relation with no `via`, which realizes nothing and so + * contributes no composition pair, and a realizing edge the composition + * rules refuse). The refuse-on-load guarantee is satisfied by the live + * registry every commit path already builds from the code graph, and by + * `getSchemaChanges` reporting it via the AFTER-side build — a diff is not + * a load. + * + * `tests/structural-subsumption-enforcement.test.ts` asserts every call site + * supplies this explicitly (never falls through to a default silently) and + * that exactly one names `"unenforced-baseline"`. + */ +export type StructuralSubsumptionMode = "enforce" | "unenforced-baseline"; + +/** For each directly declared `(first, second)` pair, the meta-edges declaring it. */ +function declaredSubsumptionPairs( + ontology: readonly NamedOntologyRelation[], +): ReadonlyMap> { + const pairs = new Map>(); + const declare = (first: string, second: string, metaEdge: string): void => { + const key = encodeTupleKey([first, second]); + const metaEdges = pairs.get(key) ?? new Set(); + metaEdges.add(metaEdge); + pairs.set(key, metaEdges); + }; + for (const relation of ontology) { + switch (relation.metaEdge) { + case META_EDGE_SUB_CLASS_OF: { + declare(relation.from, relation.to, relation.metaEdge); + break; + } + case META_EDGE_EQUIVALENT_TO: + case META_EDGE_SAME_AS: { + // Equivalence is inherently bidirectional — both directions are a + // DECLARED relation, never a transitive-only consequence. + declare(relation.from, relation.to, relation.metaEdge); + declare(relation.to, relation.from, relation.metaEdge); + break; + } + default: { + break; + } + } + } + return pairs; +} + +/** + * The relation a violation is reported under: a declared pair names a + * meta-edge that declares it, and an implied one the relations it follows + * from. A persisted `sameAs` folds into the same equivalence class as + * `equivalentTo`, so the class alone cannot say which the user wrote; for an + * implied pair `equivalentTo` is named whenever the class declares it. + */ +function violatedRelationName( + violation: StructuralSubsumptionViolation, + declaredPairs: ReadonlyMap>, + ontology: readonly NamedOntologyRelation[], + registry: KindRegistry, +): string { + if (!violation.viaEquivalence) return META_EDGE_SUB_CLASS_OF; + const inClass = (kind: string): boolean => + kind === violation.childKind || + registry.areEquivalent(violation.childKind, kind); + const declaring = + violation.declared ? + requireDefined( + declaredPairs.get( + encodeTupleKey([violation.childKind, violation.parentKind]), + ), + ) + : new Set( + ontology + .filter((relation) => inClass(relation.from) && inClass(relation.to)) + .map((relation) => relation.metaEdge), + ); + return ( + [META_EDGE_EQUIVALENT_TO, META_EDGE_SAME_AS, META_EDGE_SUB_CLASS_OF].find( + (metaEdge) => declaring.has(metaEdge), + ) ?? META_EDGE_EQUIVALENT_TO + ); +} + +function structuralSubsumptionErrorCode( + violation: StructuralSubsumptionViolation, +): string { + if (violation.viaEquivalence) { + return violation.verdict === "not-subtype" ? + "ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE" + : "ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE"; + } + return violation.verdict === "not-subtype" ? + "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE" + : "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE"; +} + +function structuralSubsumptionSuggestion( + violation: StructuralSubsumptionViolation, +): string { + if (violation.verdict === "incomparable") { + return ( + "The projected JSON Schema cannot judge this pair " + + "($ref, allOf, not, or an unmodeled keyword) — simplify the schemas " + + "or drop the relation." + ); + } + return ( + "Give the child these properties with compatible types, " + + "or declare broader(child, parent) instead." + ); +} + +function assertStructuralSubsumption( + registry: KindRegistry, + ontology: readonly NamedOntologyRelation[], + nodePropertySchemas: (kind: string) => JsonSchema | undefined, + mode: StructuralSubsumptionMode, +): void { + if (mode !== "enforce") return; + + const declaredPairs = declaredSubsumptionPairs(ontology); + const violations = findStructuralSubsumptionViolations( + registry, + (a, b) => registry.equivalenceSets.get(a)?.has(b) ?? false, + nodePropertySchemas, + (first, second) => declaredPairs.has(encodeTupleKey([first, second])), + ); + if (violations.length === 0) return; + + const first = requireDefined(violations[0]); + const relationName = violatedRelationName( + first, + declaredPairs, + ontology, + registry, + ); + const declaredNote = first.declared ? "declared" : "implied transitively"; + throw new ConfigurationError( + `Ontology is incoherent: ${relationName}(${first.childKind}, ${first.parentKind}) ` + + `(${declaredNote}) is not a structural subtype of its target ` + + `(${first.verdict}: ${first.reason}).`, + { + code: structuralSubsumptionErrorCode(first), + relation: relationName, + childKind: first.childKind, + parentKind: first.parentKind, + viaEquivalence: first.viaEquivalence, + declared: first.declared, + reason: first.reason, + path: first.path, + violations, + }, + { suggestion: structuralSubsumptionSuggestion(first) }, + ); +} + +/** Everything {@link buildValidatedKindRegistry} needs to build a registry. */ +type ValidatedKindRegistryInput = Readonly<{ + nodeKinds: ReadonlyMap; + edgeKinds: ReadonlyMap; + ontology: readonly NamedOntologyRelation[]; + edgeFacts: ReadonlyMap; + identity?: GraphIdentityConfig; + /** + * How to tell a registered node kind from a registered edge kind, for the + * equivalence-class check. Defaults to this input's own kind maps; the + * schema deserializer supplies its own because it builds a registry with + * EMPTY kind maps (it has no Zod schemas) and would otherwise classify + * every name as neither. + */ + kindClassification?: OntologyKindClassification; + /** + * Projects a registered node kind's properties to JSON Schema for the + * structural-subsumption check. Required — not optional — so no + * construction can silently skip the check. + */ + nodePropertySchemas: (kind: string) => JsonSchema | undefined; + /** See {@link StructuralSubsumptionMode}. Required for the same reason. */ + structuralSubsumption: StructuralSubsumptionMode; +}>; export function buildValidatedKindRegistry( - input: Readonly<{ - nodeKinds: ReadonlyMap; - edgeKinds: ReadonlyMap; - ontology: readonly NamedOntologyRelation[]; - edgeEndpoints: ReadonlyMap; - identity?: GraphIdentityConfig; - }>, + input: ValidatedKindRegistryInput, ): KindRegistry { + const kindClassification: OntologyKindClassification = + input.kindClassification ?? { + isNodeKind: (name: string) => input.nodeKinds.has(name), + isEdgeKind: (name: string) => input.edgeKinds.has(name), + }; + if (input.ontology.length === 0) { const registry = new KindRegistry( input.nodeKinds, input.edgeKinds, createEmptyClosures(), input.identity, + EMPTY_COMPOSITION_RELATION, + input.edgeFacts, ); - validateImpliesEndpointCompatibility(input.edgeEndpoints, registry); + validateImpliesEndpointCompatibility(input.edgeFacts, registry); return registry; } - const issues = validateOntologyRelations(input.ontology); + const issues = validateOntologyRelations( + input.ontology, + kindClassification, + ).filter( + (issue) => + input.structuralSubsumption === "enforce" || + issue.code !== "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + ); if (issues.length > 0) { const firstIssue = requireDefined(issues[0]); throw new ConfigurationError( @@ -54,17 +273,71 @@ export function buildValidatedKindRegistry( } const closures = computeClosuresFromNamedOntology(input.ontology); - const registry = new KindRegistry( + return buildRegistryWithComposition(input, closures); +} + +/** + * Building the registry composition attaches to takes two passes: + * `KindRegistry` supplies `isAssignableToAny` (subclass-closure based, + * unaffected by composition), so `buildCompositionRelation` needs a registry + * to run against before its own result can be attached to one. The first + * registry is validation-only and discarded; the second, identical but for + * its `composition` field, is what every caller gets back. + */ +function buildRegistryWithComposition( + input: Omit, + closures: RegistryClosures, +): KindRegistry { + const registryForValidation = new KindRegistry( input.nodeKinds, input.edgeKinds, closures, input.identity, ); - validateImpliesEndpointCompatibility(input.edgeEndpoints, registry); + validateImpliesEndpointCompatibility(input.edgeFacts, registryForValidation); validateInverseEndpointCompatibility( input.ontology, - input.edgeEndpoints, + input.edgeFacts, + registryForValidation, + ); + + const { relation: composition, issues: compositionIssues } = + buildCompositionRelation( + input.ontology, + input.edgeFacts, + registryForValidation, + ); + if ( + compositionIssues.length > 0 && + input.structuralSubsumption === "enforce" + ) { + const firstIssue = requireDefined(compositionIssues[0]); + throw new ConfigurationError( + `Composition ontology is incoherent: ${firstIssue.message}`, + { + code: firstIssue.code, + issues: compositionIssues, + }, + { + suggestion: + "Correct the partOf/hasPart declarations before constructing or loading the graph registry.", + }, + ); + } + + const registry = new KindRegistry( + input.nodeKinds, + input.edgeKinds, + closures, + input.identity, + composition, + input.edgeFacts, + ); + assertStructuralSubsumption( registry, + input.ontology, + input.nodePropertySchemas, + input.structuralSubsumption, ); return registry; } diff --git a/packages/typegraph/src/registry/builders.ts b/packages/typegraph/src/registry/builders.ts index dc8bce4ac..162121e59 100644 --- a/packages/typegraph/src/registry/builders.ts +++ b/packages/typegraph/src/registry/builders.ts @@ -10,6 +10,7 @@ import { getEdgeEndpointPairs, projectTargetKinds, } from "../core/edge-endpoints"; +import { effectiveTargetCardinality } from "../core/edge-integrity-options"; import { type AnyEdgeType, type EdgeRegistration, @@ -17,9 +18,11 @@ import { type NodeType, } from "../core/types"; import { type NamedOntologyRelation } from "../ontology/validation"; +import { structuralSubtypeProjection } from "../schema/structural-subtype-projection"; import { buildValidatedKindRegistry } from "./build-validated"; +import { compositionRelationFields } from "./composition-relation"; +import { type EdgeKindFacts } from "./edge-kind-facts"; import type { KindRegistry } from "./kind-registry"; -import { type EdgeEndpointKinds } from "./validate-implies"; const EMPTY_NAMED_ONTOLOGY: readonly NamedOntologyRelation[] = []; @@ -65,28 +68,51 @@ export function buildKindRegistry(graph: G): KindRegistry { relation.from : relation.from.kind, to: typeof relation.to === "string" ? relation.to : relation.to.kind, + ...compositionRelationFields(relation), })), - edgeEndpoints: buildEdgeEndpointKinds(graph.edges), + edgeFacts: buildGraphEdgeKindFacts(graph.edges), + // Memoized by structuralSubtypeProjection itself (keyed on the Zod + // schema reference), so this closure is called once per node kind per + // registry build, not once per closure pair — the structural-subsumption + // check's schema source for a live, compile-time graph. + nodePropertySchemas: (kind) => { + const nodeType = nodeTypes.get(kind); + return nodeType === undefined ? undefined : ( + structuralSubtypeProjection(nodeType.schema) + ); + }, + structuralSubsumption: "enforce", ...(graph.identity === undefined ? {} : { identity: graph.identity }), }); } /** * Maps each registered edge kind to its declared domain/range kind names, - * for `validateImpliesEndpointCompatibility`. A `Map` (rather than the - * plain `graph.edges` object) so a lookup for an edge kind literally named - * "toString" or another `Object.prototype` member can't resolve to an - * inherited member instead of `undefined`. + * for `validateImpliesEndpointCompatibility` and for + * `expandEdgeEndpointAllowance` (`src/registry/edge-endpoint-allowance.ts`). + * A `Map` (rather than the plain `graph.edges` object) so a lookup for an + * edge kind literally named "toString" or another `Object.prototype` member + * can't resolve to an inherited member instead of `undefined`. + * + * The serialized-schema sibling of this adapter is + * `buildSerializedEdgeKindFacts` (`./edge-kind-facts.ts`): the two + * read genuinely different representations (a live `GraphDef` vs a persisted + * document), so they stay separate, but both feed the one + * `expandEdgeEndpointAllowance`. */ -function buildEdgeEndpointKinds( +export function buildGraphEdgeKindFacts( edges: Record, -): ReadonlyMap { - const result = new Map(); +): ReadonlyMap { + const result = new Map(); for (const [kind, registration] of Object.entries(edges)) { result.set(kind, { from: registration.from.map((node) => node.kind), to: projectTargetKinds(registration.to), pairs: getEdgeEndpointPairs(registration.from, registration.to), + cardinality: registration.cardinality ?? "many", + targetCardinality: effectiveTargetCardinality( + registration.targetCardinality, + ), }); } return result; diff --git a/packages/typegraph/src/registry/composition-relation.ts b/packages/typegraph/src/registry/composition-relation.ts new file mode 100644 index 000000000..5d5ec16cf --- /dev/null +++ b/packages/typegraph/src/registry/composition-relation.ts @@ -0,0 +1,888 @@ +/** + * The composition relation — THE authoritative record of every declared + * `partOf` / `hasPart` pair on a graph, plus the orientation and whole-side + * population each pair resolves to. + * + * `buildCompositionRelation` is deliberately both the builder AND the + * validator, in one traversal: a separate `validateCompositionRelations` + * would have to re-derive orientation, population, and exactness to decide + * whether to throw, which is a second spelling of every decision this + * function makes. It returns both the relation and the issues found while + * building it; the caller (`src/registry/build-validated.ts`) throws. + * + * Every other consumer of composition — write validation, the claim writer, + * the acyclicity check, navigation, cascade, interchange, merge — reads the + * resulting `CompositionRelation` only through `KindRegistry`'s derived + * readers, never `pairs` directly. That is what keeps "is this edge kind a + * composition edge" and "which side is the part" answered once. + */ +import { type AnyEdgeType } from "../core/types"; +import { META_EDGE_HAS_PART, META_EDGE_PART_OF } from "../ontology/constants"; +import { type NamedOntologyRelation } from "../ontology/validation"; +import { compareStrings } from "../utils/compare"; +import { requireDefined } from "../utils/presence"; +import { encodeTupleKey } from "../utils/tuple-key"; +import { type EdgeKindFacts } from "./edge-kind-facts"; +import { type KindRegistry } from "./kind-registry"; +import { type RowMembership } from "./row-membership"; + +// ============================================================ +// Types +// ============================================================ + +/** + * A realizing edge named either by its type (a typo is a compile error) or + * by its kind string (a dynamic caller). {@link compositionViaKind} is the + * one owner of that distinction. + */ +export type CompositionViaRef = AnyEdgeType | string; + +/** The edge-kind string a {@link CompositionViaRef} names. */ +export function compositionViaKind(via: CompositionViaRef): string { + return typeof via === "string" ? via : via.kind; +} + +/** Which endpoint of the realizing edge carries the PART. */ +export type CompositionPartSide = "from" | "to"; + +/** Every value `partSide` may state, in declaration order. */ +export const COMPOSITION_PART_SIDE_VALUES = [ + "from", + "to", +] as const satisfies readonly CompositionPartSide[]; + +/** + * THE check that a stated `partSide` is one this library knows, shared by the + * `partOf` / `hasPart` factories, the registry build, and the schema-document + * validators for the same reason {@link isCompositionExistence} is: a value + * one surface admits and another refuses is a schema that persists and cannot + * be read back. + */ +export function isCompositionPartSide( + value: unknown, +): value is CompositionPartSide { + return (COMPOSITION_PART_SIDE_VALUES as readonly unknown[]).includes(value); +} + +/** + * The part and whole of a composition edge whose part sits at `partSide`: + * the one place that orientation is decided, whatever shape the endpoints + * take. + */ +export function orientCompositionEndpoints( + partSide: CompositionPartSide, + from: Endpoint, + to: Endpoint, +): Readonly<{ part: Endpoint; whole: Endpoint }> { + return partSide === "from" ? + { part: from, whole: to } + : { part: to, whole: from }; +} + +/** + * Whether a composition part can exist with no whole. + * `"required"` — the part cannot exist without a live whole, enforced at + * create (a bare create is refused; `partOf` must name a legal whole) and at + * detach (ending, soft-deleting, or hard-deleting the composition edge is + * refused while the part is live). Default `"optional"`, so a declaration + * that states no existence keeps its semantics. + */ +export type CompositionExistence = "optional" | "required"; + +/** + * The fields that realize a composition relation on an ontology relation. + * Every relation shape (declared, typed, named, serialized, extension) carries + * exactly these, so the contract is documented once here. + */ +export type CompositionRealization = Readonly<{ + /** The realizing edge kind name. Required for `partOf`/`hasPart`, absent otherwise. */ + via?: string; + /** Which end of the edge is the part. Meaningful only alongside `via`. */ + partSide?: CompositionPartSide; + /** Whether the part must have a live whole. Meaningful only alongside `via`. */ + existence?: CompositionExistence; +}>; + +/** Every value `existence` may state, in declaration order. */ +export const COMPOSITION_EXISTENCE_VALUES = [ + "optional", + "required", +] as const satisfies readonly CompositionExistence[]; + +/** + * THE check that a stated `existence` is one this library knows. One + * predicate for every surface that accepts the value — the `partOf` / + * `hasPart` factories, the registry build behind `defineGraph` and + * `store.evolve`, and the extension-document validator — so none of them can + * admit a value another refuses, and a misspelling can never be persisted + * into a schema document that no longer loads. + */ +export function isCompositionExistence( + value: unknown, +): value is CompositionExistence { + return (COMPOSITION_EXISTENCE_VALUES as readonly unknown[]).includes(value); +} + +/** One declared composition pair and the edge that realizes it. */ +export type CompositionPair = Readonly<{ + partKind: string; + wholeKind: string; + viaEdgeKind: string; + /** Inferred from the pair against the edge's endpoints, or declared. */ + partSide: CompositionPartSide; + /** + * The WHOLE-side cardinality of the realizing edge: `cardinality` when + * `partSide === "from"`, `targetCardinality` when it is `"to"`. This + * single value is the temporal population every composition claim reads. + */ + population: "one" | "oneActive"; + /** + * Total, not optional: the registry resolves the default (`"optional"`) + * once here so no consumer re-spells `?? "optional"`. See + * {@link KindRegistry.compositionExistence}, the one reader every + * existence decision goes through. + */ + existence: CompositionExistence; +}>; + +/** THE composition relation of one graph. Everything else is derived. */ +export type CompositionRelation = Readonly<{ + /** Code-point ordered by (partKind, wholeKind, viaEdgeKind). */ + pairs: readonly CompositionPair[]; + /** Every realizing edge kind, flat, code-point ordered. */ + edgeKinds: ReadonlySet; + /** Orientation per realizing edge kind. */ + partSideByEdgeKind: ReadonlyMap; +}>; + +export const EMPTY_COMPOSITION_RELATION: CompositionRelation = { + pairs: [], + edgeKinds: new Set(), + partSideByEdgeKind: new Map(), +}; + +export type CompositionIssueCode = + | "ONTOLOGY_COMPOSITION_VIA_UNKNOWN" + | "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS" + | "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED" + | "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID" + | "ONTOLOGY_COMPOSITION_CARDINALITY" + | "ONTOLOGY_COMPOSITION_VIA_MIXED" + | "ONTOLOGY_COMPOSITION_POPULATION_MIXED" + | "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED" + | "ONTOLOGY_COMPOSITION_EXISTENCE_INVALID"; + +export type CompositionIssue = Readonly<{ + code: CompositionIssueCode; + message: string; + relation: NamedOntologyRelation; +}>; + +// ============================================================ +// Orientation inference +// ============================================================ + +/** + * Whether the realizing edge admits an instance whose `from` endpoint is + * (assignable to) `fromCandidate` and whose `to` endpoint is (assignable to) + * `toCandidate`, against `EdgeKindFacts.pairs`. + */ +function edgeAdmitsPair( + fromCandidate: string, + toCandidate: string, + facts: EdgeKindFacts, + registry: KindRegistry, +): boolean { + return facts.pairs.some( + (pair) => + registry.isAssignableTo(fromCandidate, pair.from) && + registry.isAssignableTo(toCandidate, pair.to), + ); +} + +/** The subset of {@link CompositionIssueCode} `inferCompositionPartSide` can return. */ +type InferenceIssueCode = Extract< + CompositionIssueCode, + | "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS" + | "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED" + | "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID" +>; + +/** + * Infers which side of the realizing edge carries the PART, for one declared + * `(partKind, wholeKind)` pair. + * + * - Neither orientation is endpoint-compatible: `ONTOLOGY_COMPOSITION_VIA_ENDPOINTS`. + * - Exactly one is: that side — a `declared` value contradicting it is + * `ONTOLOGY_COMPOSITION_PART_SIDE_INVALID` (a stated option is applied or + * refused, never ignored); a `declared` value agreeing with it is accepted + * as redundant. + * - Both are (same-kind or otherwise ambiguous containment): `declared` + * decides; absent is `ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED`. + */ +export function inferCompositionPartSide( + pair: Readonly<{ + partKind: string; + wholeKind: string; + declared?: CompositionPartSide; + }>, + facts: EdgeKindFacts, + registry: KindRegistry, +): + | Readonly<{ partSide: CompositionPartSide }> + | Readonly<{ code: InferenceIssueCode }> { + const forwardOk = edgeAdmitsPair( + pair.partKind, + pair.wholeKind, + facts, + registry, + ); + const reverseOk = edgeAdmitsPair( + pair.wholeKind, + pair.partKind, + facts, + registry, + ); + + if (!forwardOk && !reverseOk) { + return { code: "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS" }; + } + + if (pair.declared !== undefined && !isCompositionPartSide(pair.declared)) { + return { code: "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID" }; + } + + if (forwardOk && reverseOk) { + if (pair.declared === undefined) { + return { code: "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED" }; + } + return { partSide: pair.declared }; + } + + const inferred: CompositionPartSide = forwardOk ? "from" : "to"; + if (pair.declared !== undefined && pair.declared !== inferred) { + return { code: "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID" }; + } + return { partSide: inferred }; +} + +/** + * THE `partSide` a composition relation is persisted and identified by: the + * declared side only when the realizing edge admits the pair in BOTH + * orientations, so the declaration is what decides it; `undefined` when the + * edge's endpoints decide the side, so a redundant `partSide` and an omitted + * one serialize, hash and diff identically. A declaration the inference + * refuses (an unknown via edge, endpoints admitting neither orientation, a + * contradicting or invalid side) is kept verbatim, so the registry built from + * the document refuses it exactly as it refuses the graph. + */ +export function canonicalCompositionPartSide( + relation: NamedOntologyRelation, + edgeFacts: ReadonlyMap, + registry: KindRegistry, +): CompositionPartSide | undefined { + const declared = relation.partSide; + if (declared === undefined || relation.via === undefined) return declared; + const facts = edgeFacts.get(relation.via); + if (facts === undefined) return declared; + const { partKind, wholeKind } = normalizePartWhole(relation); + const inference = inferCompositionPartSide( + { partKind, wholeKind, declared }, + facts, + registry, + ); + if ("code" in inference) return declared; + const ambiguous = + edgeAdmitsPair(partKind, wholeKind, facts, registry) && + edgeAdmitsPair(wholeKind, partKind, facts, registry); + return ambiguous ? declared : undefined; +} + +/** + * THE side->direction mapping for composition navigation: which way a + * realizing edge kind must be walked to move toward its `"parts"` (or + * `"wholes"`) end, given which endpoint carries the part. + * + * A `part -> whole` edge (`partSide: "from"`) reaches its parts by walking + * "in" (reversed) and its wholes by walking "out" (its own direction); a + * `whole -> part` edge (`partSide: "to"`, the `has_*` convention) is the + * mirror. Not exported: every composition navigator — the query builder's + * `parts()`/`wholes()` and `subgraph({ composition: true })`'s parts + * closure — derives its traversal direction through + * {@link partitionCompositionEdgeKindsByDirection} below, which is the one + * caller of this mapping, so the two paths cannot drift on which way an + * edge is walked. A flat direction: "both" is not a sound substitute — it + * climbs to ancestors and re-descends into siblings instead of reaching only + * the descendants. + */ +function compositionTraversalDirection( + partSide: CompositionPartSide, + towards: "parts" | "wholes", +): "out" | "in" { + const directionTowardParts: "out" | "in" = partSide === "from" ? "in" : "out"; + if (towards === "parts") return directionTowardParts; + return directionTowardParts === "in" ? "out" : "in"; +} + +/** + * Partitions a set of composition edge kinds into the two directions + * {@link compositionTraversalDirection} assigns them to reach `towards`. + * + * THE one owner of "resolve this edge kind's part side, then its + * direction" — `parts()`/`wholes()` (`QueryBuilder#navigateComposition`) + * and `subgraph({ composition: true })` + * (`buildSubgraphCompositionReachableCte`) both call this instead of each + * re-spelling the loop, so they cannot disagree on what happens when an + * edge kind returned by `compositionEdgeKindsUnder`/`compositionEdgeKindsOver` + * turns out to have no entry in `partSideByEdgeKind` — a registry-build + * defect, or an ontology loaded from a persisted schema. Both callers throw + * the same named-invariant message rather than one refusing loudly and the + * other silently defaulting to `"from"` and walking the wrong direction. + */ +export function partitionCompositionEdgeKindsByDirection( + registry: KindRegistry, + edgeKinds: Iterable, + towards: "parts" | "wholes", +): Readonly<{ + outEdgeKinds: readonly string[]; + inEdgeKinds: readonly string[]; +}> { + const outEdgeKinds: string[] = []; + const inEdgeKinds: string[] = []; + for (const edgeKind of edgeKinds) { + const partSide = requireDefined( + registry.compositionPartSide(edgeKind), + `"${edgeKind}" is not a composition edge kind, but was returned by the registry's composition edge-kind reader.`, + ); + const direction = compositionTraversalDirection(partSide, towards); + (direction === "out" ? outEdgeKinds : inEdgeKinds).push(edgeKind); + } + return { outEdgeKinds, inEdgeKinds }; +} + +// ============================================================ +// The builder / validator +// ============================================================ + +/** + * Whether `metaEdge` is one of the two composition meta-edges (`partOf` / + * `hasPart`). Exported so every site that needs "is this relation a + * composition relation" calls one predicate instead of re-spelling the + * `=== META_EDGE_PART_OF || === META_EDGE_HAS_PART` pair. + */ +export function isCompositionMetaEdge(metaEdge: string): boolean { + return metaEdge === META_EDGE_PART_OF || metaEdge === META_EDGE_HAS_PART; +} + +/** + * Normalizes a `partOf` or `hasPart` relation to `(partKind, wholeKind)`. + * Exported so the registry's declaration-closure collector + * (`kind-registry.ts`'s `partOf`/`hasPart` closure) shares this decision + * instead of re-spelling the same from/to flip. + */ +export function normalizePartWhole( + relation: NamedOntologyRelation, +): Readonly<{ partKind: string; wholeKind: string }> { + return relation.metaEdge === META_EDGE_PART_OF ? + { partKind: relation.from, wholeKind: relation.to } + : { partKind: relation.to, wholeKind: relation.from }; +} + +/** + * Copies `via`/`partSide` onto a fresh object only when present, so a + * relation carrying neither serializes/copies byte-identically to one from + * before composition existed (schema hashing depends on this — see + * `serializeOntologyRelation`, `src/schema/serializer.ts`). Every + * representation that carries these two fields (the compiler, the extension + * validator, the serializer, the deserializer, the registry builder, and + * introspection) copies them through this one function instead of + * re-spelling the same conditional spread six times over. + */ +export function compositionRelationFields( + source: Readonly<{ + via?: string | undefined; + partSide?: CompositionPartSide | undefined; + existence?: CompositionExistence | undefined; + }>, +): Readonly<{ + via?: string; + partSide?: CompositionPartSide; + existence?: CompositionExistence; +}> { + return { + ...(source.via === undefined ? {} : { via: source.via }), + ...(source.partSide === undefined ? {} : { partSide: source.partSide }), + // Stronger than `via`/`partSide`: an explicit `existence: "optional"` is + // ALSO omitted, because it is the default — emitting it would change the + // hash of a graph whose author merely spelled the default out. + ...(source.existence === undefined || source.existence === "optional" ? + {} + : { existence: source.existence }), + }; +} + +/** + * THE identity of an ontology relation: which declaration it IS, as an + * injective key. `via`/`partSide` are part of it so two realizing edges can + * hold the same (part, whole) pair without colliding; `existence` is + * deliberately NOT, because it names the same declared pair whichever way it + * reads (see {@link CompositionExistence}). + * + * One owner, so the duplicate-declaration check + * (`validateOntologyRelations`) and the before/after relation diff + * (`src/schema/ontology-change.ts`) cannot disagree about when two + * declarations are the same relation — a field added to one copy and not the + * other would make the diff report remove + add for a relation the duplicate + * check still treats as one, or the reverse. + */ +export function ontologyRelationIdentityKey( + relation: Readonly<{ + metaEdge: string; + from: string; + to: string; + via?: string | undefined; + partSide?: CompositionPartSide | undefined; + }>, +): string { + return encodeTupleKey([ + relation.metaEdge, + relation.from, + relation.to, + relation.via ?? "", + relation.partSide ?? "", + ]); +} + +function inferenceIssueMessage( + code: InferenceIssueCode, + relation: NamedOntologyRelation, + partKind: string, + wholeKind: string, + viaEdgeKind: string, +): string { + const relationLabel = `${relation.metaEdge}(${relation.from}, ${relation.to})`; + switch (code) { + case "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS": { + return ( + `Composition relation ${relationLabel} is endpoint-incompatible with its \`via\` edge "${viaEdgeKind}": ` + + `neither ("${partKind}" -> "${wholeKind}") nor ("${wholeKind}" -> "${partKind}") is an endpoint pair "${viaEdgeKind}" admits.` + ); + } + case "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED": { + return ( + `Composition relation ${relationLabel} is ambiguous: edge "${viaEdgeKind}" admits both orientations ` + + `between "${partKind}" and "${wholeKind}", so \`partSide: "from" | "to"\` is required.` + ); + } + case "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID": { + return `Composition relation ${relationLabel} declares a \`partSide\` that is not one of ${COMPOSITION_PART_SIDE_VALUES.join(", ")} or contradicts edge "${viaEdgeKind}"'s endpoints.`; + } + } +} + +/** + * The refusal a part kind earns when the composition pairs that can hold it + * disagree on a fact that holds relation-wide. One owner for the population and + * the existence check, which differ only in the pair field they read and in + * how the message names it. + */ +function mixedPartKindFactIssue( + kind: string, + applicable: readonly CompositionPair[], + select: (pair: CompositionPair) => string, + code: CompositionIssueCode, + valueLabel: string, + requirementLabel: string, +): CompositionIssue | undefined { + const values = new Set(applicable.map((pair) => select(pair))); + if (values.size <= 1) return undefined; + const representative = requireDefined(applicable[0]); + return { + code, + message: + `Node kind "${kind}" is a composition part under edges declaring different ${valueLabel} ` + + `(${[...values].toSorted((left, right) => compareStrings(left, right)).join(", ")}); ` + + `every composition edge that can hold "${kind}" as a part must declare the same ${requirementLabel}.`, + relation: { + metaEdge: META_EDGE_PART_OF, + from: representative.partKind, + to: representative.wholeKind, + via: representative.viaEdgeKind, + partSide: representative.partSide, + }, + }; +} + +/** + * Builds and validates the composition relation from the ontology's + * `partOf` / `hasPart` relations. + * + * Assumes the shape checks in `validateOntologyRelations` + * (`ONTOLOGY_COMPOSITION_VIA_REQUIRED` / `..._FORBIDDEN`) already ran and + * passed: every `partOf` / `hasPart` relation reaching this function has a + * `via`. A relation missing `via` is skipped rather than re-reported, since + * `build-validated.ts` never calls this function until that pass is clean. + */ +export function buildCompositionRelation( + ontology: readonly NamedOntologyRelation[], + edgeFacts: ReadonlyMap, + registry: KindRegistry, +): Readonly<{ + relation: CompositionRelation; + issues: readonly CompositionIssue[]; +}> { + const issues: CompositionIssue[] = []; + const pairs: CompositionPair[] = []; + const seenPairKeys = new Set(); + const existenceByPairKey = new Map(); + const partSideByEdgeKind = new Map(); + const representativeByEdgeKind = new Map(); + + for (const relation of ontology) { + if (!isCompositionMetaEdge(relation.metaEdge)) { + continue; + } + const viaEdgeKind = relation.via; + if (viaEdgeKind === undefined) continue; + + // A relation object need not come from a factory, so the value is + // judged here too: an unknown `existence` would otherwise read as + // "not required" everywhere and be serialized as written. + if ( + relation.existence !== undefined && + !isCompositionExistence(relation.existence) + ) { + issues.push({ + code: "ONTOLOGY_COMPOSITION_EXISTENCE_INVALID", + message: `Composition relation ${relation.metaEdge}(${relation.from}, ${relation.to}) declares \`existence: ${JSON.stringify(relation.existence)}\`; it must be one of ${COMPOSITION_EXISTENCE_VALUES.join(", ")}.`, + relation, + }); + continue; + } + + const facts = edgeFacts.get(viaEdgeKind); + if (facts === undefined) { + issues.push({ + code: "ONTOLOGY_COMPOSITION_VIA_UNKNOWN", + message: `Composition relation ${relation.metaEdge}(${relation.from}, ${relation.to}) names unregistered edge kind "${viaEdgeKind}" as its \`via\`.`, + relation, + }); + continue; + } + + const { partKind, wholeKind } = normalizePartWhole(relation); + const inference = inferCompositionPartSide( + { + partKind, + wholeKind, + ...(relation.partSide === undefined ? + {} + : { declared: relation.partSide }), + }, + facts, + registry, + ); + if ("code" in inference) { + issues.push({ + code: inference.code, + message: inferenceIssueMessage( + inference.code, + relation, + partKind, + wholeKind, + viaEdgeKind, + ), + relation, + }); + continue; + } + + const { partSide } = inference; + const population = + partSide === "from" ? facts.cardinality : facts.targetCardinality; + if (population !== "one" && population !== "oneActive") { + const optionName = + partSide === "from" ? "cardinality" : "targetCardinality"; + issues.push({ + code: "ONTOLOGY_COMPOSITION_CARDINALITY", + message: + `Composition edge "${viaEdgeKind}" must declare \`${optionName}: "one"\` or \`"oneActive"\` ` + + `(found "${population}") because it realizes ${relation.metaEdge}(${relation.from}, ${relation.to}).`, + relation, + }); + continue; + } + + // A second declaration disagreeing with an already-recorded orientation + // for this edge kind is refused directly, rather than relying on + // composition-exactness (below) to notice a coverage gap: exactness + // only catches the conflict when the corrupted pair also fails to + // reproduce the edge's admitted pairs, which is not guaranteed for + // every endpoint shape and makes the verdict declaration-order + // dependent. Recording one side per edge kind and refusing a + // contradicting second side keeps the map's invariant true by + // construction instead of by coincidence. + const existingPartSide = partSideByEdgeKind.get(viaEdgeKind); + if (existingPartSide !== undefined && existingPartSide !== partSide) { + issues.push({ + code: "ONTOLOGY_COMPOSITION_VIA_MIXED", + message: + `Edge kind "${viaEdgeKind}" realizes composition in two different orientations ` + + `("${existingPartSide}" and "${partSide}"); one edge kind must have one part side.`, + relation, + }); + continue; + } + partSideByEdgeKind.set(viaEdgeKind, partSide); + representativeByEdgeKind.set(viaEdgeKind, relation); + + // The documented `partOf(part, whole, ...)` + mirrored + // `hasPart(whole, part, ...)` idiom (both realized by the same edge) + // normalizes to the identical (partKind, wholeKind, viaEdgeKind) tuple. + // Keep one `CompositionPair` per tuple so every `pairs` consumer + // (exactness, population, cascade, navigation) processes it once. + // + // The two declarations of that one pair must agree on `existence`, and + // that is judged BEFORE the second one is folded away: keeping the first + // and dropping the other would make "is this part required" depend on + // declaration order. An unstated `existence` reads as the default on + // either side. + const pairKey = encodeTupleKey([partKind, wholeKind, viaEdgeKind]); + const existence = relation.existence ?? "optional"; + const declaredExistence = existenceByPairKey.get(pairKey); + if (declaredExistence !== undefined && declaredExistence !== existence) { + issues.push({ + code: "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + message: + `Composition pair ("${partKind}" part of "${wholeKind}" via "${viaEdgeKind}") is declared twice with different \`existence\` ` + + `(${[declaredExistence, existence].toSorted((left, right) => compareStrings(left, right)).join(", ")}); ` + + `a mirrored partOf/hasPart declaration must state the same existence.`, + relation, + }); + continue; + } + existenceByPairKey.set(pairKey, existence); + if (seenPairKeys.has(pairKey)) continue; + seenPairKeys.add(pairKey); + + pairs.push({ + partKind, + wholeKind, + viaEdgeKind, + partSide, + population, + existence, + }); + } + + // Composition-exactness: every allowed (from, to) pair the realizing edge + // admits must be a declared composition pair in the inferred orientation. + // This is what makes "is a composition edge" a whole-kind property, so + // cascade and navigation can use a flat edge-kind set with no per-pair + // filter. + for (const edgeKind of partSideByEdgeKind.keys()) { + const facts = edgeFacts.get(edgeKind); + if (facts === undefined) continue; + const declaredTuples = new Set( + pairs + .filter((pair) => pair.viaEdgeKind === edgeKind) + .map((pair) => + pair.partSide === "from" ? + encodeTupleKey([pair.partKind, pair.wholeKind]) + : encodeTupleKey([pair.wholeKind, pair.partKind]), + ), + ); + const representative = requireDefined( + representativeByEdgeKind.get(edgeKind), + `Composition edge "${edgeKind}" has an orientation with no declaring relation.`, + ); + for (const { from, to } of facts.pairs) { + if (!declaredTuples.has(encodeTupleKey([from, to]))) { + issues.push({ + code: "ONTOLOGY_COMPOSITION_VIA_MIXED", + message: + `Edge kind "${edgeKind}" admits (${from} -> ${to}), which is not a declared composition pair. ` + + `Every pair "${edgeKind}" admits must be declared as a partOf/hasPart pair, or the edge must be split so it is exclusively a composition edge.`, + relation: representative, + }); + } + } + } + + const candidateKinds = new Set(); + for (const pair of pairs) { + candidateKinds.add(pair.partKind); + for (const descendant of registry.subClassDescendants.get(pair.partKind) ?? + []) { + candidateKinds.add(descendant); + } + } + // Which declared pairs can hold each candidate kind as their part, resolved + // once for both uniformity checks below. + const applicablePairsByPartKind = new Map( + [...candidateKinds].map((kind) => [ + kind, + pairs.filter((pair) => registry.isAssignableTo(kind, pair.partKind)), + ]), + ); + + // Uniform population: every composition edge that can hold a given node + // kind as its part must agree on the whole-side cardinality, because the + // claim axis is relation-wide, not per-edge. + for (const [kind, applicable] of applicablePairsByPartKind) { + const issue = mixedPartKindFactIssue( + kind, + applicable, + (pair) => pair.population, + "ONTOLOGY_COMPOSITION_POPULATION_MIXED", + "populations", + "cardinality", + ); + if (issue !== undefined) issues.push(issue); + } + + // The mixed-existence refusal: a part has one whole across every declared + // composition relation, so "must this part have one" is a property of the + // part kind, not of a pair — the same argument + // `ONTOLOGY_COMPOSITION_POPULATION_MIXED` already makes for population. + // This is what makes `KindRegistry.compositionExistence` total. + for (const [kind, applicable] of applicablePairsByPartKind) { + const issue = mixedPartKindFactIssue( + kind, + applicable, + (pair) => pair.existence, + "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + "`existence`", + "existence", + ); + if (issue !== undefined) issues.push(issue); + } + + const sortedPairs = pairs.toSorted( + (left, right) => + compareStrings(left.partKind, right.partKind) || + compareStrings(left.wholeKind, right.wholeKind) || + compareStrings(left.viaEdgeKind, right.viaEdgeKind), + ); + const edgeKinds = new Set( + [...partSideByEdgeKind.keys()].toSorted((left, right) => + compareStrings(left, right), + ), + ); + + return { + relation: { pairs: sortedPairs, edgeKinds, partSideByEdgeKind }, + issues, + }; +} + +/** + * The registry readers that answer a composition walk towards `towards`: the + * realizing edge kinds under a source kind, the target kinds they reach, and + * the target kinds one realizing edge alone reaches. + */ +export function compositionReadersTowards( + registry: KindRegistry, + towards: "parts" | "wholes", +): Readonly<{ + edgeKindsFrom: (kind: string) => readonly string[]; + targetKindsFrom: (kind: string) => readonly string[]; + targetKindsFromVia: (kind: string, viaEdgeKind: string) => readonly string[]; +}> { + return towards === "parts" ? + { + edgeKindsFrom: (kind) => registry.compositionEdgeKindsUnder(kind), + targetKindsFrom: (kind) => registry.compositionPartKindsUnder(kind), + targetKindsFromVia: (kind, viaEdgeKind) => + registry.compositionPartKindsUnderVia(kind, viaEdgeKind), + } + : { + edgeKindsFrom: (kind) => registry.compositionEdgeKindsOver(kind), + targetKindsFrom: (kind) => registry.compositionWholeKindsOver(kind), + targetKindsFromVia: (kind, viaEdgeKind) => + registry.compositionWholeKindsOverVia(kind, viaEdgeKind), + }; +} + +/** + * THE rule a composition navigator's `via` must satisfy, and what it reaches: + * `viaEdgeKind` must realize a pair AT one of `sourceKinds` itself (towards + * `towards`), because a walk narrowed to one edge starts from the source and + * follows that edge alone — an edge that realizes a pair only further down + * the closure is never reached from the source, so naming it would read + * nothing at all rather than refuse. + * + * Realized: the target kinds that edge's own closure reaches. Not realized: + * the edges that WOULD be accepted, code-point ordered, for the refusal. + * `parts()`/`wholes()` and `subgraph({ composition: { via } })` both decide + * through this, so neither can accept a `via` the other refuses. + */ +export function compositionViaReach( + registry: KindRegistry, + towards: "parts" | "wholes", + sourceKinds: readonly string[], + viaEdgeKind: string, +): + | Readonly<{ realized: true; targetKinds: readonly string[] }> + | Readonly<{ realized: false; declaredVia: readonly string[] }> { + const { edgeKindsFrom, targetKindsFromVia } = compositionReadersTowards( + registry, + towards, + ); + const targetKinds = [ + ...new Set( + sourceKinds.flatMap((kind) => targetKindsFromVia(kind, viaEdgeKind)), + ), + ]; + if (targetKinds.length > 0) return { realized: true, targetKinds }; + const declaredVia = [ + ...new Set(sourceKinds.flatMap((kind) => edgeKindsFrom(kind))), + ] + .filter((edgeKind) => + sourceKinds.some((kind) => targetKindsFromVia(kind, edgeKind).length > 0), + ) + .toSorted((left, right) => compareStrings(left, right)); + return { realized: false, declaredVia }; +} + +/** + * THE membership rule of a composition row under its pair's whole-side + * population: `"one"` binds part and whole for the row's whole life, ended + * or not (`"allRows"`); `"oneActive"` holds only while the window is open + * (`"openRows"`), because an ended row is the history a reparent leaves + * behind and attaches nothing. + * + * Every reader of composition membership decides through this — the + * cascade's closure (`compositionEdgeCounts`), the composition acyclicity + * relation, `subgraph({ composition })` and `parts()`/`wholes()` — so none + * can disagree about which rows bind. The composition CLAIM reaches the same + * rule through its cardinality axis (`holderLiveness` of the `"one"` / + * `"oneActive"` axis); `tests/composition-row-membership.test.ts` pins the + * two together. + */ +export function compositionPopulationMembership( + population: "one" | "oneActive", +): RowMembership { + return population === "oneActive" ? "openRows" : "allRows"; +} + +/** + * Each composition edge kind's own {@link compositionPopulationMembership} + * rule, read off the registry: the per-kind input every SQL reader of + * composition membership renders (`compileEdgeMembershipFilters`, + * `src/query/compiler/temporal.ts`). + */ +export function compositionEdgeKindMembers( + registry: KindRegistry, + edgeKinds: Iterable, +): readonly Readonly<{ edgeKind: string; membership: RowMembership }>[] { + return [...edgeKinds].map((edgeKind) => ({ + edgeKind, + membership: compositionPopulationMembership( + requireDefined( + registry.compositionEdgePopulation(edgeKind), + `"${edgeKind}" is not a composition edge kind, but was named as one`, + ), + ), + })); +} diff --git a/packages/typegraph/src/registry/edge-endpoint-allowance.ts b/packages/typegraph/src/registry/edge-endpoint-allowance.ts new file mode 100644 index 000000000..b1a80198b --- /dev/null +++ b/packages/typegraph/src/registry/edge-endpoint-allowance.ts @@ -0,0 +1,48 @@ +/** + * THE concrete `(from, to)` pairs an edge kind's declaration admits, after + * subsumption. + * + * One owner, called by the graph-side fence declarations + * (`src/store/claims/verify.ts`) and by the serialized-schema-side ontology + * tightening probe (`src/schema/ontology-change.ts`), so the audit and the + * probe can never disagree about what a declaration allows. + */ +import type { EdgeEndpointAllowance } from "../backend/types"; +import { compareStringTuples } from "../utils/compare"; +import { encodeTupleKey } from "../utils/tuple-key"; +import type { EdgeKindFacts } from "./edge-kind-facts"; +import type { KindRegistry } from "./kind-registry"; + +/** + * `facts.pairs` is the source — the one builder that read the declaration + * already resolved a source-dependent target map or a Cartesian `from × to` + * into it. Each side is expanded with `registry.expandSubClasses`, which is + * the same expansion `registry.isAssignableTo` answers with — a kind is + * assignable to a declared endpoint exactly when it is that endpoint or one + * of its `expandSubClasses` descendants. + * + * Deduped and sorted by `compareStringTuples` over `(from, to)` so two + * runs produce one shape. + */ +export function expandEdgeEndpointAllowance( + edgeKind: string, + facts: EdgeKindFacts, + registry: KindRegistry, +): EdgeEndpointAllowance { + const seen = new Set(); + const allowedPairs: (readonly [string, string])[] = []; + for (const { from: declaredFrom, to: declaredTo } of facts.pairs) { + for (const concreteFrom of registry.expandSubClasses(declaredFrom)) { + for (const concreteTo of registry.expandSubClasses(declaredTo)) { + const key = encodeTupleKey([concreteFrom, concreteTo]); + if (seen.has(key)) continue; + seen.add(key); + allowedPairs.push([concreteFrom, concreteTo]); + } + } + } + + allowedPairs.sort((left, right) => compareStringTuples(left, right)); + + return { edgeKind, allowedPairs }; +} diff --git a/packages/typegraph/src/registry/edge-kind-facts.ts b/packages/typegraph/src/registry/edge-kind-facts.ts new file mode 100644 index 000000000..c52f63fd6 --- /dev/null +++ b/packages/typegraph/src/registry/edge-kind-facts.ts @@ -0,0 +1,106 @@ +/** + * `EdgeKindFacts` — one entry type carrying an edge kind's declared + * endpoints AND its cardinalities. + * + * Composition validation (`src/registry/composition-relation.ts`) needs the + * realizing edge's cardinality alongside its endpoints. A sibling map built + * at a second site could disagree with this one about which edge kinds + * exist or what their endpoints are, so the fact lives on this one entry + * instead — built once per input shape (a live `GraphDef` or a persisted + * schema) and read everywhere else. + */ +import { effectiveTargetCardinality } from "../core/edge-integrity-options"; +import { type Cardinality, type TargetCardinality } from "../core/types"; +import { type SerializedEdgeDef } from "../schema/types"; +import { encodeTupleKey } from "../utils/tuple-key"; + +/** + * An edge kind's declared domain (`from`) and range (`to`) kind names, its + * allowed endpoint pairs, and its declared cardinalities. + */ +export type EdgeKindFacts = Readonly<{ + from: readonly string[]; + to: readonly string[]; + /** + * The `(from, to)` pairs the declaration admits, already resolved from a + * source-dependent target map or a plain Cartesian product by whichever + * builder read the declaration — so no consumer re-derives a second, + * driftable spelling of the same value. + */ + pairs: readonly Readonly<{ from: string; to: string }>[]; + cardinality: Cardinality; + targetCardinality: TargetCardinality; +}>; + +/** + * Maps each edge kind's serialized definition to its domain/range kind + * names and cardinalities, for `validateImpliesEndpointCompatibility`, for + * `expandEdgeEndpointAllowance` (`src/registry/edge-endpoint-allowance.ts`), + * and for `buildCompositionRelation` + * (`src/registry/composition-relation.ts`). A `Map` (rather than the plain + * `schema.edges` object) so a lookup for an edge kind literally named + * "toString" or another `Object.prototype` member can't resolve to an + * inherited member instead of `undefined`. + */ +export function buildSerializedEdgeKindFacts( + edges: Record, +): ReadonlyMap { + const result = new Map(); + for (const [kind, def] of Object.entries(edges)) { + result.set(kind, { + from: def.fromKinds, + to: def.toKinds, + pairs: serializedEdgeEndpointPairs(def), + cardinality: def.cardinality, + targetCardinality: effectiveTargetCardinality(def.targetCardinality), + }); + } + return result; +} + +/** + * The `(from, to)` pairs a serialized edge declares: its source-dependent + * target map when it has one, else the Cartesian `fromKinds × toKinds`. + */ +export function serializedEdgeEndpointPairs( + def: SerializedEdgeDef, +): EdgeKindFacts["pairs"] { + const pairs: { from: string; to: string }[] = []; + if (def.targetKindsBySource === undefined) { + for (const fromKind of def.fromKinds) { + for (const toKind of def.toKinds) { + pairs.push({ from: fromKind, to: toKind }); + } + } + } else { + for (const [sourceKind, targetKinds] of Object.entries( + def.targetKindsBySource, + )) { + for (const targetKind of targetKinds) { + pairs.push({ from: sourceKind, to: targetKind }); + } + } + } + return pairs; +} + +/** + * The declared endpoint pairs `before` holds that `after` does not, deduped, + * in `before`'s order. Endpoint kind lists are sets, so the order pairs are + * declared in carries no meaning. + */ +export function lostEndpointPairs( + before: EdgeKindFacts["pairs"], + after: EdgeKindFacts["pairs"], +): EdgeKindFacts["pairs"] { + const afterKeys = new Set( + after.map((pair) => encodeTupleKey([pair.from, pair.to])), + ); + const seen = new Set(); + return before.filter((pair) => { + const key = encodeTupleKey([pair.from, pair.to]); + if (afterKeys.has(key) || seen.has(key)) return false; + seen.add(key); + return true; + }); +} diff --git a/packages/typegraph/src/registry/index.ts b/packages/typegraph/src/registry/index.ts index 9eb4e31d2..3ae9cc62c 100644 --- a/packages/typegraph/src/registry/index.ts +++ b/packages/typegraph/src/registry/index.ts @@ -6,4 +6,7 @@ export { } from "./kind-registry"; // Builder functions -export { buildKindRegistry } from "./builders"; +export { buildGraphEdgeKindFacts, buildKindRegistry } from "./builders"; + +// Edge endpoint allowance expansion +export { expandEdgeEndpointAllowance } from "./edge-endpoint-allowance"; diff --git a/packages/typegraph/src/registry/kind-registry.ts b/packages/typegraph/src/registry/kind-registry.ts index ea1233497..25d6b4b18 100644 --- a/packages/typegraph/src/registry/kind-registry.ts +++ b/packages/typegraph/src/registry/kind-registry.ts @@ -21,8 +21,27 @@ import { import { isExternalIri } from "../ontology/external-iri"; import { type OntologyRelation } from "../ontology/types"; import { type NamedOntologyRelation } from "../ontology/validation"; -import { compareStrings } from "../utils/compare"; +import { compareCodePoints } from "../utils/compare"; import { requireDefined } from "../utils/presence"; +import { encodeTupleKey } from "../utils/tuple-key"; +import { + type CompositionExistence, + type CompositionPair, + type CompositionPartSide, + type CompositionRelation, + EMPTY_COMPOSITION_RELATION, + normalizePartWhole, +} from "./composition-relation"; +import { type EdgeKindFacts } from "./edge-kind-facts"; + +/** Which end of a composition pair a traversal moves toward. */ +type CompositionSide = "part" | "whole"; + +/** What one composition walk reaches: node kinds and the edge kinds crossed. */ +type CompositionClosure = Readonly<{ + kinds: readonly string[]; + edgeKinds: readonly string[]; +}>; const DISJOINT_PAIR_SEPARATOR = "|"; const ENCODED_DISJOINT_PAIR_PREFIX = "\u001Epair\u001E"; @@ -127,13 +146,30 @@ function computeSubClassComponents( } } const component = Object.freeze( - [...members].toSorted((left, right) => compareStrings(left, right)), + [...members].toSorted((left, right) => compareCodePoints(left, right)), ); for (const member of members) components.set(member, component); } return components; } +/** Every precomputed ontology closure a `KindRegistry` is built from. */ +export type RegistryClosures = Readonly<{ + subClassAncestors: ReadonlyMap>; + subClassDescendants: ReadonlyMap>; + broaderClosure: ReadonlyMap>; + narrowerClosure: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + iriToKind: ReadonlyMap; + relatedKinds: ReadonlyMap>; + disjointPairs: ReadonlySet; + partOfClosure: ReadonlyMap>; + hasPartClosure: ReadonlyMap>; + edgeInverses: ReadonlyMap; + edgeImplicationsClosure: ReadonlyMap>; + edgeImplyingClosure: ReadonlyMap>; +}>; + /** * KindRegistry holds precomputed closures for ontological reasoning. * @@ -154,7 +190,11 @@ export class KindRegistry { // Transitive closure for inheritance readonly subClassAncestors: ReadonlyMap>; readonly subClassDescendants: ReadonlyMap>; - readonly #subClassComponents: ReadonlyMap; + /** + * Computed on first read: the composition builder's validation-only + * registry is discarded without ever asking for a component. + */ + #subClassComponents: ReadonlyMap | undefined; // === Hierarchy (broader/narrower) === // Transitive closure for concept hierarchy (separate from subClassOf!) @@ -169,10 +209,43 @@ export class KindRegistry { // === Constraints === readonly disjointPairs: ReadonlySet; // Injectively encoded unordered pairs - // === Composition === + // === Composition (declaration-level closures) === readonly partOfClosure: ReadonlyMap>; readonly hasPartClosure: ReadonlyMap>; + /** + * THE composition relation: every declared `partOf`/`hasPart` + * pair, its realizing edge, its orientation, and its whole-side + * population. Defaults to empty so the many `new KindRegistry(...)` call + * sites that predate composition (chiefly `tests/property/**`) keep + * compiling unchanged. + */ + readonly #composition: CompositionRelation; + + /** + * Pure caches over {@link #composition}, which is immutable for the life of + * the registry: per concrete kind, the derived answers the composition + * readers below would otherwise recompute on every call. The readers stay + * the single owners of their decisions — only the repetition is removed. + */ + readonly #compositionClosureCache = new Map(); + readonly #compositionFirstPairByPartKind = new Map< + string, + CompositionPair | undefined + >(); + readonly #compositionPopulationByEdgeKind: ReadonlyMap< + string, + CompositionPair["population"] + >; + readonly #compositionEdgeKindList: readonly string[]; + + /** + * Each registered edge kind's declared endpoints and cardinalities, as the + * builder that read the declaration resolved them. Defaults to empty for + * the same call sites {@link #composition} does. + */ + readonly #edgeFacts: ReadonlyMap; + // === Edge Relationships === readonly edgeInverses: ReadonlyMap; readonly edgeImplicationsClosure: ReadonlyMap>; @@ -181,33 +254,22 @@ export class KindRegistry { constructor( nodeKinds: ReadonlyMap, edgeKinds: ReadonlyMap, - closures: { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; - }, + closures: RegistryClosures, identity?: GraphIdentityConfig, + composition: CompositionRelation = EMPTY_COMPOSITION_RELATION, + edgeFacts: ReadonlyMap = new Map(), ) { + this.#edgeFacts = edgeFacts; this.nodeKinds = nodeKinds; this.edgeKinds = edgeKinds; this.identity = identity; + this.#composition = composition; + this.#compositionPopulationByEdgeKind = new Map( + composition.pairs.map((pair) => [pair.viaEdgeKind, pair.population]), + ); + this.#compositionEdgeKindList = Object.freeze([...composition.edgeKinds]); this.subClassAncestors = closures.subClassAncestors; this.subClassDescendants = closures.subClassDescendants; - this.#subClassComponents = computeSubClassComponents( - nodeKinds, - closures.subClassAncestors, - closures.subClassDescendants, - ); this.broaderClosure = closures.broaderClosure; this.narrowerClosure = closures.narrowerClosure; this.equivalenceSets = closures.equivalenceSets; @@ -258,6 +320,11 @@ export class KindRegistry { * in code-point order. Every member of one component returns the same array. */ getSubClassComponent(kind: string): readonly string[] { + this.#subClassComponents ??= computeSubClassComponents( + this.nodeKinds, + this.subClassAncestors, + this.subClassDescendants, + ); return this.#subClassComponents.get(kind) ?? [kind]; } @@ -399,6 +466,265 @@ export class KindRegistry { return parts ? [...parts] : []; } + // === Composition Relation === + // + // `getParts`/`getWholes` above keep their declaration-level closure + // semantics unchanged: they follow literally declared kind names only. The + // readers below answer for concrete node kinds, resolving every kind a walk + // reaches through subsumption, and are the ONLY way any + // other lane reaches `pairs` — nothing outside this class indexes into + // the relation directly, which is what keeps "is this edge kind a + // composition edge" and "which side is the part" answered once. + + /** THE composition relation: every declared pair, its realizing edge, and its orientation. */ + compositionRelation(): CompositionRelation { + return this.#composition; + } + + /** Whether `edgeKind`'s live rows realize a composition pair. */ + isCompositionEdge(edgeKind: string): boolean { + return this.#composition.edgeKinds.has(edgeKind); + } + + /** Which endpoint of `edgeKind` carries the PART, or `undefined` if it is not a composition edge. */ + compositionPartSide(edgeKind: string): CompositionPartSide | undefined { + return this.#composition.partSideByEdgeKind.get(edgeKind); + } + + /** + * The whole-side population of `edgeKind`'s rows, or `undefined` if it is + * not a composition edge. A property of the realizing edge kind's own + * declaration, so every pair sharing one `via` agrees on it + * (`ONTOLOGY_COMPOSITION_VIA_MIXED` refuses a graph where they would not). + */ + compositionEdgePopulation(edgeKind: string): "one" | "oneActive" | undefined { + return this.#compositionPopulationByEdgeKind.get(edgeKind); + } + + /** + * Whether `kind` (or a superclass it is assignable to) is a composition + * WHOLE — declares parts under {@link compositionEdgeKindsUnder}. The one + * owner of this classification, read by a node-delete's constraint fence, + * the fused-delete-command eligibility guard, and merge's orphan scan, so + * none of them spells `compositionEdgeKindsUnder(kind).length > 0` inline. + */ + isCompositionWhole(kind: string): boolean { + return this.compositionEdgeKindsUnder(kind).length > 0; + } + + /** The parts mirror of {@link isCompositionWhole}, over {@link compositionEdgeKindsOver}. */ + isCompositionPart(kind: string): boolean { + return this.compositionEdgeKindsOver(kind).length > 0; + } + + /** + * THE composition closure of one concrete node kind along one direction: + * `"part"` descends from a whole to everything under it, `"whole"` climbs + * from a part to everything over it. One traversal yields both the node + * kinds reached and the realizing edge kinds crossed, so the readers below + * are projections of a single walk and cannot drift. + * + * The walk is over CONCRETE kinds, because that is what a stored row + * carries. A node of kind `K` sits at a pair's anchor end whenever `K` is + * assignable to the kind the pair was declared against, and the opposite + * end admits that declared kind and every subclass of it (edge-endpoint + * validation accepts any of them). Every kind reached is therefore + * re-resolved through subsumption before the next step: a pair declared on + * a subclass of a reached kind, and a pair declared on a superclass of one, + * are both crossed. + * + * `viaEdgeKind` narrows the walk to the pairs that one edge realizes, so + * the closure is empty when it realizes no pair at `kind` itself. + */ + #compositionClosureAlong( + kind: string, + side: CompositionSide, + viaEdgeKind?: string, + ): CompositionClosure { + const cacheKey = encodeTupleKey( + viaEdgeKind === undefined ? [side, kind] : [side, kind, viaEdgeKind], + ); + const cached = this.#compositionClosureCache.get(cacheKey); + if (cached !== undefined) return cached; + const pairs = + viaEdgeKind === undefined ? + this.#composition.pairs + : this.#composition.pairs.filter( + (pair) => pair.viaEdgeKind === viaEdgeKind, + ); + const reachedKinds = new Set(); + const edgeKinds = new Set(); + const expanded = new Set([kind]); + const frontier = [kind]; + for (const concreteKind of frontier) { + for (const pair of pairs) { + const [anchorKind, oppositeKind] = + side === "part" ? + [pair.wholeKind, pair.partKind] + : [pair.partKind, pair.wholeKind]; + if (!this.isAssignableTo(concreteKind, anchorKind)) continue; + edgeKinds.add(pair.viaEdgeKind); + for (const reachedKind of this.expandSubClasses(oppositeKind)) { + reachedKinds.add(reachedKind); + if (expanded.has(reachedKind)) continue; + expanded.add(reachedKind); + frontier.push(reachedKind); + } + } + } + const closure: CompositionClosure = { + kinds: [...reachedKinds].toSorted((left, right) => + compareCodePoints(left, right), + ), + edgeKinds: [...edgeKinds].toSorted((left, right) => + compareCodePoints(left, right), + ), + }; + this.#compositionClosureCache.set(cacheKey, closure); + return closure; + } + + /** + * The first declared pair that can hold a node of this concrete kind as its + * part, memoized. `ONTOLOGY_COMPOSITION_POPULATION_MIXED` and + * `ONTOLOGY_COMPOSITION_EXISTENCE_MIXED` refuse any graph where the pairs + * that can hold one kind disagree, so the FIRST applicable pair carries the + * answer for all of them. + */ + #firstCompositionPairFor( + concretePartKind: string, + ): CompositionPair | undefined { + if (this.#compositionFirstPairByPartKind.has(concretePartKind)) { + return this.#compositionFirstPairByPartKind.get(concretePartKind); + } + const applicable = this.#composition.pairs.find((pair) => + this.isAssignableTo(concretePartKind, pair.partKind), + ); + this.#compositionFirstPairByPartKind.set(concretePartKind, applicable); + return applicable; + } + + /** + * EVERY declared composition pair between this part and whole kind — + * either may be a subclass of the kind the pair was declared against — + * code-point ordered by realizing edge kind (the order + * `CompositionRelation.pairs` already carries). + * + * Deliberately plural. Two realizing edges may hold the same + * (part, whole) pair, and this reader used to answer with the + * code-point-FIRST one alone, which silently picked an attachment's + * realizing edge for the caller. The caller now names it (`partOf`'s + * `via`), and ambiguity is refused rather than resolved by sort order — + * see {@link compositionPairVia} for the by-`via` lookup and + * `resolveCompositionAttachment` + * (`src/store/operations/composition-create.ts`) for the one owner of that + * refusal. + */ + compositionPairsBetween( + partKind: string, + wholeKind: string, + ): readonly CompositionPair[] { + return this.#composition.pairs.filter( + (pair) => + this.isAssignableTo(partKind, pair.partKind) && + this.isAssignableTo(wholeKind, pair.wholeKind), + ); + } + + /** + * The declared composition pair between this part and whole kind realized + * by exactly `viaEdgeKind`, or `undefined` when the three do not name a + * declared pair. The by-`via` projection of + * {@link compositionPairsBetween}, so a caller that already knows the + * realizing edge (an attachment naming `via`, a cascade row whose own + * `kind` IS the realizing edge) never has to re-filter the plural answer. + */ + compositionPairVia( + partKind: string, + wholeKind: string, + viaEdgeKind: string, + ): CompositionPair | undefined { + return this.compositionPairsBetween(partKind, wholeKind).find( + (pair) => pair.viaEdgeKind === viaEdgeKind, + ); + } + + /** Every realizing edge kind, code-point ordered. */ + compositionEdgeKinds(): readonly string[] { + return this.#compositionEdgeKindList; + } + + /** + * The realizing edge kinds reachable under `wholeKind`: every pair that can + * hold a node of `wholeKind`, or of any kind transitively under it, as its + * whole. This is what lets cascade and `parts()` cross heterogeneous edge + * kinds without the caller spelling the path. + */ + compositionEdgeKindsUnder(wholeKind: string): readonly string[] { + return this.#compositionClosureAlong(wholeKind, "part").edgeKinds; + } + + /** The wholes mirror of {@link compositionEdgeKindsUnder}. */ + compositionEdgeKindsOver(partKind: string): readonly string[] { + return this.#compositionClosureAlong(partKind, "whole").edgeKinds; + } + + /** Every concrete part kind transitively under `wholeKind`, subclasses included, across every composition relation. */ + compositionPartKindsUnder(wholeKind: string): readonly string[] { + return this.#compositionClosureAlong(wholeKind, "part").kinds; + } + + /** Every concrete whole kind transitively over `partKind`, subclasses included, across every composition relation. */ + compositionWholeKindsOver(partKind: string): readonly string[] { + return this.#compositionClosureAlong(partKind, "whole").kinds; + } + + /** + * Every concrete part kind under `wholeKind` reachable through + * `viaEdgeKind` alone — the by-`via` narrowing of + * {@link compositionPartKindsUnder}. Empty when `viaEdgeKind` realizes no + * pair at `wholeKind` itself. + */ + compositionPartKindsUnderVia( + wholeKind: string, + viaEdgeKind: string, + ): readonly string[] { + return this.#compositionClosureAlong(wholeKind, "part", viaEdgeKind).kinds; + } + + /** The wholes mirror of {@link compositionPartKindsUnderVia}. */ + compositionWholeKindsOverVia( + partKind: string, + viaEdgeKind: string, + ): readonly string[] { + return this.#compositionClosureAlong(partKind, "whole", viaEdgeKind).kinds; + } + + /** + * The whole-side population a composition part of this concrete kind is + * held to — total over every concrete node kind that can appear as a + * composition part, because `ONTOLOGY_COMPOSITION_POPULATION_MIXED` + * refuses any graph where that would be ambiguous. + */ + compositionPopulation( + concretePartKind: string, + ): "one" | "oneActive" | undefined { + return this.#firstCompositionPairFor(concretePartKind)?.population; + } + + /** + * THE answer to "must a node of this kind have a whole" — total + * over every concrete node kind, because `ONTOLOGY_COMPOSITION_EXISTENCE_MIXED` + * refuses any graph where that would be ambiguous. Returns `"optional"` for + * a kind that declares no part side at all. Every existence decision reads this + * one function. + */ + compositionExistence(concretePartKind: string): CompositionExistence { + return ( + this.#firstCompositionPairFor(concretePartKind)?.existence ?? "optional" + ); + } + // === Edge Relationship Methods === /** @@ -484,6 +810,16 @@ export class KindRegistry { return this.nodeKinds.get(name); } + /** + * The endpoints and cardinalities an edge kind's REGISTRATION declares, + * wherever they were stated (on the edge type, or on the graph's + * `{ type, from, to }` entry). `getEdgeType(name).from`/`.to` carry only + * the former, so an endpoint decision reads these facts instead. + */ + edgeKindFacts(edgeKind: string): EdgeKindFacts | undefined { + return this.#edgeFacts.get(edgeKind); + } + /** * Gets an edge kind by name. */ @@ -495,21 +831,7 @@ export class KindRegistry { /** * Builder function to create empty closures. */ -export function createEmptyClosures(): { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; -} { +export function createEmptyClosures(): RegistryClosures { return { subClassAncestors: new Map(), subClassDescendants: new Map(), @@ -565,6 +887,30 @@ type CollectedOntologyRelations = Readonly<{ * The closures disjointness expansion consumes. `validateOntologyRelations` * and the registry share this exact input so a declaration validation accepts * can never expand differently at runtime. + * + * Every `equivalentTo` pair is folded into `subClassAncestors`/ + * `subClassDescendants` before this closure is built (see + * {@link computeSubsumptionAndEquivalenceClosures}), so every equivalence-set + * member is ALREADY reachable through `subClassDescendants` alone — + * `equivalenceSets` is not a second, independent source of "which kinds does + * this one inherit disjointness from". It is carried here purely so + * {@link expandDisjointSide} can recognize when two kinds share a class and + * skip re-walking a fellow's (nearly-identical, and potentially huge) + * `subClassDescendants` view a second time: a large `equivalentTo`/`sameAs` + * class would otherwise cost O(class size) PER MEMBER to re-discover the same + * fellows and structural descendants every other member's view already + * named. + * + * PRECONDITION `expandDisjointSide` relies on and does not re-check: within + * one equivalence class, every member's `subClassDescendants` entry must be + * "nearly identical" — differing only by which single member each entry + * excludes (the property `expandCollapsedRelation` guarantees by construction + * for its own two closures). A `subClassDescendants` built any other way + * (e.g. by hand, or by a future second builder) breaks the "one fellow's view + * already covers every other fellow's" skip silently: it stops early having + * missed whichever fellow's distinct descendants were never walked, with no + * error. Only `computeDisjointExpansionClosures` is a sound source of this + * type today. */ export type DisjointExpansionClosures = Readonly<{ subClassDescendants: ReadonlyMap>; @@ -614,13 +960,10 @@ function collectOntologyRelations( disjoint.push([fromName, toName]); break; } - case META_EDGE_PART_OF: { - partOf.push([fromName, toName]); - break; - } + case META_EDGE_PART_OF: case META_EDGE_HAS_PART: { - // hasPart is inverse of partOf - partOf.push([toName, fromName]); + const { partKind, wholeKind } = normalizePartWhole(relation); + partOf.push([partKind, wholeKind]); break; } case META_EDGE_INVERSE_OF: { @@ -647,15 +990,246 @@ function collectOntologyRelations( }; } +/** + * Maps every equivalence-class member — including an external IRI member — + * to that class's canonical representative: the code-point minimum of the + * class's LOCAL (non-IRI) members. A kind outside every equivalence class is + * absent (callers fall back to the kind itself). + * + * `equivalentTo` is folded into mutual subsumption by collapsing each class to + * ONE node before the transitive closure runs ({@link + * computeSubsumptionAndEquivalenceClosures}), rather than by spelling out + * every ordered pair of distinct members as a direct edge: a class of N + * members already has every member mutually reachable BY CONSTRUCTION (that + * is what an equivalence class is), so materializing all N·(N-1) pairs before + * even reaching Warshall — and Warshall's own O(V³) worst case over that many + * newly-connected nodes — turns one large, otherwise-ordinary `sameAs` + * migration into an out-of-memory crash. Collapsing first keeps THE + * TRANSITIVE-CLOSURE STEP (`computeTransitiveClosure` and its invert) + * proportional to the number of DISTINCT classes and subClassOf-connected + * kinds, never to a single class's size — the O(class size) work in + * `expandCollapsedRelation` below to fold each class back in is separate and + * unavoidable, and `computeSubClassComponents`, downstream of both, still + * BFSes one large equivalence-only class in O(class size²). + * + * An IRI member MUST map to its class's representative too, even though an + * IRI never surfaces as a kind on its own (that filter lives downstream, in + * {@link expandCollapsedRelation}'s `ownClassMembers` handling). A raw + * `subClassOf` relation can name an IRI as an endpoint — a graph-extension + * document is not type-checked against `NodeType`, so `subClassOf(Student, + * "")` is representable even though the typed `subClassOf()` helper + * never produces one — and `collapsedSubClass` below collapses that endpoint + * through `representativeOf`. Leaving the IRI unmapped left it as a literal + * node in the collapsed graph: reachable from the ancestor side (subClassOf's + * child), because that side walks OUTWARD from a real kind, but never a KEY + * on the descendant side, because nothing collapses to look IT up. That made + * `subClassAncestors`/`subClassDescendants` stop being exact inverses for + * exactly the shape the fold exists to unify — an equivalence class routed through + * an IRI that also terminates a `subClassOf` edge. + */ +function computeEquivalenceRepresentatives( + equivalenceSets: ReadonlyMap>, +): ReadonlyMap { + const representativeOf = new Map(); + for (const [member, others] of equivalenceSets) { + if (representativeOf.has(member)) continue; + const allMembers = [member, ...others]; + const localMembers = allMembers.filter((name) => !isExternalIri(name)); + // A class made up ENTIRELY of external IRIs — two bare IRIs declared + // equivalentTo each other — has no local kind to route to. The typed + // `equivalentTo()` helper can never produce this (its left parameter is + // always a real kind), but the raw, untyped ontology-relation shape a + // graph-extension document or a hand-built `NamedOntologyRelation` uses + // can. Leave its members unmapped rather than crash: with no local kind + // in the class, there is nothing for a `subClassOf` endpoint to route + // to, so this is the same pre-existing, out-of-scope "bare unmapped IRI" + // case as an IRI with no equivalence class at all. + if (localMembers.length === 0) continue; + const representative = requireDefined( + localMembers.toSorted((left, right) => compareCodePoints(left, right))[0], + ); + for (const name of allMembers) representativeOf.set(name, representative); + } + return representativeOf; +} + +/** + * Expands a collapsed-representative reachability relation (subClassOf + * ancestors, or its invert) back to a per-kind map, folding each kind's own + * equivalence class back in. Used for BOTH directions: once with the + * collapsed ancestor closure to build `subClassAncestors`, once with its + * invert (cheap — see {@link computeSubsumptionAndEquivalenceClosures}) to + * build `subClassDescendants`, since the fold is symmetric in both. + * + * The per-representative "extra" reachable set — everything reached + * transitively through subClassOf, each expanded to ITS OWN class's fellow + * members — is computed and cached ONCE per representative, never once per + * class member: every member of one equivalence class shares the identical + * extra set, so recomputing it per member is exactly the redundant O(class + * size) work {@link computeEquivalenceRepresentatives}'s docstring explains + * collapsing avoids. + * + * When a kind's class reaches nothing beyond itself in `collapsedRelation` + * (the common case for a class formed purely by `equivalentTo`/`sameAs`, with + * no other subClassOf relation anywhere in the class), this reuses + * `equivalenceSets.get(kind)` — already an O(1)-to-construct excluding view — + * as the kind's reachable set directly, with NO per-kind materialization. + * Only a kind whose class ALSO reaches outside members pays the O(class size) + * cost of building a real combined `Set`, and it pays it once per + * representative, never once per sibling — the same discipline that keeps + * `withoutSelfMembership`'s caller cheap for a huge equivalence-only class. + * + * An equivalence class may itself contain an external IRI (a kind mapped to + * one for cross-system reference), and an IRI is never a kind, so it must not + * surface as a member here. Whether a REPRESENTATIVE's class contains one is + * checked and cached once per representative — `classHasExternalIri` below — + * rather than scanned once per member, so a large IRI-free equivalence class + * (the common case) still costs O(1) per member instead of paying an O(class + * size) scan on every one of its members. + */ +function expandCollapsedRelation( + collapsedRelation: ReadonlyMap>, + equivalenceSets: ReadonlyMap>, + representativeOf: ReadonlyMap, +): ReadonlyMap> { + const allKinds = new Set(representativeOf.keys()); + for (const [from, tos] of collapsedRelation) { + allKinds.add(from); + for (const to of tos) allKinds.add(to); + } + + const extraByRepresentative = new Map>(); + function extraOf(representative: string): ReadonlySet { + const cached = extraByRepresentative.get(representative); + if (cached !== undefined) return cached; + const extra = new Set(); + for (const reachableRepresentative of collapsedRelation.get( + representative, + ) ?? []) { + extra.add(reachableRepresentative); + for (const fellow of equivalenceSets.get(reachableRepresentative) ?? []) { + if (!isExternalIri(fellow)) extra.add(fellow); + } + } + extraByRepresentative.set(representative, extra); + return extra; + } + + const classHasExternalIriByRepresentative = new Map(); + function classHasExternalIri(representative: string): boolean { + const cached = classHasExternalIriByRepresentative.get(representative); + if (cached !== undefined) return cached; + let found = false; + for (const fellow of equivalenceSets.get(representative) ?? []) { + if (isExternalIri(fellow)) { + found = true; + break; + } + } + classHasExternalIriByRepresentative.set(representative, found); + return found; + } + + const result = new Map>(); + for (const kind of allKinds) { + if (isExternalIri(kind)) continue; + const representative = representativeOf.get(kind) ?? kind; + const extra = extraOf(representative); + const rawOwnClassMembers = equivalenceSets.get(kind); + const ownClassMembers = + rawOwnClassMembers !== undefined && classHasExternalIri(representative) ? + new Set( + [...rawOwnClassMembers].filter((fellow) => !isExternalIri(fellow)), + ) + : rawOwnClassMembers; + if (extra.size === 0) { + if (ownClassMembers !== undefined && ownClassMembers.size > 0) { + result.set(kind, ownClassMembers); + } + continue; + } + const combined = new Set(extra); + for (const fellow of ownClassMembers ?? []) combined.add(fellow); + result.set(kind, combined); + } + return result; +} + +/** + * Strips a kind's own name from its ancestor set. + * + * A perfectly LEGAL ontology can put a representative on a cycle: a kind + * sitting strictly between two mutually-equivalent kinds forces it, since + * `equivalentTo` collapses `A` and `B` to one representative before the + * closure runs — `equivalentTo(A, B)`, `subClassOf(A, C)`, `subClassOf(C, B)` + * passes `validateOntologyRelations` (the raw `subClassOf` edges `A → C → B` + * are not a cycle; nothing here inspects `equivalentTo`), yet after the fold + * `C`'s representative reaches `A`'s representative and back, so `A`, `B` and + * `C` all become reachable from themselves. A genuinely cyclic `subClassOf` + * declaration (rejected by `validateOntologyRelations`, but still reached by + * the disjointness-conflict pass before that rejection is enforced) produces + * the same self-reachability the harder way. Subsumption is STRICT in both + * cases — `isSubClassOf(k, k)` is false and `expandSubClasses(k)` lists `k` + * once — so the self-edge is removed here, at the one place the closure is + * built, rather than guarded at each of the read sites. + */ +function withoutSelfMembership( + closure: ReadonlyMap>, +): ReadonlyMap> { + const result = new Map>(); + for (const [kind, reachable] of closure) { + result.set( + kind, + reachable.has(kind) ? + new Set([...reachable].filter((other) => other !== kind)) + : reachable, + ); + } + return result; +} + function computeSubsumptionAndEquivalenceClosures( collected: CollectedOntologyRelations, ): DisjointExpansionClosures & - Readonly<{ subClassAncestors: ReadonlyMap> }> { - const subClassAncestors = computeTransitiveClosure(collected.subClass); + Readonly<{ + subClassAncestors: ReadonlyMap>; + equivalenceSets: ReadonlyMap>; + }> { + const equivalenceSets = computeEquivalenceSets(collected.equivalent); + const representativeOf = computeEquivalenceRepresentatives(equivalenceSets); + + const collapsedSubClass: NamedRelationPair[] = []; + for (const [child, parent] of collected.subClass) { + const repChild = representativeOf.get(child) ?? child; + const repParent = representativeOf.get(parent) ?? parent; + if (repChild !== repParent) collapsedSubClass.push([repChild, repParent]); + } + const collapsedAncestors = computeTransitiveClosure(collapsedSubClass); + // Inverting the COLLAPSED closure is cheap — it is sized to the distinct + // subClassOf-connected kinds, never to any one equivalence class's member + // count — unlike inverting the expanded `subClassAncestors` below, which + // would force materializing the full O(class size²) descendant relation a + // large equivalence-only class already avoids by construction. + const collapsedDescendants = invertClosure(collapsedAncestors); + + const subClassAncestors = withoutSelfMembership( + expandCollapsedRelation( + collapsedAncestors, + equivalenceSets, + representativeOf, + ), + ); + const subClassDescendants = withoutSelfMembership( + expandCollapsedRelation( + collapsedDescendants, + equivalenceSets, + representativeOf, + ), + ); return { subClassAncestors, - subClassDescendants: invertClosure(subClassAncestors), - equivalenceSets: computeEquivalenceSets(collected.equivalent), + subClassDescendants, + equivalenceSets, }; } @@ -675,24 +1249,38 @@ export function computeDisjointExpansionClosures( ); } +/** + * Every equivalence class the ontology declares, each as its member list in + * code-point order, once per class. + * + * Exported so ontology validation classifies the SAME classes the registry + * folds into subsumption. A second spelling of "which kinds are one class" + * would let validation accept a class the fold then treats differently. + */ +export function computeEquivalenceClasses( + ontology: readonly NamedOntologyRelation[], +): readonly (readonly string[])[] { + const sets = computeEquivalenceSets( + collectOntologyRelations(ontology).equivalent, + ); + const seen = new Set(); + const classes: (readonly string[])[] = []; + for (const [member, others] of sets) { + const members = [member, ...others].toSorted((left, right) => + compareCodePoints(left, right), + ); + const key = encodeTupleKey(members); + if (seen.has(key)) continue; + seen.add(key); + classes.push(Object.freeze(members)); + } + return classes; +} + /** Computes all registry closures from already-normalized relation names. */ export function computeClosuresFromNamedOntology( ontology: readonly NamedOntologyRelation[], -): { - subClassAncestors: ReadonlyMap>; - subClassDescendants: ReadonlyMap>; - broaderClosure: ReadonlyMap>; - narrowerClosure: ReadonlyMap>; - equivalenceSets: ReadonlyMap>; - iriToKind: ReadonlyMap; - relatedKinds: ReadonlyMap>; - disjointPairs: ReadonlySet; - partOfClosure: ReadonlyMap>; - hasPartClosure: ReadonlyMap>; - edgeInverses: ReadonlyMap; - edgeImplicationsClosure: ReadonlyMap>; - edgeImplyingClosure: ReadonlyMap>; -} { +): RegistryClosures { const collected = collectOntologyRelations(ontology); const { subClassAncestors, subClassDescendants, equivalenceSets } = @@ -986,43 +1574,59 @@ function computeDisjointPairs( /** * Expands one side of a disjoint pair to every kind that inherits its - * disjointness: the kind itself, its subclass descendants, and its - * equivalence-set members. External IRIs are excluded — they are inert - * references, not local kinds that participate in identity folding. + * disjointness: the kind itself and its subclass descendants. External IRIs + * are excluded — they are inert references, not local kinds that participate + * in identity folding. * - * The IRI exclusion stops descent, so a subclass reached only *through* an - * IRI does not inherit disjointness. Equivalence, by contrast, still crosses - * IRIs because `computeEquivalenceSets` unions over them: a class containing - * an IRI is already closed, and every member is one hop away. + * `equivalentTo` is folded into mutual subsumption before this closure is + * built ({@link computeSubsumptionAndEquivalenceClosures}), so an + * equivalence-set member is + * ALREADY a subclass descendant — walking `equivalenceSets` here too would be + * a second, redundant path to the same kinds. The IRI exclusion still stops + * descent through a subclass declared against an IRI endpoint (a graph + * extension may author one), so a subclass reached only *through* an IRI does + * not inherit disjointness. * * Both the registry and `validateOntologyRelations` expand through this * function. Re-implementing it against the declared relations instead of the - * precomputed closures reintroduces exactly that IRI asymmetry as a + * precomputed closure reintroduces the equivalence-through-IRI case as a * validation blind spot. */ export function expandDisjointSide( kind: string, closures: DisjointExpansionClosures, ): readonly string[] { - const expanded = new Set(); - // Equivalence sets partition the kinds, so every member of a class sees the - // same class. Consuming a class once keeps expansion linear in the class - // size instead of quadratic. - const consumedEquivalenceClasses = new Set(); + if (isExternalIri(kind)) return []; + // `expanded` marks a kind the MOMENT it is discovered (pushed), not only + // once it is popped and processed — so every kind is pushed AT MOST ONCE + // for the life of this call, which bounds `pending`'s size to the number of + // distinct kinds ever discovered instead of letting an equivalence class's + // members re-discover each other before any of them is marked. + const expanded = new Set([kind]); + // A member of an already-fully-walked equivalence class needs no second + // walk of its own `subClassDescendants` view: two fellows' views differ + // only by which single member each excludes, so once ANY fellow's view has + // been iterated, every other fellow's view is already a subset of + // `expanded`. Without this, a class of N members costs O(N) to iterate + // PER MEMBER — O(N²) total, and exactly what turned an otherwise-ordinary + // `equivalentTo`/`sameAs` migration into a multi-billion-entry queue before + // this guard existed. Marking every fellow the first time (also O(N), + // exactly once per class) is what makes the skip legal for the rest. + const descendantsAlreadyCoveredBy = new Set(); const pending = [kind]; while (pending.length > 0) { const current = requireDefined(pending.pop()); - if (expanded.has(current) || isExternalIri(current)) continue; - expanded.add(current); - if (!consumedEquivalenceClasses.has(current)) { - consumedEquivalenceClasses.add(current); - for (const equivalent of closures.equivalenceSets.get(current) ?? []) { - consumedEquivalenceClasses.add(equivalent); - pending.push(equivalent); + if (!descendantsAlreadyCoveredBy.has(current)) { + for (const descendant of closures.subClassDescendants.get(current) ?? + []) { + if (!expanded.has(descendant) && !isExternalIri(descendant)) { + expanded.add(descendant); + pending.push(descendant); + } + } + for (const fellow of closures.equivalenceSets.get(current) ?? []) { + descendantsAlreadyCoveredBy.add(fellow); } - } - for (const descendant of closures.subClassDescendants.get(current) ?? []) { - pending.push(descendant); } } return [...expanded]; diff --git a/packages/typegraph/src/registry/row-membership.ts b/packages/typegraph/src/registry/row-membership.ts new file mode 100644 index 000000000..afb3fbace --- /dev/null +++ b/packages/typegraph/src/registry/row-membership.ts @@ -0,0 +1,19 @@ +/** + * Which of a population's non-deleted rows are its members: every one + * (`"allRows"`), or only a row whose validity window is still open + * (`"openRows"`, no `validTo`). + * + * A static property of a row, never of an instant: composition refuses a + * window that ends or starts later than its write + * (`assertCompositionWindowCurrent`, + * `src/store/operations/composition-create.ts`), so an open-ended + * composition row is current and an ended one is history, for good. That is + * what lets one predicate, `membershipCountsRow` + * (`src/store/claims/edge-claims.ts`), decide membership for a claim, the + * audit, the cascade and the acyclicity relation alike. + * + * Declared at the registry layer because both the store's claims and the + * query compiler's composition navigation read it, and the query layer does + * not depend on the store. + */ +export type RowMembership = "allRows" | "openRows"; diff --git a/packages/typegraph/src/registry/validate-implies.ts b/packages/typegraph/src/registry/validate-implies.ts index 54c4d88df..18322cf67 100644 --- a/packages/typegraph/src/registry/validate-implies.ts +++ b/packages/typegraph/src/registry/validate-implies.ts @@ -13,15 +13,9 @@ */ import { ConfigurationError } from "../errors/index"; import { META_EDGE_IMPLIES } from "../ontology/constants"; +import { type EdgeKindFacts } from "./edge-kind-facts"; import { type KindRegistry } from "./kind-registry"; -/** An edge kind's declared domain (`from`) and range (`to`) kind names and allowed pairs. */ -export type EdgeEndpointKinds = Readonly<{ - from: readonly string[]; - to: readonly string[]; - pairs?: readonly Readonly<{ from: string; to: string }>[]; -}>; - /** * Rejects implications whose implying edge can never be endpoint-compatible * with the edge it implies. @@ -31,7 +25,7 @@ export type EdgeEndpointKinds = Readonly<{ * `registry.expandImplyingEdges(C)` reports as (transitively) implying `C` is * validated directly against `C`. This is what keeps the gate sound through an * unregistered intermediate — given `A implies B implies C` with `B` absent - * from `edgeEndpoints`, neither direct hop is individually checkable, yet the + * from `edgeFacts`, neither direct hop is individually checkable, yet the * precomputed closure still folds `A` into a traversal of `C`, so `A` must be * validated against `C` regardless of `B`. * @@ -40,50 +34,45 @@ export type EdgeEndpointKinds = Readonly<{ * descendant) — to at least one kind the implied edge allows on that same * side, and every allowed endpoint pair of the implying edge is assignable to * an allowed endpoint pair of the implied edge. An implying edge kind absent - * from `edgeEndpoints` is skipped: it is unregistered on this graph, has no + * from `edgeFacts` is skipped: it is unregistered on this graph, has no * stored rows, and so can never fold anything into a traversal. */ export function validateImpliesEndpointCompatibility( - edgeEndpoints: ReadonlyMap, + edgeFacts: ReadonlyMap, registry: KindRegistry, ): void { - for (const [impliedEdgeKind, impliedEndpoints] of edgeEndpoints) { + for (const [impliedEdgeKind, impliedFacts] of edgeFacts) { for (const implyingEdgeKind of registry.expandImplyingEdges( impliedEdgeKind, )) { if (implyingEdgeKind === impliedEdgeKind) continue; - const implyingEndpoints = edgeEndpoints.get(implyingEdgeKind); - if (!implyingEndpoints) continue; + const implyingFacts = edgeFacts.get(implyingEdgeKind); + if (!implyingFacts) continue; assertEndpointCompatible( "from", implyingEdgeKind, - implyingEndpoints.from, + implyingFacts.from, impliedEdgeKind, - impliedEndpoints.from, + impliedFacts.from, registry, ); assertEndpointCompatible( "to", implyingEdgeKind, - implyingEndpoints.to, + implyingFacts.to, impliedEdgeKind, - impliedEndpoints.to, + impliedFacts.to, registry, ); - if ( - implyingEndpoints.pairs !== undefined && - impliedEndpoints.pairs !== undefined - ) { - assertPairsCompatible( - implyingEdgeKind, - implyingEndpoints.pairs, - impliedEdgeKind, - impliedEndpoints.pairs, - registry, - ); - } + assertPairsCompatible( + implyingEdgeKind, + implyingFacts.pairs, + impliedEdgeKind, + impliedFacts.pairs, + registry, + ); } } } diff --git a/packages/typegraph/src/registry/validate-inverse.ts b/packages/typegraph/src/registry/validate-inverse.ts index ccec94f61..4ea23c37c 100644 --- a/packages/typegraph/src/registry/validate-inverse.ts +++ b/packages/typegraph/src/registry/validate-inverse.ts @@ -1,19 +1,19 @@ import { ConfigurationError } from "../errors"; import { META_EDGE_INVERSE_OF } from "../ontology/constants"; import { type NamedOntologyRelation } from "../ontology/validation"; +import { type EdgeKindFacts } from "./edge-kind-facts"; import { type KindRegistry } from "./kind-registry"; -import { type EdgeEndpointKinds } from "./validate-implies"; /** Validates the endpoint reversal required by every registered inverse pair. */ export function validateInverseEndpointCompatibility( ontology: readonly NamedOntologyRelation[], - edgeEndpoints: ReadonlyMap, + edgeFacts: ReadonlyMap, registry: KindRegistry, ): void { for (const relation of ontology) { if (relation.metaEdge !== META_EDGE_INVERSE_OF) continue; - const left = edgeEndpoints.get(relation.from); - const right = edgeEndpoints.get(relation.to); + const left = edgeFacts.get(relation.from); + const right = edgeFacts.get(relation.to); if (left === undefined || right === undefined) continue; assertInverseSideCompatible( @@ -55,23 +55,21 @@ export function validateInverseEndpointCompatibility( ); } - if (left.pairs !== undefined && right.pairs !== undefined) { + assertInversePairsCompatible( + relation.from, + left.pairs, + relation.to, + right.pairs, + registry, + ); + if (relation.from !== relation.to) { assertInversePairsCompatible( - relation.from, - left.pairs, relation.to, right.pairs, + relation.from, + left.pairs, registry, ); - if (relation.from !== relation.to) { - assertInversePairsCompatible( - relation.to, - right.pairs, - relation.from, - left.pairs, - registry, - ); - } } } } diff --git a/packages/typegraph/src/registry/validate-structural-subsumption.ts b/packages/typegraph/src/registry/validate-structural-subsumption.ts new file mode 100644 index 000000000..af0816d30 --- /dev/null +++ b/packages/typegraph/src/registry/validate-structural-subsumption.ts @@ -0,0 +1,176 @@ +/** + * The registry-build-time structural subsumption check. + * + * The compile-time filter in `src/ontology/core-meta-edges.ts` is a compile-time FILTER: it + * catches every incompatibility TypeScript's structural assignability over + * `z.infer` can see, but a value-level constraint (`z.string().min(3)` + * tightening a bare `z.string()`) is invisible to it. This module is the + * AUTHORITATIVE check — it runs against the projected JSON Schema, which + * does carry those constraints, for every `subClassOf` and `equivalentTo` + * pair the registry's transitive closure can produce, regardless of which of + * the three authoring routes (compile-time `GraphDef`, `evolve()`-authored + * extension, or a deserialized persisted document) produced it. + * + * For a live graph the projection compared is `structuralSubtypeProjection` + * (`src/schema/structural-subtype-projection.ts`), which also marks the Zod + * constraints the persisted projection drops (regex flags, refinements, + * strict objects, constraint-shaped `.meta()` keys), so a pair carrying one + * is `incomparable` rather than judged on a projection that lost it. A + * persisted document has no Zod schema and is compared on its projection + * alone; the live check, which every commit path, `createStore` and + * `getSchemaChanges` build, is the stricter of the two. + * + * **The pair set is `subClassAncestors`, not the declared relations.** The + * registry folds every `equivalentTo` class into `subClassAncestors` / + * `subClassDescendants` before the transitive closure + * (`src/registry/kind-registry.ts`), so walking `subClassAncestors` alone + * already covers: + * + * - every declared `subClassOf(child, parent)` (`parent ∈ + * ancestors(child)`); + * - every `equivalentTo(A, B)` in BOTH directions (the fold is symmetric); + * - every TRANSITIVE consequence of either. + * + * This is exactly the set `isAssignableTo` answers `true` for, and + * `isAssignableTo` is what the write path, edge endpoints, + * `expandSubClasses`, the `expansion` option, and the `kindWithSubClasses` + * claim axis all consume — binding the guarantee to the CLOSURE rather than + * to the declaration syntax is what makes it load-bearing: + * `isAssignableTo(A, B)` must imply every A row satisfies B's schema. + * + * Iteration is in `compareCodePoints` order over kinds and over each kind's + * ancestors, so the first violation `buildValidatedKindRegistry` reports is + * stable across runs and engines. + * + * A pair where either side has no projected property schema — an + * unregistered parent kind (a supported pattern; see the doc example at + * `src/registry/builders.ts`) or an external IRI — is skipped: subsumption + * does not apply to a kind this registry does not know the shape of. + * + * **A Zod construct `z.toJSONSchema` cannot convert** (`z.set()`, `z.map()`, + * `z.date()`, `z.bigint()`, a `.transform()`, `embedding()` and any other + * `z.custom()`) is judged per node in a live graph: the comparison + * projection marks that node alone, and `isStructuralSubtype` accepts it + * only under a parent field that admits every value or beside a node of the + * same identity (the same Zod instance, or plain `z.date()`s), while every + * other property of the pair is still compared. An `embedding()` is compared + * as the fixed-length number array it validates; its index options do not + * affect subsumption. A + * persisted document has no such marker. Its kind holding the construct + * projects as the whole-kind placeholder (`isUnprojectableSchemaProjection`, + * `src/schema/serializer.ts`), so a pair with that kind on either side is + * skipped here. The live projection keeps the placeholder too when it cannot + * do better (see `structuralSubtypeProjection`). The placeholder is never + * compared as a schema: read as one it is an open object declaring no + * properties, which would refuse a genuine subtype as missing a property it + * declares. Every commit path also builds the live registry from the code + * graph before it writes (`prepareNewSchemaVersion`), as do `createStore` and + * the pre-flight (`getSchemaChanges`), so the document's coarser check never + * decides alone. What neither check sees inside an + * unconvertible node is left to the compile-time check. + * + * `evolve()` re-declaring an existing kind needs no special path: the + * registry is rebuilt from the merged graph and this module walks the WHOLE + * closure every time, so a redeclared kind is re-checked against its + * parents, its children, and its equivalents in one pass. Do not add a + * second, narrower re-check for the `evolve()` case. + */ +import { isUnprojectableSchemaProjection } from "../schema/serializer"; +import { + isStructuralSubtype, + type StructuralIncomparableReason, + type StructuralSubtypeReason, +} from "../schema/structural-subtype"; +import { type JsonSchema } from "../schema/types"; +import { compareCodePoints } from "../utils/compare"; +import { type KindRegistry } from "./kind-registry"; + +// ============================================================ +// Public Types +// ============================================================ + +/** One `subClassOf`/`equivalentTo` pair whose child does not structurally + * extend its parent — either verdict `isStructuralSubtype` can return that + * is not `"subtype"`. */ +export type StructuralSubsumptionViolation = Readonly<{ + childKind: string; + parentKind: string; + /** Whether this pair is (also) held equivalent, for the caller's error-code choice. */ + viaEquivalence: boolean; + /** A directly declared relation, vs a transitive consequence of one or more declared relations. */ + declared: boolean; + verdict: "not-subtype" | "incomparable"; + reason: StructuralSubtypeReason | StructuralIncomparableReason; + path: readonly string[]; +}>; + +/** + * Whether `first, second` (in that order) is a directly DECLARED relation — + * a `subClassOf(first, second)`, or an `equivalentTo`/`sameAs` pair in + * either order (equivalence is inherently bidirectional; both directions are + * "declared", never transitive-only). + */ +export type DeclaredSubsumptionPair = ( + first: string, + second: string, +) => boolean; + +// ============================================================ +// The check +// ============================================================ + +/** + * Every `(child, parent)` pair in `closures.subClassAncestors`'s transitive + * closure whose projected schemas are NOT a structural subtype, in stable + * `compareCodePoints` order. Empty when every pair the closure produces is + * either a genuine subtype, unregistered, or unprojectable on either side + * (both skipped). + */ +export function findStructuralSubsumptionViolations( + closures: Pick, + equivalence: (a: string, b: string) => boolean, + propertySchemaOf: (kind: string) => JsonSchema | undefined, + declared: DeclaredSubsumptionPair, +): readonly StructuralSubsumptionViolation[] { + const violations: StructuralSubsumptionViolation[] = []; + const childKinds = [...closures.subClassAncestors.keys()].toSorted( + (left, right) => compareCodePoints(left, right), + ); + + for (const childKind of childKinds) { + const ancestors = closures.subClassAncestors.get(childKind) ?? new Set(); + const parentKinds = [...ancestors].toSorted((left, right) => + compareCodePoints(left, right), + ); + if (parentKinds.length === 0) continue; + const childSchema = propertySchemaOf(childKind); + + for (const parentKind of parentKinds) { + const parentSchema = propertySchemaOf(parentKind); + // Unregistered kind (external IRI, or a parent this document only + // references) — subsumption does not apply; skip, do not throw. + if (childSchema === undefined || parentSchema === undefined) continue; + if ( + isUnprojectableSchemaProjection(childSchema) || + isUnprojectableSchemaProjection(parentSchema) + ) { + continue; + } + + const result = isStructuralSubtype(childSchema, parentSchema); + if (result.verdict === "subtype") continue; + + violations.push({ + childKind, + parentKind, + viaEquivalence: equivalence(childKind, parentKind), + declared: declared(childKind, parentKind), + verdict: result.verdict, + reason: result.reason, + path: result.path, + }); + } + } + + return violations; +} diff --git a/packages/typegraph/src/schema/canonical.ts b/packages/typegraph/src/schema/canonical.ts index 17c04cc22..4b8c71e62 100644 --- a/packages/typegraph/src/schema/canonical.ts +++ b/packages/typegraph/src/schema/canonical.ts @@ -38,6 +38,14 @@ export function sortedReplacer(_key: string, value: unknown): unknown { return value; } +/** + * `value` as JSON with sibling keys sorted at every depth + * (`JSON.stringify(value, sortedReplacer)`). + */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(value, sortedReplacer); +} + /** * Compare two values for canonical-form equality. * @@ -50,7 +58,5 @@ export function sortedReplacer(_key: string, value: unknown): unknown { * may or may not be the desired semantics depending on context. */ export function canonicalEqual(a: unknown, b: unknown): boolean { - return ( - JSON.stringify(a, sortedReplacer) === JSON.stringify(b, sortedReplacer) - ); + return canonicalJson(a) === canonicalJson(b); } diff --git a/packages/typegraph/src/schema/deserializer.ts b/packages/typegraph/src/schema/deserializer.ts index 8f1ab5fb6..5b261da71 100644 --- a/packages/typegraph/src/schema/deserializer.ts +++ b/packages/typegraph/src/schema/deserializer.ts @@ -7,9 +7,13 @@ */ import { type AnyEdgeType, type NodeType } from "../core/types"; import { type NamedOntologyRelation } from "../ontology/validation"; -import { buildValidatedKindRegistry } from "../registry/build-validated"; +import { + buildValidatedKindRegistry, + type StructuralSubsumptionMode, +} from "../registry/build-validated"; +import { compositionRelationFields } from "../registry/composition-relation"; +import { buildSerializedEdgeKindFacts } from "../registry/edge-kind-facts"; import type { KindRegistry } from "../registry/kind-registry"; -import { type EdgeEndpointKinds } from "../registry/validate-implies"; import { hasOwnKey } from "../utils/object"; import { type SerializedClosures, @@ -95,7 +99,7 @@ export function deserializeSchema( version: schema.version, generatedAt: schema.generatedAt, - // Own-key reads, for the same reason {@link buildEdgeEndpointKinds} below + // Own-key reads, for the same reason `buildSerializedEdgeKindFacts` (`src/registry/edge-kind-facts.ts`) // uses a `Map`: `schema` is parsed out of a stored JSON document and the // caller supplies the name, so a raw read hands back an `Object.prototype` // member typed as a definition for any name no kind is registered under. @@ -120,7 +124,7 @@ export function deserializeSchema( getIdentity: () => schema.identity, getRaw: () => schema, - buildRegistry: () => buildRegistryFromRelations(schema), + buildRegistry: () => buildRegistryFromSerializedSchema(schema), }; } @@ -129,12 +133,25 @@ export function deserializeSchema( // ============================================================ /** - * Builds a KindRegistry from serialized relations. + * Builds a KindRegistry from a serialized schema's ontology, nodes, edges, + * and identity slices. * * Persisted closures are a legacy inspection artifact. Relations are validated * and closures are recomputed so old schemas gain current hardening rules. + * + * `nodes` registers no node kinds (node kinds are discovered through the + * ontology relations and edge endpoints, and no Zod schema can be + * reconstructed from JSON Schema): it only classifies names and supplies the + * property schemas the structural-subsumption check compares. + * + * The one construction for a persisted ontology, so the deserializer's + * registry and the ontology-tightening classifier's registry + * (`src/schema/ontology-change.ts`) cannot interpret one document two ways. */ -function buildRegistryFromRelations(schema: SerializedSchema): KindRegistry { +export function buildRegistryFromSerializedSchema( + schema: Pick, + structuralSubsumption: StructuralSubsumptionMode = "enforce", +): KindRegistry { // Build empty node/edge kind maps (we don't have the actual Zod schemas) const nodeKinds = new Map(); const edgeKinds = new Map(); @@ -146,46 +163,29 @@ function buildRegistryFromRelations(schema: SerializedSchema): KindRegistry { metaEdge: relation.metaEdge, from: relation.from, to: relation.to, + ...compositionRelationFields(relation), }), ), - edgeEndpoints: buildEdgeEndpointKinds(schema.edges), + edgeFacts: buildSerializedEdgeKindFacts(schema.edges), + // The registry above is built with EMPTY node/edge kind maps (no Zod + // schemas survive serialization), so the equivalence-class check needs + // its own classifier built from the document's own `nodes`/`edges` + // records, or every name would classify as neither and the check would + // silently pass a persisted schema `buildValidatedKindRegistry` would + // otherwise refuse. + kindClassification: { + isNodeKind: (name) => hasOwnKey(schema.nodes, name), + isEdgeKind: (name) => hasOwnKey(schema.edges, name), + }, + // The persisted document's own projected property schema per node kind + // — the structural-subsumption check's schema source for a + // deserialized ontology (`src/registry/validate-structural-subsumption.ts`). + nodePropertySchemas: (kind) => { + const nodeDef = + hasOwnKey(schema.nodes, kind) ? schema.nodes[kind] : undefined; + return nodeDef?.properties; + }, + structuralSubsumption, ...(schema.identity === undefined ? {} : { identity: schema.identity }), }); } - -/** - * Maps each edge kind's serialized definition to its domain/range kind - * names, for `validateImpliesEndpointCompatibility`. A `Map` (rather than - * the plain `schema.edges` object) so a lookup for an edge kind literally - * named "toString" or another `Object.prototype` member can't resolve to - * an inherited member instead of `undefined`. - */ -function buildEdgeEndpointKinds( - edges: Record, -): ReadonlyMap { - const result = new Map(); - for (const [kind, def] of Object.entries(edges)) { - const pairs: { from: string; to: string }[] = []; - if (def.targetKindsBySource === undefined) { - for (const fromKind of def.fromKinds) { - for (const toKind of def.toKinds) { - pairs.push({ from: fromKind, to: toKind }); - } - } - } else { - for (const [sourceKind, targetKinds] of Object.entries( - def.targetKindsBySource, - )) { - for (const targetKind of targetKinds) { - pairs.push({ from: sourceKind, to: targetKind }); - } - } - } - result.set(kind, { - from: def.fromKinds, - to: def.toKinds, - pairs, - }); - } - return result; -} diff --git a/packages/typegraph/src/schema/edge-cardinality-change.ts b/packages/typegraph/src/schema/edge-cardinality-change.ts new file mode 100644 index 000000000..1471a74d4 --- /dev/null +++ b/packages/typegraph/src/schema/edge-cardinality-change.ts @@ -0,0 +1,82 @@ +/** + * The edge-cardinality half of schema-tightening classification: which axes a + * proposed schema newly constrains, i.e. those whose existing rows a commit + * owes a data check. + * + * Deliberately its own module, parallel to `ontology-change.ts` rather than + * folded into it: an ontology change is a statement about RELATIONS between + * kinds, while a cardinality change is a per-edge-kind declaration with no + * relation vocabulary of its own — folding it into `OntologyChange` would + * force every ontology-only consumer (`classifyOntologyChanges`, + * `computeSchemaDiff`'s ontology bucket) to grow a case they cannot act on. + */ +import type { EdgeCardinalityDeclaration } from "../backend/types"; +import { effectiveTargetCardinality } from "../core/edge-integrity-options"; +import { type Cardinality } from "../core/types"; +import { edgeCardinalityAxisReferences } from "../store/claims/edge-claims"; +import type { OntologySnapshot } from "./ontology-change"; + +/** + * Whether a row population that satisfies `stored` on an axis direction + * already satisfies `proposed`, so a commit moving the axis between them owes + * no data check. + * + * `one` counts a row for its whole life and `oneActive` only while its + * `validTo` is open, so at most one row of all implies at most one open row: + * `one` → `oneActive` only stops counting ended rows. The reverse is a + * tightening (an ended row and an open one from the same endpoint satisfy + * `oneActive` and violate `one`), and every other transition is probed. + */ +function cardinalityLoosens( + stored: Cardinality, + proposed: Cardinality, +): boolean { + return stored === "one" && proposed === "oneActive"; +} + +/** + * THE axes a schema commit newly constrains, i.e. those whose existing rows + * this commit owes a data check. + * + * An axis is probed whenever the proposed value is a constrained one, differs + * from the stored value, and is not a proven loosening of it + * ({@link cardinalityLoosens}). Deliberately coarser than a full implication + * lattice over the cardinalities: probing a genuine loosening costs one read + * of a population that is already clean, while missing a tightening + * publishes a constraint the data violates. The cheap error is the one this + * takes, except where the loosening is proven. + * + * A stored declaration this proposal does not carry (a brand-new edge kind) + * reads as `"many"` on both axes — the same default an old schema loads as — + * so a newly-declared constrained kind is always probed, even though it can + * have no violating rows yet: the read is cheap and the alternative is a + * special case that could drift. + */ +export function newlyConstrainedEdgeAxes( + before: OntologySnapshot, + after: OntologySnapshot, +): readonly EdgeCardinalityDeclaration[] { + const result: EdgeCardinalityDeclaration[] = []; + for (const [edgeKind, afterDef] of Object.entries(after.edges)) { + const beforeDef = before.edges[edgeKind]; + const declarations = { + cardinality: afterDef.cardinality, + targetCardinality: effectiveTargetCardinality(afterDef.targetCardinality), + }; + for (const ref of edgeCardinalityAxisReferences(declarations)) { + const storedCardinality = + ref.direction === "source" ? + (beforeDef?.cardinality ?? "many") + : effectiveTargetCardinality(beforeDef?.targetCardinality); + if ( + storedCardinality !== ref.cardinality && + !cardinalityLoosens(storedCardinality, ref.cardinality) + ) { + result.push({ ...ref, edgeKind }); + } + } + } + return result; +} + +export { type EdgeCardinalityDeclaration } from "../backend/types"; diff --git a/packages/typegraph/src/schema/evolution-plan.ts b/packages/typegraph/src/schema/evolution-plan.ts index c9f24c699..99ab6c4c0 100644 --- a/packages/typegraph/src/schema/evolution-plan.ts +++ b/packages/typegraph/src/schema/evolution-plan.ts @@ -1,4 +1,5 @@ /** Pure preparation for a schema evolution; database-dependent guards run at apply time. */ +import type { EdgeCardinalityDeclaration } from "../backend/types"; import type { GraphDef } from "../core/define-graph"; import { resolveGraphVectorSlots } from "../core/embedding"; import { classifyModifications } from "../graph-extension/classify"; @@ -9,6 +10,11 @@ import type { VectorSlot } from "../query/dialect/vector-strategy"; import { freezeDeep } from "../utils/object"; import { canonicalEqual } from "./canonical"; import { prepareNewSchemaVersion } from "./new-schema-version"; +import type { OntologyChange } from "./ontology-change"; +import { + prepareStoreVerbSchemaTighteningPreflight, + type SchemaTighteningPreflight, +} from "./tightening-preflight"; import type { SchemaHash, SchemaIdentity, SerializedSchema } from "./types"; /** A planned schema delta, database-dependent check, or provisioning requirement. */ @@ -29,6 +35,16 @@ export type EvolutionRequirement = nodeKind: string; fieldPath: string; }> + | Readonly<{ + /** + * Existing rows must satisfy an ontology axiom or edge cardinality this + * delta newly declares; apply refuses with the same `MigrationError` + * `Store.evolve()` raises when they do not. + */ + kind: "ontology-tightening"; + changes: readonly OntologyChange[]; + edgeCardinalityAxes: readonly EdgeCardinalityDeclaration[]; + }> | Readonly<{ kind: "identity"; nodeKinds: readonly string[]; @@ -49,6 +65,8 @@ export type EvolutionPlanRequirements = Readonly<{ { kind: "vector-slot" } >[]; identityAffectedKinds: readonly string[]; + /** The data preflight the delta's ontology or edge-cardinality tightening owes. */ + schemaTightening: SchemaTighteningPreflight | undefined; }>; declare const evolutionPlanBrand: unique symbol; @@ -226,6 +244,16 @@ export async function prepareEvolutionPlan( const schemaDocument = freezeDeep(prepared.schemaDocument); const resultingHash = prepared.schemaHash; const addedKinds = newlyAddedKinds(existingExtension, extension); + // Derived from the exact before/after documents this plan commits, the + // same pair `Store.evolve()` hands the same preflight. + const schemaTightening = prepareStoreVerbSchemaTighteningPreflight({ + verb: "store.planEvolution()", + graphId: baselineGraph.id, + fromVersion: baselineVersion, + toVersion: resultingVersion, + before: storedSchema, + after: schemaDocument, + }); const groupedRequirements: EvolutionPlanRequirements = Object.freeze({ requireEmpty: Object.freeze( classification.requireEmpty.map((entry) => @@ -241,11 +269,47 @@ export async function prepareEvolutionPlan( identityAffectedKinds: Object.freeze( identityKindsRequiringPreflight(baselineGraph, mergedGraph), ), + schemaTightening, }); + // A uniqueness claim gate is an emptiness requirement on its kind, so it is + // listed as one. A kind the extension classifier already requires empty is + // not listed twice, and a kind this extension adds is already listed as new: + // its gate only guards rows an earlier removal left in the table, which the + // commit's own probe refuses. + const classifiedEmptyNodeKinds = new Set( + [...groupedRequirements.requireEmpty, ...groupedRequirements.addedKinds] + .filter((requirement) => requirement.entity === "node") + .map((requirement) => requirement.kindName), + ); + const uniquenessGatedKinds = [ + ...new Set( + (schemaTightening?.uniquenessClaimGates ?? []).map((gate) => gate.kind), + ), + ].filter((kindName) => !classifiedEmptyNodeKinds.has(kindName)); + const tightensOntologyOrCardinality = + schemaTightening !== undefined && + (schemaTightening.probedChanges.length > 0 || + schemaTightening.newlyConstrainedAxes.length > 0); const requirements: EvolutionRequirements = Object.freeze([ ...groupedRequirements.requireEmpty, + ...uniquenessGatedKinds.map((kindName) => + Object.freeze({ + kind: "require-empty" as const, + entity: "node" as const, + kindName, + }), + ), ...groupedRequirements.addedKinds, ...groupedRequirements.vectorSlots, + ...(tightensOntologyOrCardinality ? + [ + freezeDeep({ + kind: "ontology-tightening" as const, + changes: schemaTightening.probedChanges, + edgeCardinalityAxes: schemaTightening.newlyConstrainedAxes, + }), + ] + : []), ...(groupedRequirements.identityAffectedKinds.length > 0 ? [ Object.freeze({ diff --git a/packages/typegraph/src/schema/graph-templates.ts b/packages/typegraph/src/schema/graph-templates.ts index e2017140e..409b2bd32 100644 --- a/packages/typegraph/src/schema/graph-templates.ts +++ b/packages/typegraph/src/schema/graph-templates.ts @@ -3,13 +3,24 @@ import { type GraphDef } from "../core/define-graph"; import { resolveGraphVectorSlots } from "../core/embedding"; import { ConfigurationError } from "../errors"; import { type ReconciledSchema } from "../store/store"; +import { storedSchemaHashMatches } from "./manager"; import { computeSchemaHash, serializeSchema } from "./serializer"; /** A locally held handle for a durable server-side graph template. */ export type GraphTemplate = Readonly<{ id: string; reconciled: ReconciledSchema; + /** Content hash of the template's schema document, in this release's serialization. */ schemaHash: string; + /** + * The hash the template registry keys this template under, which + * instantiation binds. It equals `schemaHash` unless an earlier release + * registered the template: the registry keeps the hash that release + * computed, and the stored document is judged equal to `schemaHash` + * through `activeSchemaHash` rather than rewritten. Absent means + * `schemaHash`. + */ + registryHash?: string; }>; export type InstantiateGraphTemplateResult = Readonly<{ @@ -61,15 +72,25 @@ export async function registerGraphTemplate( { code: "GRAPH_TEMPLATE_RECONCILED_HASH_MISMATCH" }, ); } - await register({ + const stored = await register({ templateId: params.templateId, schemaHash, schemaDoc: schemaDocument, }); + if (!(await storedSchemaHashMatches(stored, schemaHash))) { + throw new ConfigurationError( + `Graph template "${params.templateId}" already exists with different schema content.`, + { + code: "GRAPH_TEMPLATE_CONTENT_CONFLICT", + templateId: params.templateId, + }, + ); + } return Object.freeze({ id: params.templateId, reconciled, schemaHash, + registryHash: stored.schema_hash, }); } @@ -104,12 +125,28 @@ export async function instantiateGraphTemplate( id: params.graphId, }); const targetHash = await computeSchemaHash(serializeSchema(targetGraph, 1)); - const result = await instantiate({ - templateId: params.template.id, - templateSchemaHash: params.template.schemaHash, - graphId: params.graphId, - schemaHash: targetHash, - }); + const instantiateUnder = (schemaHash: string) => + instantiate({ + templateId: params.template.id, + templateSchemaHash: + params.template.registryHash ?? params.template.schemaHash, + graphId: params.graphId, + schemaHash, + }); + const firstResult = await instantiateUnder(targetHash); + // An idempotent retry against a target an earlier release instantiated is + // refused by the hash key alone: that target's v1 row carries the hash the + // earlier release computed. Judge its document, and retry under its own + // stored key when it holds this target's schema. + const earlierReleaseTargetHash = + firstResult.status === "refused" ? + await earlierReleaseTargetSchemaHash(backend, params.graphId, targetHash) + : undefined; + const boundTargetHash = earlierReleaseTargetHash ?? targetHash; + const result = + earlierReleaseTargetHash === undefined ? firstResult : ( + await instantiateUnder(earlierReleaseTargetHash) + ); if (result.status === "refused") { throw new ConfigurationError( `Graph template instantiation was refused for graph "${params.graphId}". The template may be absent, or the graph already has a different schema.`, @@ -123,7 +160,7 @@ export async function instantiateGraphTemplate( if ( result.row.graph_id !== params.graphId || result.row.version !== 1 || - result.row.schema_hash !== targetHash + result.row.schema_hash !== boundTargetHash ) { throw new ConfigurationError( "Graph template backend returned a schema row that does not match the requested target.", @@ -135,12 +172,32 @@ export async function instantiateGraphTemplate( reconciled: Object.freeze({ graph: targetGraph, version: result.row.version, - hash: result.row.schema_hash, + hash: targetHash, }), schema: result.row, }); } +/** + * The stored hash column of the target's active v1 row when an earlier + * release wrote it for exactly this target schema, otherwise `undefined`. + */ +async function earlierReleaseTargetSchemaHash( + backend: GraphBackend, + graphId: string, + targetHash: string, +): Promise { + const active = await backend.getActiveSchema(graphId); + if ( + active?.version !== 1 || + active.schema_hash === targetHash || + !(await storedSchemaHashMatches(active, targetHash)) + ) { + return undefined; + } + return active.schema_hash; +} + function graphTemplateHandleMismatchError( templateId: string, ): ConfigurationError { diff --git a/packages/typegraph/src/schema/index.ts b/packages/typegraph/src/schema/index.ts index c914fb64f..dd1425348 100644 --- a/packages/typegraph/src/schema/index.ts +++ b/packages/typegraph/src/schema/index.ts @@ -99,6 +99,34 @@ export { type SchemaChangeClassification, type SchemaDiff, } from "./migration"; +export type { UniquenessClaimGate } from "./uniqueness-claim-gate"; + +// ============================================================ +// Structural Subtyping +// ============================================================ + +export { + isStructuralSubtype, + isTypeLevelSubtype, + projectTypeVisible, + type StructuralIncomparableReason, + type StructuralSubtypeReason, + type StructuralSubtypeResult, +} from "./structural-subtype"; + +// ============================================================ +// Ontology change classification (data-validated tightening) +// ============================================================ + +export { + classifyOntologyChanges, + type OntologyDataProbe, + type OntologySnapshot, + ontologyTighteningProbes, + PROBE_VIOLATION_FAMILIES, + probeCoversViolationFamily, + type UniquenessComponentProbeGroup, +} from "./ontology-change"; // ============================================================ // Validation Utilities diff --git a/packages/typegraph/src/schema/manager.ts b/packages/typegraph/src/schema/manager.ts index 81d120d6d..fd4303ddd 100644 --- a/packages/typegraph/src/schema/manager.ts +++ b/packages/typegraph/src/schema/manager.ts @@ -8,7 +8,10 @@ * - Error reporting for breaking changes */ import { assertEdgeMatchIdentityBackendSupport } from "../backend/edge-match-identity"; -import { countSchemaKindRows } from "../backend/schema-kind-emptiness"; +import { + countSchemaKindRows, + populatedSchemaKinds, +} from "../backend/schema-kind-emptiness"; import { type CommitSchemaVersionIfKindsEmptyResult, type CommitSchemaVersionParams, @@ -17,6 +20,7 @@ import { type SchemaCommitPreflightBackend, type SchemaKindEmptinessProbe, type SchemaVersionRow, + type SetActiveVersionParams, type TransactionBackend, } from "../backend/types"; import { @@ -39,6 +43,7 @@ import { identityKindCascadeNeeded, identityKindCascadePreflight, identitySchemaCommitPreflight, + resolveSchemaCommitCapture, withIdentityDdlRaceRetry, } from "../identity/schema-transition"; import { @@ -49,6 +54,7 @@ import { import { buildKindRegistry } from "../registry"; import { freezeDeep } from "../utils/object"; import { isMissingTableError } from "../utils/sql-errors"; +import { canonicalEqual } from "./canonical"; import { computeSchemaDiff, getMigrationActions, @@ -58,12 +64,24 @@ import { } from "./migration"; import { prepareNewSchemaVersion } from "./new-schema-version"; import { + canonicalStoredSchemaDocument, computeSchemaHash, getSchemaHash, serializeSchema, serializeSchemaPreservingUnknownFields, } from "./serializer"; -import { type SerializedSchema, serializedSchemaZod } from "./types"; +import { + type AtomicPreflightCapabilityError, + prepareSchemaTighteningPreflight, + type SchemaTighteningPreflight, +} from "./tightening-preflight"; +import { + type SchemaCommitTransition, + type SchemaHash, + type SerializedEdgeDef, + type SerializedSchema, + serializedSchemaZod, +} from "./types"; /** * Bounded LRU cache for `parseSerializedSchema` results, keyed on the @@ -129,6 +147,67 @@ export function parseSerializedSchema(json: string): SerializedSchema { return validated; } +/** + * Recomputed hashes, keyed on the parsed document `parseSerializedSchema` + * caches, so a document is hashed at most once while it stays cached. + */ +const ACTIVE_SCHEMA_HASH_CACHE = new WeakMap(); + +/** + * THE hash of a stored schema version: its document, in the form this release + * serializes it ({@link canonicalStoredSchemaDocument}), hashed by + * `computeSchemaHash`. + * + * Every comparison of a stored version against a graph serialized today, or + * against another stored version, reads this rather than the row's + * `schema_hash` column. The column records the hash the WRITING release + * computed; a release that changes how a document is serialized without + * changing what it states (the meta-edge fields and `equivalentTo` closures + * this release dropped) leaves that column describing a serialization nobody + * produces any more, and a schema the diff calls unchanged would compare + * unequal to its own graph forever. Recomputing on read keeps the stored + * document and the column untouched, so a reader still running an earlier + * release is unaffected. + * + * For a document written by this release the result equals the column. The + * hash is taken over the document as stored (validated by + * {@link parseSerializedSchema}, then read raw), never over the parsed form: + * the parser fills in defaults, and the canonical form is defined over what + * was stored. + * + * @throws DatabaseOperationError when the stored document is not a valid + * schema document. + */ +export async function activeSchemaHash( + row: Pick, +): Promise { + const document = parseSerializedSchema(row.schema_doc); + const cached = ACTIVE_SCHEMA_HASH_CACHE.get(document); + if (cached !== undefined) return cached; + const storedDocument = JSON.parse(row.schema_doc) as SerializedSchema; + const hash = await computeSchemaHash( + canonicalStoredSchemaDocument(storedDocument), + ); + ACTIVE_SCHEMA_HASH_CACHE.set(document, hash); + return hash; +} + +/** + * Whether a stored schema or template row holds the schema `currentHash` + * names. The column is checked first because it is equal whenever this + * release wrote the row; a row an earlier release wrote is judged by its + * document, through {@link activeSchemaHash}. + */ +export async function storedSchemaHashMatches( + row: Pick, + currentHash: SchemaHash, +): Promise { + return ( + row.schema_hash === currentHash || + (await activeSchemaHash(row)) === currentHash + ); +} + // ============================================================ // Helpers // ============================================================ @@ -346,6 +425,57 @@ export type SchemaManagerOptions = Readonly<{ schema?: SqlSchema; }>; +/** + * Extends {@link ensureSchema}'s public options with the pre-fetched loader + * snapshot `createStoreWithSchema` passes through — an established public + * capability of the standalone entry point (see the field's own doc), so it + * stays on the type every `ensureSchema` caller sees. + */ +type EnsureSchemaPreloadedOptions = Readonly<{ + /** + * Pre-fetched active row + parsed stored schema. When the loader + * (`createStoreWithSchema`) has already paid for `getActiveSchema` + * and `parseSerializedSchema` to peek at `extension`, it + * passes the results through here so `ensureSchema` doesn't repeat + * the round trip + Zod walk on every Store boot. + */ + preloaded?: Readonly<{ + activeRow: SchemaVersionRow | undefined; + storedSchema: SerializedSchema | undefined; + }>; +}>; + +/** + * Internal-only extra `ensureSchema` needs from store construction but must + * never accept from a caller of the public entry point: this bag exists + * precisely so `historyEnabled` cannot reach `ensureSchema` through + * `SchemaManagerOptions`, the type its standalone (`initializeSchema` / + * `migrateSchema` / public `ensureSchema`) callers see. Only + * {@link ensureSchemaInternal} — imported directly by `store.ts`, never + * re-exported from the package's public entry points — accepts it. + */ +type EnsureSchemaInternalOptions = EnsureSchemaPreloadedOptions & + Readonly<{ + /** + * The driving Store's capture flag: whether TypeGraph captures recorded + * history for its writes (`history: true` under TypeGraph-owned recorded + * time). Threaded so a schema commit that runs BEFORE any Store exists to + * wrap the backend — every commit `prepareStoreWithSchema` drives, first + * enablement included — binds its identity preflight's ledger touches and + * transition-log notes to a capture session exactly as the Store's own + * writes are. Absent for the standalone `initializeSchema` / + * `migrateSchema` / public `ensureSchema` entry points called outside a + * Store: there, the graph's own recorded relations decide + * ({@link resolveSchemaCommitCapture}'s `"database"` source), so a + * standalone migration of a history database still records its ledger + * pre-images and transition notes. Kept off `SchemaManagerOptions` so a + * caller of the public entry point cannot override that evidence: + * TypeScript's excess-property check rejects it on an object literal, and + * `ensureSchema` below does not forward it. + */ + historyEnabled?: boolean; + }>; + // ============================================================ // Schema Manager // ============================================================ @@ -368,19 +498,22 @@ export type SchemaManagerOptions = Readonly<{ export async function ensureSchema( backend: GraphBackend, graph: G, - options?: SchemaManagerOptions & { - /** - * Pre-fetched active row + parsed stored schema. When the loader - * (`createStoreWithSchema`) has already paid for `getActiveSchema` - * and `parseSerializedSchema` to peek at `extension`, it - * passes the results through here so `ensureSchema` doesn't repeat - * the round trip + Zod walk on every Store boot. - */ - preloaded?: Readonly<{ - activeRow: SchemaVersionRow | undefined; - storedSchema: SerializedSchema | undefined; - }>; - }, + options?: SchemaManagerOptions & EnsureSchemaPreloadedOptions, +): Promise { + return ensureSchemaInternal(backend, graph, options); +} + +/** + * The real implementation behind {@link ensureSchema}, additionally taking + * {@link EnsureSchemaInternalOptions} — the store-construction-only extras a + * caller of the public entry point must never be able to set. Exported for + * `store.ts` to import directly (as `ensureSchemaImpl`); not part of any + * public entry point. + */ +export async function ensureSchemaInternal( + backend: GraphBackend, + graph: G, + options?: SchemaManagerOptions & EnsureSchemaInternalOptions, ): Promise { const autoMigrate = options?.autoMigrate ?? true; const throwOnBreaking = options?.throwOnBreaking ?? true; @@ -411,6 +544,9 @@ export async function ensureSchema( const result = await initializeSchemaImpl(backend, graph, { ...(options?.schema === undefined ? {} : { schema: options.schema }), baseSchemaPrepared: true, + ...(options?.historyEnabled === undefined ? + {} + : { historyEnabled: options.historyEnabled }), }); return { status: "initialized", @@ -424,10 +560,9 @@ export async function ensureSchema( // SHA-256 walk. When the hash matches, we never need the // `currentSchema` or the `storedSchema` (no diff is computed), so // defer those allocations until they're actually needed. - const storedHash = activeSchema.schema_hash; const currentHash = await getSchemaHash(graph, activeSchema.version + 1); - if (storedHash === currentHash) { + if (await storedSchemaHashMatches(activeSchema, currentHash)) { return { status: "unchanged", version: activeSchema.version }; } @@ -461,15 +596,36 @@ export async function ensureSchema( // preflight, whichever public path drove it. It is derived HERE — // never accepted from the caller — so it cannot be substituted or // suppressed; `options.schema` only points it at the effective tables. - const preflight = + const identityPreflight = graph.identity === undefined ? undefined : await prepareIdentitySchemaCommit(backend, graph, { enablement: storedSchema.identity === undefined, + ...(options?.historyEnabled === undefined ? + {} + : { historyEnabled: options.historyEnabled }), ...(options?.schema === undefined ? {} : { schema: options.schema }), }); + // Same reasoning, for the schema tightening probe (ontology and edge + // cardinality): derived here from the actual before/after documents + // this commit is about to publish, never accepted from the caller. + const schemaTighteningPreflight = prepareSchemaTighteningPreflight({ + graphId: graph.id, + fromVersion: activeSchema.version, + toVersion: activeSchema.version + 1, + before: storedSchema, + after: currentSchema, + // `diff` above already classified this exact before/after pair. + changes: diff.ontology, + }); + const preflight = planSchemaCommitPreflight({ + structural: undefined, + edgeMatchIdentity: undefined, + tightening: schemaTighteningPreflight, + identity: identityPreflight, + }); const committedRow = preflight === undefined ? await commitNewSchemaVersion( @@ -559,7 +715,13 @@ export async function ensureSchema( * the version required by the backend. * @throws ConfigurationError if no schema has been initialized for * `graph.id` (the privileged migration step has not run, or the base - * tables do not exist on this connection). + * tables do not exist on this connection); also if the stored or the + * current ontology adds or removes a relation and cannot be interpreted + * (`computeSchemaDiff` → `classifyOntologyChanges` — a schema document + * written under an older, laxer validator can hold an ontology today's + * hardening rejects). Callers built on this — `createVerifiedStore` and + * `assertSchemaCurrent` — inherit this throw and do not distinguish it + * from the uninitialized-schema case above. * @throws MigrationError if the persisted schema is behind the code * graph — for **any** pending change, safe or breaking. The * least-privilege runtime cannot migrate; "behind" means the @@ -585,9 +747,8 @@ export async function loadAndVerifyGraph( // Hash short-circuit avoids the serialize + diff walk on the steady- // state warm path. A semantic no-op (hash differs but `hasChanges` is // false) takes the same return path. - const storedHash = activeRow.schema_hash; const currentHash = await getSchemaHash(merged, activeRow.version + 1); - if (storedHash !== currentHash) { + if (!(await storedSchemaHashMatches(activeRow, currentHash))) { const currentSchema = serializeSchemaPreservingUnknownFields( merged, activeRow.version + 1, @@ -739,28 +900,184 @@ function schemaNotInitializedError( ); } +/** + * What a caller of `commitNewSchemaVersionWithPreflight` refuses with when + * the backend cannot commit a preflight atomically. Reusing IDENTITY's code + * for a commit identity takes no part in would misdirect an operator on a + * graph with identity disabled, so the primitive takes the bag + * {@link planSchemaCommitPreflight} picks rather than hardcoding one message. + * The tightening-specific bags live in `./tightening-preflight`, beside the + * decision that picks between them. + */ +const IDENTITY_ATOMIC_PREFLIGHT_CAPABILITY_ERROR: AtomicPreflightCapabilityError = + { + code: "IDENTITY_REQUIRES_ATOMIC_BACKEND", + message: + "This backend cannot atomically commit identity data with a schema transition.", + }; + +/** + * Thrown when a commit that activates or changes an edge kind's match + * identity needs the atomic preflight-commit primitive: the emptiness probe + * that lets the new durable key be declared must run in the transaction that + * publishes it. + */ +const EDGE_MATCH_IDENTITY_ATOMIC_PREFLIGHT_CAPABILITY_ERROR: AtomicPreflightCapabilityError = + { + code: "EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND", + message: + "This backend cannot atomically verify that an edge kind is empty while activating or changing its match identity as part of a schema transition.", + }; + /** * Returns the backend's atomic preflight-commit primitive, throwing - * `IDENTITY_REQUIRES_ATOMIC_BACKEND` if the backend doesn't support - * committing identity data atomically with a schema transition. + * `capabilityError` if the backend doesn't support committing a preflight + * atomically with a schema transition. */ function requireCommitWithPreflight( backend: GraphBackend, graph: GraphDef, + capabilityError: AtomicPreflightCapabilityError, ): NonNullable { const commitWithPreflight = backend.commitSchemaVersionWithPreflight; if (commitWithPreflight === undefined) { - throw new ConfigurationError( - "This backend cannot atomically commit identity data with a schema transition.", - { - code: "IDENTITY_REQUIRES_ATOMIC_BACKEND", - graphId: graph.id, - }, - ); + throw atomicPreflightUnsupportedError(graph.id, capabilityError); } return commitWithPreflight; } +/** The refusal for a backend that cannot run a schema preflight atomically. */ +function atomicPreflightUnsupportedError( + graphId: string, + capabilityError: AtomicPreflightCapabilityError, +): ConfigurationError { + return new ConfigurationError( + capabilityError.message, + { code: capabilityError.code, graphId, ...capabilityError.details }, + capabilityError.suggestion === undefined ? + undefined + : { suggestion: capabilityError.suggestion }, + ); +} + +/** One step of a composed schema-commit preflight; `undefined` drops out. */ +type SchemaCommitPreflightStep = + ((target: SchemaCommitPreflightBackend) => Promise) | undefined; + +type SchemaCommitPreflightFunction = ( + target: SchemaCommitPreflightBackend, +) => Promise; + +/** + * The edge match-identity re-key step: the emptiness probe of the edge kinds + * whose durable key the commit activates or changes, and the refusal it owes + * a backend that cannot run it atomically, naming those kinds. + */ +type EdgeMatchIdentityPreflight = Readonly<{ + run: SchemaCommitPreflightFunction; + capabilityError: AtomicPreflightCapabilityError; +}>; + +/** + * The steps a schema-commit preflight is composed from, by role. Every key is + * required — a path with nothing to run for a role passes `undefined` — so a + * commit path cannot drop a role by forgetting it, and never spells the order + * the roles run in. + */ +export type SchemaCommitPreflightSteps = Readonly<{ + /** Gates deciding whether the commit is legal at all (dropped/required kinds empty). */ + structural: SchemaCommitPreflightStep; + edgeMatchIdentity: EdgeMatchIdentityPreflight | undefined; + tightening: SchemaTighteningPreflight | undefined; + identity: SchemaCommitPreflightStep; +}>; + +/** + * The preflight a schema commit runs inside the backend's atomic + * preflight-commit primitive, with the refusal it owes a backend that lacks + * that primitive. + */ +export type SchemaCommitPreflightPlan = Readonly<{ + run: SchemaCommitPreflightFunction; + capabilityError: AtomicPreflightCapabilityError; +}>; + +/** + * THE decision whether a schema commit needs the backend's atomic + * preflight-commit primitive, the preflight it runs there, and the refusal it + * owes a backend that lacks the primitive — `undefined` when no step needs it. + * + * A `structural` gate alone never needs it: dropped-kinds and + * required-kinds-empty probes commit through + * `commitSchemaVersionIfKindsEmpty`. Once another step is present the + * structural gate runs inside the same preflight, ahead of it. + * + * The refusal names the step that cannot be split from the commit. Identity's + * wins whenever identity contributed a step; then the tightening's, which + * names the axis it is about (see + * {@link SchemaTighteningPreflight.capabilityError}); then the edge match + * identity re-key's. One owner, because every commit path that relies on the + * primitive (`ensureSchema`'s auto-migrate branch, `migrateSchema`, + * `Store.evolve`, `Store.removeKinds`, `rollbackSchema`) owes the same + * decision, and a copy that drifts would blame the wrong subsystem in an + * operator-facing refusal. `withEvolvedTransaction` runs its preflight inside + * the caller's adopted transaction and owes no such error. + */ +export function planSchemaCommitPreflight( + steps: SchemaCommitPreflightSteps, +): SchemaCommitPreflightPlan | undefined { + const capabilityError = + steps.identity === undefined ? + steps.tightening === undefined ? + steps.edgeMatchIdentity === undefined ? + undefined + : steps.edgeMatchIdentity.capabilityError + : steps.tightening.capabilityError + : IDENTITY_ATOMIC_PREFLIGHT_CAPABILITY_ERROR; + if (capabilityError === undefined) return undefined; + return { run: composeSchemaCommitPreflight(steps), capabilityError }; +} + +/** + * THE order a schema-commit preflight runs its steps in, and the one place + * that order is spelled — called by every commit path that publishes a + * changed schema document (`ensureSchema`'s auto-migrate branch, + * `migrateSchema`, `Store.evolve`, `Store.withEvolvedTransaction`): structural + * gates that decide whether the commit is legal at all (dropped-kinds / + * required-kinds-empty), then `edgeMatchIdentityPreflight`, then the + * ontology-tightening preflight, then the identity preflight (whose last act + * is the closure rebuild). + * + * Ontology precedes identity because the identity closure is DERIVED from + * the ontology being committed (`identitySchemaCommitPreflight` rebuilds it + * from the target registry), so rebuilding it under an ontology the data + * falsifies is work a refusal would only throw away. + * + * `undefined` steps drop out. Whether a commit needs the atomic primitive at + * all is {@link planSchemaCommitPreflight}'s decision, which composes its + * preflight here; `withEvolvedTransaction`, already inside the caller's + * transaction, composes directly. + * + * @internal Not re-exported through `src/schema/index.ts` or the package + * root, the same convention `commitNewSchemaVersionWithPreflight` uses. + */ +export function composeSchemaCommitPreflight( + steps: SchemaCommitPreflightSteps, +): SchemaCommitPreflightFunction { + const ordered: readonly SchemaCommitPreflightStep[] = [ + steps.structural, + steps.edgeMatchIdentity?.run, + steps.tightening?.run, + steps.identity, + ]; + const defined = ordered.filter( + (step): step is SchemaCommitPreflightFunction => step !== undefined, + ); + return async (target: SchemaCommitPreflightBackend): Promise => { + for (const step of defined) await step(target); + }; +} + async function commitInitialEdgeIdentityOnEmptyKinds( backend: GraphBackend, graph: GraphDef, @@ -790,7 +1107,7 @@ async function commitInitialEdgeIdentityOnEmptyKinds( throw edgeMatchIdentityRekeyPopulatedError( graph.id, - 0, + { fromVersion: 0, toVersion: 1 }, result.kinds.map((entry) => entry.kind), ); } @@ -820,6 +1137,8 @@ type InitializeSchemaImplOptions = InitializeSchemaOptions & Readonly<{ /** The caller already completed the deployment-wide adoption gate. */ baseSchemaPrepared: boolean; + /** See {@link EnsureSchemaInternalOptions.historyEnabled}. */ + historyEnabled?: boolean; }>; export async function initializeSchema( @@ -866,7 +1185,7 @@ async function initializeSchemaImpl( const edgeMatchIdentityPreflight = prepareEdgeMatchIdentityCommitPreflight( graph, edgeIdentityKinds, - 0, + { fromVersion: 0, toVersion: 1 }, ); const schema = serializeSchema(graph, 1); @@ -891,10 +1210,14 @@ async function initializeSchemaImpl( commit, edgeIdentityKinds, ) - : commitWithPreflight(commit, edgeMatchIdentityPreflight); + : commitWithPreflight(commit, edgeMatchIdentityPreflight.run); } - const commitWithPreflight = requireCommitWithPreflight(backend, graph); + const commitWithPreflight = requireCommitWithPreflight( + backend, + graph, + IDENTITY_ATOMIC_PREFLIGHT_CAPABILITY_ERROR, + ); // An identity-enabled graph's FIRST schema commit is an enablement: a // legacy database populated through an unmanaged Store can already hold @@ -907,6 +1230,9 @@ async function initializeSchemaImpl( // accepts while identity reads answer from a never-built closure. const preflight = await prepareIdentitySchemaCommit(backend, graph, { enablement: true, + ...(options.historyEnabled === undefined ? + {} + : { historyEnabled: options.historyEnabled }), ...(options.schema === undefined ? {} : { schema: options.schema }), }); // The preflight issues idempotent identity DDL INSIDE this transaction (see @@ -919,7 +1245,7 @@ async function initializeSchemaImpl( // instead of silently succeeding. return withIdentityDdlRaceRetry(() => commitWithPreflight(commit, async (transactionBackend) => { - await edgeMatchIdentityPreflight?.(transactionBackend); + await edgeMatchIdentityPreflight?.run(transactionBackend); await preflight(transactionBackend); }), ); @@ -1069,14 +1395,46 @@ export async function migrateSchema( const edgeMatchIdentityPreflight = prepareEdgeMatchIdentityCommitPreflight( target, rekeyedEdgeKinds, - currentVersion, + { fromVersion: currentVersion, toVersion: currentVersion + 1 }, ); + // No BEFORE document, no ontology to tighten against: a v1 initial commit + // has nothing preceding it (mirrors `initializeSchema`'s exclusion). + const schemaTighteningPreflight = + storedSchema === undefined ? undefined : ( + prepareSchemaTighteningPreflight({ + graphId: target.id, + fromVersion: currentVersion, + toVersion: currentVersion + 1, + before: storedSchema, + after: serializeSchemaPreservingUnknownFields( + target, + currentVersion + 1, + storedSchema, + ), + }) + ); + + // When a preflight is owed, the emptiness fence moves inside the commit + // transaction: the preflight-carrying primitive is the only one that can + // also run the identity rebuild atomically, so the probe runs alongside it + // rather than through `commitSchemaVersionIfKindsEmpty`. Ordering — and the + // "ontology before identity" reasoning — is spelled once, at + // `composeSchemaCommitPreflight`. + const preflight = planSchemaCommitPreflight({ + structural: (transactionBackend) => + assertDroppedKindsEmpty( + transactionBackend, + target.id, + currentVersion, + guardedDrops, + ), + edgeMatchIdentity: edgeMatchIdentityPreflight, + tightening: schemaTighteningPreflight, + identity: identityPreflight, + }); const committed = - ( - identityPreflight === undefined && - edgeMatchIdentityPreflight === undefined - ) ? + preflight === undefined ? guardedDrops.length > 0 ? await commitDroppedKindsOnlyWhenEmpty( backend, @@ -1095,20 +1453,7 @@ export async function migrateSchema( backend, target, currentVersion, - async (transactionBackend) => { - // The emptiness fence moves inside the commit transaction here: the - // preflight-carrying primitive is the only one that can also run the - // identity rebuild atomically, so the probe runs alongside it rather - // than through `commitSchemaVersionIfKindsEmpty`. - await assertDroppedKindsEmpty( - transactionBackend, - target.id, - currentVersion, - guardedDrops, - ); - await edgeMatchIdentityPreflight?.(transactionBackend); - await identityPreflight?.(transactionBackend); - }, + preflight, storedSchema, ); return committed.version; @@ -1121,6 +1466,10 @@ export async function migrateSchema( * Bundled backends use a durable version marker, so a warm privileged open is * one read and no base-adoption DDL. Runtime-only construction remains * DDL-free. + * + * A caller that checks identity storage before `ensureSchema` reaches this + * adoption runs both through + * {@link ensureIdentityStorageOnAdoptedBaseSchema}. */ async function adoptBaseSchemaStorage(backend: GraphBackend): Promise { if (backend.adoptBaseSchema !== undefined) { @@ -1130,6 +1479,28 @@ async function adoptBaseSchemaStorage(backend: GraphBackend): Promise { await backend.ensureEdgeMatchIdentityStorage?.(); } +/** + * THE identity-storage check a Store runs ahead of a schema commit: base + * storage adopted first, then {@link ensureIdentitySchemaStorage}. + * + * The order is the point. That check treats an identity relation missing from + * an already-enabled graph as ledger data loss, and a relation this library + * version added since the database was last opened by a privileged entry + * point is an upgrade, not data loss. Every caller that runs the check before + * `ensureSchema`'s own adoption — opening a store with its schema, and + * `Store.evolve` — reaches it through here, so neither can run the check + * against storage the upgrade has not caught up to. Adoption is idempotent by + * its durable version marker: one read when there is nothing to adopt. + */ +export async function ensureIdentityStorageOnAdoptedBaseSchema( + backend: GraphBackend, + schema: SqlSchema, + options: Parameters[2], +): ReturnType { + await adoptBaseSchemaStorage(backend); + return ensureIdentitySchemaStorage(backend, schema, options); +} + function assertGraphEdgeMatchIdentitySupport( backend: GraphBackend, graph: GraphDef, @@ -1150,51 +1521,78 @@ function edgeKindsRequiringMatchIdentityMaterialization( target: GraphDef, storedSchema?: SerializedSchema, ): readonly string[] { - return getEdgeKinds(target).filter((kind) => { - const after = target.edges[kind]?.matchIdentity; - if (after === undefined) return false; - const before = storedSchema?.edges[kind]?.matchIdentity; - return !matchIdentitiesEqual(before, after); - }); + return matchIdentityRekeyedEdgeKinds( + getEdgeKinds(target).map( + (kind) => [kind, target.edges[kind]?.matchIdentity] as const, + ), + storedSchema, + ); +} + +/** + * The edge kinds whose `after` match identity is declared and differs from + * `before`'s — the kinds whose durable keys a transition must materialize. + */ +function matchIdentityRekeyedEdgeKinds( + after: readonly (readonly [ + kind: string, + matchIdentity: SerializedEdgeDef["matchIdentity"], + ])[], + before: SerializedSchema | undefined, +): readonly string[] { + return after + .filter( + ([kind, matchIdentity]) => + matchIdentity !== undefined && + !matchIdentitiesEqual( + before?.edges[kind]?.matchIdentity, + matchIdentity, + ), + ) + .map(([kind]) => kind); } /** Refuses identity activation/re-keying while rows still lack target keys. */ function prepareEdgeMatchIdentityCommitPreflight( - target: GraphDef, + target: Readonly<{ id: string }>, edgeKinds: readonly string[], - currentVersion: number, -): ((backend: SchemaCommitPreflightBackend) => Promise) | undefined { + transition: Pick, +): EdgeMatchIdentityPreflight | undefined { if (edgeKinds.length === 0) return undefined; - return async (backend): Promise => { - const populated: string[] = []; - for (const kind of edgeKinds) { - const count = await countSchemaKindRows(backend, target.id, { - entity: "edge", - kind, - rows: "all", - }); - if (count > 0) populated.push(kind); - } - if (populated.length === 0) return; - throw edgeMatchIdentityRekeyPopulatedError( - target.id, - currentVersion, - populated, - ); + return { + run: async (backend): Promise => { + const populated = await populatedSchemaKinds( + backend, + target.id, + edgeKinds.map( + (kind) => ({ entity: "edge", kind, rows: "all" }) as const, + ), + ); + if (populated.length === 0) return; + throw edgeMatchIdentityRekeyPopulatedError( + target.id, + transition, + populated.map((probe) => probe.kind), + ); + }, + capabilityError: { + ...EDGE_MATCH_IDENTITY_ATOMIC_PREFLIGHT_CAPABILITY_ERROR, + details: { edgeKinds }, + }, }; } function edgeMatchIdentityRekeyPopulatedError( graphId: string, - currentVersion: number, + transition: Pick, edgeKinds: readonly string[], ): MigrationError { return new MigrationError( `Refusing to activate or change match identity for populated edge kinds: ${edgeKinds.join(", ")}. Export and hard-delete those edges, migrate the schema, then import them so TypeGraph can materialize the new durable keys.`, { graphId, - fromVersion: currentVersion, - toVersion: currentVersion + 1, + fromVersion: transition.fromVersion, + toVersion: transition.toVersion, reason: "edge-match-identity-rekey", edgeKinds, }, @@ -1244,6 +1642,7 @@ async function prepareIdentityKindCascade( return identityKindCascadePreflight( { graphId: target.id, schema }, options.droppedNodeKinds, + resolveSchemaCommitCapture(backend, undefined), ); } @@ -1264,6 +1663,8 @@ async function prepareIdentitySchemaCommit( enablement: boolean; schema?: SqlSchema; droppedNodeKinds?: readonly string[]; + /** See {@link EnsureSchemaInternalOptions.historyEnabled}. */ + historyEnabled?: boolean; }>, ): Promise< (transactionBackend: SchemaCommitPreflightBackend) => Promise @@ -1305,6 +1706,10 @@ async function prepareIdentitySchemaCommit( enablement: options.enablement, droppedNodeKinds: options.droppedNodeKinds ?? [], provisionDerivedRelations: provisioning.provisionInCommit, + // Resolved against the ROOT `GraphBackend` — available here, not inside + // `identitySchemaCommitPreflight`, which sees only the schema-commit + // TRANSACTION target. + capture: resolveSchemaCommitCapture(backend, options.historyEnabled), }, ); } @@ -1511,12 +1916,15 @@ async function buildNewSchemaVersionCommit( }; } -/** @internal Commits a data preflight and schema CAS in one transaction. */ +/** + * @internal Commits a data preflight and schema CAS in one transaction, or + * refuses with the plan's `capabilityError` when the backend cannot. + */ export async function commitNewSchemaVersionWithPreflight( backend: GraphBackend, graph: G, currentVersion: number, - preflight: (target: SchemaCommitPreflightBackend) => Promise, + plan: SchemaCommitPreflightPlan, previous: SerializedSchema | undefined, ): Promise { if (backend.commitSchemaVersionWithPreflight === undefined) { @@ -1524,7 +1932,11 @@ export async function commitNewSchemaVersionWithPreflight( // structurally invalid graph before probing backend capability. buildKindRegistry(graph); } - const commitWithPreflight = requireCommitWithPreflight(backend, graph); + const commitWithPreflight = requireCommitWithPreflight( + backend, + graph, + plan.capabilityError, + ); // Same catalog-race retry as `initializeSchema`, for the same in-transaction // identity DDL. The commit payload is built once, outside the retry, so a // re-run commits the identical version and hash rather than recomputing one. @@ -1533,7 +1945,7 @@ export async function commitNewSchemaVersionWithPreflight( currentVersion, previous, ); - return withIdentityDdlRaceRetry(() => commitWithPreflight(commit, preflight)); + return withIdentityDdlRaceRetry(() => commitWithPreflight(commit, plan.run)); } /** @@ -1547,10 +1959,24 @@ export async function commitNewSchemaVersionWithPreflight( * version. Concurrent rollbacks or commits surface as * `StaleVersionError`. * + * Reactivating a version is a schema transition like any commit, and owes + * the same preflight steps (see `prepareRollbackPreflight`): a tightening the + * target declares is checked against existing rows, and an edge kind the + * target re-keys must be empty. A rollback that changes identity-relevant + * schema on an identity-enabled target is refused, because rebuilding the + * identity closure needs the graph definition a stored document cannot + * reconstruct. Owed steps run under the same schema write fence as the flip + * (`setActiveVersionWithPreflight`) and refuse with the same errors a forward + * commit raises. + * * @param backend - The database backend * @param graphId - The graph ID * @param targetVersion - The version to roll back to - * @throws MigrationError if the target version does not exist + * @throws MigrationError if the target version does not exist, or if + * existing rows violate a tightening the target version declares + * @throws ConfigurationError if a preflight is owed and the backend cannot + * run it atomically with the flip, or (`IDENTITY_ROLLBACK_REQUIRES_MIGRATION`) + * if the rollback changes identity-relevant schema * @throws StaleVersionError if another writer changed the active version concurrently */ export async function rollbackSchema( @@ -1570,11 +1996,155 @@ export async function rollbackSchema( }, ); } - await backend.setActiveVersion({ + const params: SetActiveVersionParams = { graphId, expected: { kind: "active", version: activeRow.version }, version: targetVersion, + }; + // An absent target row owes no preflight: `setActiveVersion` refuses it. + const targetRow = await backend.getSchemaVersion(graphId, targetVersion); + const preflight = + targetRow === undefined ? undefined : ( + prepareRollbackPreflight( + graphId, + { version: activeRow.version, schemaDoc: activeRow.schema_doc }, + { version: targetVersion, schemaDoc: targetRow.schema_doc }, + ) + ); + if (preflight === undefined) { + await backend.setActiveVersion(params); + return; + } + const setActiveWithPreflight = backend.setActiveVersionWithPreflight; + if (setActiveWithPreflight === undefined) throw preflight.unsupportedError; + await setActiveWithPreflight(params, preflight.run); +} + +/** + * The preflight reactivating `target` owes, composed through + * {@link composeSchemaCommitPreflight} like every other schema commit, or + * `undefined` when it owes none. + * + * - `structural`: none. A rollback deletes no rows: rows of a kind the + * target lacks stay stored and reappear when a later version declares the + * kind again, so there is no emptiness gate to run. + * - `edgeMatchIdentity`: an edge kind whose match identity the target + * declares differently must hold no rows, exactly as on a forward commit. + * - `tightening`: the target's ontology and edge cardinalities against the + * active document. + * - `identity`: refuses when {@link rollbackChangesIdentityDerivation}. + * Every other commit path rebuilds the closure from the target graph's + * registry; a stored document carries no node kind definitions to rebuild + * one from (`buildRegistryFromSerializedSchema` registers no node kinds), so + * a rebuild here would derive an empty closure. Refusing inside the fence, + * after the tightening step, keeps the refusal order every path shares. + */ +function prepareRollbackPreflight( + graphId: string, + active: Readonly<{ version: number; schemaDoc: string }>, + target: Readonly<{ version: number; schemaDoc: string }>, +): + | Readonly<{ + run: (target: SchemaCommitPreflightBackend) => Promise; + /** + * What a backend without `setActiveVersionWithPreflight` is refused + * with. When the identity refusal is owed, that refusal itself: it + * reads no data and refuses the rollback on every backend, so the + * missing atomic primitive is not why the rollback cannot run. + */ + unsupportedError: Error; + }> + | undefined { + const before = parseSerializedSchema(active.schemaDoc); + const after = parseSerializedSchema(target.schemaDoc); + const tightening = prepareSchemaTighteningPreflight({ + graphId, + fromVersion: active.version, + toVersion: target.version, + before, + after, + }); + const identity = + rollbackChangesIdentityDerivation(before, after) ? + () => + Promise.reject( + identityRollbackRequiresMigrationError( + graphId, + active.version, + target.version, + ), + ) + : undefined; + const edgeMatchIdentity = prepareEdgeMatchIdentityCommitPreflight( + { id: graphId }, + matchIdentityRekeyedEdgeKinds( + Object.entries(after.edges).map( + ([kind, edge]) => [kind, edge.matchIdentity] as const, + ), + before, + ), + { fromVersion: active.version, toVersion: target.version }, + ); + const preflight = planSchemaCommitPreflight({ + structural: undefined, + edgeMatchIdentity, + tightening, + identity, }); + if (preflight === undefined) return undefined; + return { + run: preflight.run, + unsupportedError: + identity === undefined ? + atomicPreflightUnsupportedError(graphId, preflight.capabilityError) + : identityRollbackRequiresMigrationError( + graphId, + active.version, + target.version, + ), + }; +} + +/** + * Whether reactivating `after` would change what an identity-enabled target + * derives its closure from: enabling identity, changing its profile, or + * changing the ontology or the node kinds the closure folds over. A target + * with identity disabled derives nothing (its retained ledger is rebuilt by + * whichever later commit re-enables it). + */ +function rollbackChangesIdentityDerivation( + before: SerializedSchema, + after: SerializedSchema, +): boolean { + if (after.identity === undefined) return false; + return ( + !canonicalEqual(before.identity, after.identity) || + !canonicalEqual(before.ontology.relations, after.ontology.relations) || + !canonicalEqual( + Object.keys(before.nodes).toSorted(), + Object.keys(after.nodes).toSorted(), + ) + ); +} + +function identityRollbackRequiresMigrationError( + graphId: string, + fromVersion: number, + toVersion: number, +): ConfigurationError { + return new ConfigurationError( + "rollbackSchema cannot rebuild the identity closure for a version whose identity-relevant schema differs from the active one.", + { + code: "IDENTITY_ROLLBACK_REQUIRES_MIGRATION", + graphId, + fromVersion, + toVersion, + }, + { + suggestion: + "Commit the target version's graph definition forward with migrateSchema(), which rebuilds the closure from that definition.", + }, + ); } /** @@ -1618,6 +2188,19 @@ export async function isSchemaInitialized( * @param backend - The database backend * @param graph - The current graph definition * @returns The diff, or undefined if schema not initialized + * @throws ConfigurationError when the stored or the current ontology adds or + * removes a relation and cannot be interpreted (`computeSchemaDiff` → + * `classifyOntologyChanges`) — a schema document written under an older, + * laxer validator can hold an ontology today's hardening rejects. + * `requiresMigration`, built on this function, does not propagate this + * throw; see its own docblock. + * @throws ConfigurationError when the current graph's + * `subClassOf`/`equivalentTo`/`sameAs` hierarchy is not a structural + * subtype of its target — reported HERE, before an upgrade, rather than + * only at commit, and whether or not anything changed: the diff enforces + * the contract whenever the graph declares such a relation (see + * `computeSchemaDiff`'s own docblock), and the live registry the commit + * and `createStore` build judges a pair the stored document cannot. */ export async function getSchemaChanges( backend: GraphBackend, @@ -1640,6 +2223,11 @@ export async function getSchemaChanges( storedSchema, ); const diff = computeSchemaDiff(storedSchema, currentSchema); + // The diff judges subsumption from documents, which skip any pair holding a + // field JSON Schema cannot express. The commit path and `createStore` judge + // that pair field by field from the live graph, so the pre-flight builds the + // same live registry over the same merged graph and refuses what they do. + buildKindRegistry(merged); return diff.hasChanges ? diff : { ...diff, toVersion: diff.fromVersion }; } @@ -1649,12 +2237,26 @@ export async function getSchemaChanges( * pre-flight with no DDL and no writes. * * Returns `true` when the schema has not been initialized yet (the privileged - * bootstrap is required) and when the committed schema is behind `graph`. + * bootstrap is required), when the committed schema is behind `graph`, and + * when the stored or the current ontology adds or removes a relation whose + * coherence `getSchemaChanges` could not determine (`ConfigurationError` from + * `classifyOntologyChanges` — a schema document written under an older, + * laxer validator can hold an ontology today's hardening rejects), or when + * the current graph declares a `subClassOf`/`equivalentTo`/`sameAs` hierarchy + * the structural-subsumption check refuses, whether or not anything changed. + * A document this predicate cannot interpret is, by construction, one the privileged + * path must look at, so this function never throws for that reason: it is + * the routing check a least-privilege runtime relies on to decide *before* a + * write discovers the migration wall mid-request, and a throw here would + * defeat that routing exactly when it matters most. + * * This is the predicate a least-privilege runtime checks to route to the * privileged path *before* a write discovers the migration wall mid-request. * * For the additive-vs-incompatible distinction, use `getSchemaChanges` and - * {@link classifySchemaChanges} instead — this collapses both to `true`. + * {@link classifySchemaChanges} instead — this collapses both to `true`, and + * unlike `getSchemaChanges` it never throws `ConfigurationError` for an + * unclassifiable ontology. * * @param backend - The database backend * @param graph - The current graph definition @@ -1664,7 +2266,13 @@ export async function requiresMigration( backend: GraphBackend, graph: G, ): Promise { - const diff = await getSchemaChanges(backend, graph); + let diff: SchemaDiff | undefined; + try { + diff = await getSchemaChanges(backend, graph); + } catch (error) { + if (error instanceof ConfigurationError) return true; + throw error; + } if (diff === undefined) return true; return diff.hasChanges; } diff --git a/packages/typegraph/src/schema/migration.ts b/packages/typegraph/src/schema/migration.ts index 2a5152bd4..34ba9c005 100644 --- a/packages/typegraph/src/schema/migration.ts +++ b/packages/typegraph/src/schema/migration.ts @@ -4,18 +4,30 @@ * Provides diff detection between schema versions to identify * what has changed and what migrations might be needed. */ +import { effectiveTargetCardinality } from "../core/edge-integrity-options"; import { type IndexEntity } from "../core/types"; +import { MigrationError } from "../errors"; import { type IndexDeclaration } from "../indexes/types"; +import { isSubsumptionMetaEdge } from "../ontology/constants"; import { encodeJsonPointerSegment } from "../query/json-pointer"; +import { + lostEndpointPairs, + serializedEdgeEndpointPairs, +} from "../registry/edge-kind-facts"; import { compareStrings } from "../utils/compare"; import { createDataKeyedBag, hasOwnKey } from "../utils/object"; import { requireDefined } from "../utils/presence"; -import { canonicalEqual, sortedReplacer } from "./canonical"; +import { canonicalEqual, canonicalJson } from "./canonical"; +import { buildRegistryFromSerializedSchema } from "./deserializer"; +import { + classifyOntologyChanges, + type OntologyChange, +} from "./ontology-change"; import { type JsonSchema, + type SchemaCommitTransition, type SerializedEdgeDef, type SerializedNodeDef, - type SerializedOntology, type SerializedSchema, } from "./types"; @@ -30,10 +42,23 @@ export type ChangeType = "added" | "removed" | "modified" | "renamed"; /** * Severity of a change for migration purposes. + * + * `warning` auto-migrates only if the data allows it: an ontology tightening + * (`disjointWith` / `subClassOf` / `equivalentTo` / `sameAs` addition, or a + * `subClassOf` / `equivalentTo` / `sameAs` removal) is `warning`-severity and + * still routes through `ensureSchema`'s auto-migrate branch, but the commit + * transaction runs a data probe first (`prepareSchemaTighteningPreflight`) + * and refuses with `MigrationError` `reason: "ontology-tightening-violated"` + * when existing rows would violate the tightened ontology. A unique-constraint + * change is `warning`-severity on the same terms: the same preflight refuses + * an added or reshaped constraint with `reason: + * "unique-constraint-requires-empty-kind"` while the kind holds rows. + * `isBackwardsCompatible` keeps meaning exactly "no `breaking` change" — it does not mean "safe to + * auto-migrate unconditionally". */ export type ChangeSeverity = | "safe" // No data migration needed - | "warning" // Might need attention + | "warning" // Auto-migrates only if the data allows it | "breaking"; // Requires data migration // ============================================================ @@ -72,17 +97,6 @@ export type EdgeChange = Readonly<{ // Ontology Changes // ============================================================ -/** - * A change to the ontology. - */ -export type OntologyChange = Readonly<{ - type: ChangeType; - entity: "metaEdge" | "relation"; - name: string; - severity: ChangeSeverity; - details: string; -}>; - /** A durable graph-level Operational Identity capability change. */ export type IdentityChange = Readonly<{ type: ChangeType; @@ -234,6 +248,23 @@ export type SchemaDiff = Readonly<{ * @param before - The previous schema version * @param after - The new schema version * @returns A diff describing all changes + * @throws ConfigurationError when `before` or `after` adds or removes a + * relation and the ontology on the affected side cannot be interpreted — + * see {@link classifyOntologyChanges}. Every caller of this function + * inherits the throw: `loadAndVerifyGraph` / `createVerifiedStore`, + * `getSchemaChanges`, and (through it) `requiresMigration` are audited at + * their own declarations. + * @throws ConfigurationError (structural-subsumption check, `ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE` + * / `ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE` / + * `ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE` / + * `ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE`) when `after` declares a + * `subClassOf`/`equivalentTo`/`sameAs` hierarchy whose child does not + * structurally extend its parent — including a hierarchy this diff never + * touched. A migration that only edits a node kind's property schema can + * break an existing hierarchy, and an unchanged graph can carry one an + * earlier release accepted, so this diff builds and enforces the AFTER + * registry whenever `after` declares any of those three meta-edges, even + * when nothing changed at all. */ export function computeSchemaDiff( before: SerializedSchema, @@ -241,7 +272,14 @@ export function computeSchemaDiff( ): SchemaDiff { const nodeChanges = diffNodes(before.nodes, after.nodes); const edgeChanges = diffEdges(before.edges, after.edges); - const ontologyChanges = diffOntology(before.ontology, after.ontology); + const ontologyChanges = classifyOntologyChanges(before, after); + // `classifyOntologyChanges` builds (and thereby structurally enforces + // subsumption on) the AFTER registry only when a relation changed. The + // contract has to hold for the proposed graph whatever this diff touched: + // a property-only edit can break an existing hierarchy, and an UNCHANGED + // graph can carry one an earlier release accepted — which is the case the + // pre-upgrade report exists for. + if (declaresSubsumption(after)) buildRegistryFromSerializedSchema(after); const identityChange = diffIdentity(before.identity, after.identity); const annotationsChange = diffGraphAnnotations( before.annotations, @@ -407,6 +445,17 @@ function diffNodes( return changes; } +/** + * Whether a document declares any `subClassOf`/`equivalentTo`/`sameAs` + * relation — the cheap gate on building a registry just to enforce + * structural subsumption. A document declaring none has nothing to enforce. + */ +function declaresSubsumption(schema: SerializedSchema): boolean { + return schema.ontology.relations.some((relation) => + isSubsumptionMetaEdge(relation.metaEdge), + ); +} + /** * JSON-Schema keywords whose array value is semantically a *set*: `required` * lists which properties must be present, `enum` lists which values are @@ -470,38 +519,136 @@ const SUBSCHEMA_MAP_KEYWORDS: ReadonlySet = new Set([ */ const DEPENDENT_REQUIRED_KEYWORD = "dependentRequired"; -function canonicalKey(value: unknown): string { - return JSON.stringify(value, sortedReplacer); -} - function sortedByCanonicalForm(items: readonly unknown[]): readonly unknown[] { // `compareStrings`, not `localeCompare`: the ordering has to be identical in // every process that diffs this schema, and locale-aware collation varies // with the host's ICU configuration. return items.toSorted((left, right) => - compareStrings(canonicalKey(left), canonicalKey(right)), + compareStrings(canonicalJson(left), canonicalJson(right)), ); } /** - * Recursively order-normalizes {@link SET_VALUED_KEYWORDS} arrays so that a - * pure reordering compares equal. + * Whether `schema` closes a tuple by OMITTING `items` beside `prefixItems`. + * + * The projection closes a tuple that has no rest element with `items: false`. + * A document stored by an earlier projection closes one by omission instead, + * so both spellings read as closed. The one owner of that reading: the diff + * ({@link projectionSpellingNormalized}) and the structural-subtype predicate + * (`arrayTail`, `./structural-subtype`) both ask here. + */ +export function closesTupleByOmission( + schema: Readonly<{ prefixItems?: unknown; items?: unknown }>, +): boolean { + return schema.prefixItems !== undefined && schema.items === undefined; +} + +/** + * The type tokens a union member is exactly made of: `{ type: "string" }` or, + * once an inner union has been folded, `{ type: ["string", "null"] }`. A + * member carrying any other keyword is not a bare union of primitives. + */ +function bareTypeTokens(member: unknown): readonly string[] | undefined { + if (member === null || typeof member !== "object") return undefined; + const entries: readonly (readonly [string, unknown])[] = + Object.entries(member); + const [only, ...others] = entries; + if (only === undefined || others.length > 0) return undefined; + const [key, token] = only; + if (key !== "type") return undefined; + if (typeof token === "string") return [token]; + const isTokenArray = + Array.isArray(token) && token.every((entry) => typeof entry === "string"); + return isTokenArray ? token : undefined; +} + +/** + * Folds the spellings different projections give ONE schema into a single + * form, so a stored document and the same graph projected today compare + * equal instead of reading as a breaking property change. + * + * Two spellings changed between projections, and each rule rewrites only the + * known keywords that spell it — every other key, unknown extension keys + * included, passes through untouched: + * + * - **A union of bare primitives.** `anyOf: [{ type: "string" }, { type: + * "null" }]` and `type: ["string", "null"]` admit the same values; sibling + * keywords are ANDed with either form. Folded to the token array, in member + * order; a member that is itself a folded union (`.nullable()` over a + * primitive `z.union()`, or `.nullable().nullable()`) is flattened into it + * and a repeated token collapses. A member carrying any other keyword keeps + * the union as written. + * - **A closed tuple's arity.** An earlier projection wrote a tuple with no + * rest element as a bare `prefixItems`; the current one closes it with + * `items: false` and states its arity as `minItems` / `maxItems`. The bare + * spelling ({@link closesTupleByOmission}) is the only one that is + * provably the earlier projection's — the current one always writes + * `items` — so it alone is rewritten: closed explicitly, with both bounds + * restated at the prefix length. That reads it as the required-arity + * tuple, because the earlier projection wrote a required and an optional + * trailing member identically and the document cannot say which was + * meant; a stored optional-trailing tuple therefore still reads as + * changed. Beside an explicit `items`, `minItems` is never touched: the + * current projection OMITS it when every prefix member is optional, so + * there an absent `minItems` means zero and a stated one is information. + * A rest tuple stored by the earlier projection carries no `minItems` + * either and so reads as the all-optional one. A closed tuple's absent + * `maxItems` is restated, since a closed tuple cannot exceed its prefix. + */ +function projectionSpellingNormalized( + schema: Record, +): Record { + const normalized = createDataKeyedBag(); + for (const [key, entry] of Object.entries(schema)) normalized[key] = entry; + + const { anyOf } = normalized; + if ( + normalized["type"] === undefined && + Array.isArray(anyOf) && + anyOf.length > 1 + ) { + const memberTokens = anyOf.map((member) => bareTypeTokens(member)); + if (memberTokens.every((tokens) => tokens !== undefined)) { + delete normalized["anyOf"]; + normalized["type"] = [...new Set(memberTokens.flat())]; + } + } + + const { prefixItems } = normalized; + if (Array.isArray(prefixItems)) { + if (closesTupleByOmission(normalized)) { + normalized["items"] = false; + normalized["minItems"] ??= prefixItems.length; + } + if (normalized["items"] === false) { + normalized["maxItems"] ??= prefixItems.length; + } + } + return normalized; +} + +/** + * The form two property schemas are compared in: {@link SET_VALUED_KEYWORDS} + * arrays order-normalized so a pure reordering compares equal, and + * projection spellings folded ({@link projectionSpellingNormalized}) so a + * respelling does too. Recursive over the {@link SCHEMA_VALUED_KEYWORDS} + * allowlist only. * * Deliberately *not* folded into `canonicalEqual` / `sortedReplacer`: that - * canonical form also feeds `computeSchemaHash`, and normalizing arrays there - * would change the hash of every schema already committed to a database. - * This normalization is scoped to diff comparison only. + * canonical form also feeds `computeSchemaHash`, and normalizing there would + * change the hash of every schema already committed to a database. This + * normalization is scoped to diff comparison only. */ -function orderNormalizedSchema(value: unknown): unknown { +function comparisonNormalizedSchema(value: unknown): unknown { if (Array.isArray(value)) { - return value.map((item) => orderNormalizedSchema(item)); + return value.map((item) => comparisonNormalizedSchema(item)); } if (value !== null && typeof value === "object") { const normalized = createDataKeyedBag(); for (const [key, entry] of Object.entries(value)) { normalized[key] = normalizedKeywordValue(key, entry); } - return normalized; + return projectionSpellingNormalized(normalized); } return value; } @@ -523,7 +670,7 @@ function normalizedKeywordValue(key: string, value: unknown): unknown { if (key === DEPENDENT_REQUIRED_KEYWORD) { return normalizedDependentRequired(value); } - if (SCHEMA_VALUED_KEYWORDS.has(key)) return orderNormalizedSchema(value); + if (SCHEMA_VALUED_KEYWORDS.has(key)) return comparisonNormalizedSchema(value); // Everything else is preserved verbatim: annotations (`title`), instance // data (`default`, `const`, `examples`), and unknown extension keys. See // {@link SCHEMA_VALUED_KEYWORDS} for why recursion is an allowlist. @@ -550,52 +697,30 @@ function normalizedDependentRequired(value: unknown): unknown { */ function normalizedSubschemaMap(value: unknown): unknown { if (value === null || typeof value !== "object" || Array.isArray(value)) { - return orderNormalizedSchema(value); + return comparisonNormalizedSchema(value); } const normalized = createDataKeyedBag(); for (const [name, subschema] of Object.entries(value)) { - normalized[name] = orderNormalizedSchema(subschema); + normalized[name] = comparisonNormalizedSchema(subschema); } return normalized; } /** * Whether two property JSON-Schemas are the same schema. Insensitive to the - * order of set-valued keywords, so restating a kind with its fields declared - * in a different order is correctly a no-op rather than a "modified" kind that - * forces a migration. + * order of set-valued keywords and to which projection spelled them, so + * restating a kind with its fields declared in a different order — or + * reopening a stored document after the projection changed how it spells a + * union of primitives or a closed tuple — is correctly a no-op rather than a + * "modified" kind that forces a migration. */ -function propertySchemasEqual(before: unknown, after: unknown): boolean { +export function propertySchemasEqual(before: unknown, after: unknown): boolean { return canonicalEqual( - orderNormalizedSchema(before), - orderNormalizedSchema(after), + comparisonNormalizedSchema(before), + comparisonNormalizedSchema(after), ); } -/** - * Endpoint kind lists are sets — the order edge endpoints are declared in - * carries no meaning. - */ -function getSerializedEdgePairs(edge: SerializedEdgeDef): ReadonlySet { - const pairs = new Set(); - if (edge.targetKindsBySource === undefined) { - for (const fromKind of edge.fromKinds) { - for (const toKind of edge.toKinds) { - pairs.add(`${fromKind}\0${toKind}`); - } - } - } else { - for (const [sourceKind, targetKinds] of Object.entries( - edge.targetKindsBySource, - )) { - for (const targetKind of targetKinds) { - pairs.add(`${sourceKind}\0${targetKind}`); - } - } - } - return pairs; -} - function endpointKindsEqual( before: readonly string[] | undefined, after: readonly string[] | undefined, @@ -703,9 +828,15 @@ function propertyTypeSignature(schema: JsonSchema): string { /** * Non-constraining JSON-Schema keywords: changing them cannot invalidate an - * existing stored value, so a diff limited to these is safe. + * existing stored value, so a diff limited to these is safe. Exported (module- + * local; not re-exported from `./index`) so `structural-subtype.ts`'s + * projection-coverage test can classify a projected keyword as migration + * metadata without re-spelling this list — this predicate's own rule 4 + * silently drops the same keywords for a different reason (see that module's + * doc comment), but the KEYWORD SET a Zod projection can emit has exactly one + * owner regardless of which predicate is asking about it. */ -const NON_CONSTRAINING_KEYWORDS = new Set([ +export const NON_CONSTRAINING_KEYWORDS: ReadonlySet = new Set([ "description", "title", "default", @@ -713,7 +844,9 @@ const NON_CONSTRAINING_KEYWORDS = new Set([ ]); /** A copy of `schema` with the non-constraining keywords removed. */ -function stripSchemaMetadata(schema: JsonSchema): Record { +export function stripSchemaMetadata( + schema: JsonSchema, +): Record { // Data-keyed: JSON-Schema keywords parsed out of the persisted document. const stripped = createDataKeyedBag(); for (const [key, value] of Object.entries(schema)) { @@ -778,7 +911,7 @@ function describeSchemaDifferences( return [`${pointer}: ${JSON.stringify(before)} → ${JSON.stringify(after)}`]; } -function isObjectSchema(schema: JsonSchema): boolean { +export function isObjectSchema(schema: JsonSchema): boolean { return schema.type === "object" || schema.properties !== undefined; } @@ -897,7 +1030,7 @@ function classifyPropertyChanges( for (const [property, beforeProperty] of Object.entries(beforeProps)) { const afterProperty = afterProps[property]; if (afterProperty === undefined) continue; // removed — handled below - if (canonicalEqual(beforeProperty, afterProperty)) continue; // unchanged + if (propertySchemasEqual(beforeProperty, afterProperty)) continue; // unchanged if (isBreakingPropertyChange(beforeProperty, afterProperty)) { breakingProps.push(property); } @@ -1035,15 +1168,10 @@ function diffEdgeDef( after.targetKindsBySource, ) ) { - const beforePairs = getSerializedEdgePairs(before); - const afterPairs = getSerializedEdgePairs(after); - const removedPairs: string[] = []; - for (const pair of beforePairs) { - if (!afterPairs.has(pair)) { - const [from, to] = pair.split("\0"); - removedPairs.push(`(${from} -> ${to})`); - } - } + const removedPairs = lostEndpointPairs( + serializedEdgeEndpointPairs(before), + serializedEdgeEndpointPairs(after), + ).map(({ from, to }) => `(${from} -> ${to})`); const isBreaking = removedPairs.length > 0; const details = @@ -1073,6 +1201,29 @@ function diffEdgeDef( }); } + // Check target cardinality — same "modified"/"warning" shape as the source + // axis: severity policy is unchanged for both directions, because the data + // probe (not the severity) is what actually gates a tightening. Defaulted + // before comparing: unlike `cardinality`, an absent key means "many" (see + // `SerializedEdgeDef`), so an undeclared-vs-undeclared or + // undeclared-vs-explicit-"many" pair must diff as unchanged. + const beforeTargetCardinality = effectiveTargetCardinality( + before.targetCardinality, + ); + const afterTargetCardinality = effectiveTargetCardinality( + after.targetCardinality, + ); + if (beforeTargetCardinality !== afterTargetCardinality) { + changes.push({ + type: "modified", + kind: name, + severity: "warning", + details: `Target cardinality changed from "${beforeTargetCardinality}" to "${afterTargetCardinality}" for "${name}"`, + before, + after, + }); + } + // Write-time validation only: stored edges are not invalidated, so this // is a warning (like cardinality), not a breaking change. if (before.endpointExistence !== after.endpointExistence) { @@ -1170,84 +1321,6 @@ function annotationsChanged(before: unknown, after: unknown): boolean { return !canonicalEqual(before, after); } -// ============================================================ -// Ontology Diff -// ============================================================ - -/** - * Computes changes to the ontology. - */ -function diffOntology( - before: SerializedOntology, - after: SerializedOntology, -): readonly OntologyChange[] { - const changes: OntologyChange[] = []; - - // Diff meta-edges - const metaEdgesBefore = new Set(Object.keys(before.metaEdges)); - const metaEdgesAfter = new Set(Object.keys(after.metaEdges)); - - for (const name of metaEdgesBefore) { - if (!metaEdgesAfter.has(name)) { - changes.push({ - type: "removed", - entity: "metaEdge", - name, - severity: "breaking", - details: `Meta-edge "${name}" was removed`, - }); - } - } - - for (const name of metaEdgesAfter) { - if (!metaEdgesBefore.has(name)) { - changes.push({ - type: "added", - entity: "metaEdge", - name, - severity: "safe", - details: `Meta-edge "${name}" was added`, - }); - } - } - - // Diff relations (simplified - just detect additions/removals) - const relationsBefore = new Set( - before.relations.map((r) => `${r.metaEdge}:${r.from}:${r.to}`), - ); - const relationsAfter = new Set( - after.relations.map((r) => `${r.metaEdge}:${r.from}:${r.to}`), - ); - - for (const relationKey of relationsBefore) { - if (!relationsAfter.has(relationKey)) { - const [metaEdge, from, to] = relationKey.split(":"); - changes.push({ - type: "removed", - entity: "relation", - name: relationKey, - severity: "warning", - details: `Relation ${metaEdge}(${from}, ${to}) was removed`, - }); - } - } - - for (const relationKey of relationsAfter) { - if (!relationsBefore.has(relationKey)) { - const [metaEdge, from, to] = relationKey.split(":"); - changes.push({ - type: "added", - entity: "relation", - name: relationKey, - severity: "safe", - details: `Relation ${metaEdge}(${from}, ${to}) was added`, - }); - } - } - - return changes; -} - // ============================================================ // Index Diff // ============================================================ @@ -1410,6 +1483,21 @@ function diffDeprecatedKinds( /** * Generates a human-readable summary of changes. */ +/** + * One summary clause for a list of typed changes, or `undefined` when the + * list is empty. Every change type is counted, so a list holding only + * `modified` entries can never summarize as nothing. + */ +function summarizeChangeCounts( + label: string, + changes: readonly Readonly<{ type: ChangeType }>[], +): string | undefined { + if (changes.length === 0) return undefined; + const count = (type: ChangeType): number => + changes.filter((change) => change.type === type).length; + return `${label}: ${count("added")} added, ${count("removed")} removed, ${count("modified")} modified`; +} + function generateSummary( nodeChanges: readonly NodeChange[], edgeChanges: readonly EdgeChange[], @@ -1422,35 +1510,13 @@ function generateSummary( ): string { const parts: string[] = []; - const nodeAdded = nodeChanges.filter((c) => c.type === "added").length; - const nodeRemoved = nodeChanges.filter((c) => c.type === "removed").length; - const nodeModified = nodeChanges.filter((c) => c.type === "modified").length; - - if (nodeAdded > 0 || nodeRemoved > 0 || nodeModified > 0) { - parts.push( - `Nodes: ${nodeAdded} added, ${nodeRemoved} removed, ${nodeModified} modified`, - ); - } - - const edgeAdded = edgeChanges.filter((c) => c.type === "added").length; - const edgeRemoved = edgeChanges.filter((c) => c.type === "removed").length; - const edgeModified = edgeChanges.filter((c) => c.type === "modified").length; - - if (edgeAdded > 0 || edgeRemoved > 0 || edgeModified > 0) { - parts.push( - `Edges: ${edgeAdded} added, ${edgeRemoved} removed, ${edgeModified} modified`, - ); - } - - const ontologyAdded = ontologyChanges.filter( - (c) => c.type === "added", - ).length; - const ontologyRemoved = ontologyChanges.filter( - (c) => c.type === "removed", - ).length; - - if (ontologyAdded > 0 || ontologyRemoved > 0) { - parts.push(`Ontology: ${ontologyAdded} added, ${ontologyRemoved} removed`); + for (const [label, changes] of [ + ["Nodes", nodeChanges], + ["Edges", edgeChanges], + ["Ontology", ontologyChanges], + ] as const) { + const counts = summarizeChangeCounts(label, changes); + if (counts !== undefined) parts.push(counts); } if (identityChange !== undefined) { @@ -1461,17 +1527,8 @@ function generateSummary( parts.push(`Graph annotations: ${annotationsChange.type}`); } - const indexAdded = indexChanges.filter((c) => c.type === "added").length; - const indexRemoved = indexChanges.filter((c) => c.type === "removed").length; - const indexModified = indexChanges.filter( - (c) => c.type === "modified", - ).length; - - if (indexAdded > 0 || indexRemoved > 0 || indexModified > 0) { - parts.push( - `Indexes: ${indexAdded} added, ${indexRemoved} removed, ${indexModified} modified`, - ); - } + const indexCounts = summarizeChangeCounts("Indexes", indexChanges); + if (indexCounts !== undefined) parts.push(indexCounts); if (extensionChange !== undefined) { parts.push(`Graph extension document: ${extensionChange.type}`); @@ -1496,12 +1553,21 @@ function generateSummary( // ============================================================ /** - * Checks if a schema change is backwards compatible. + * Checks if a schema change is backwards compatible: exactly "no `breaking` + * change" (`!diff.hasBreakingChanges`) — nodes or edges removed, required + * properties added, existing properties removed, and a `breaking` ontology + * change (`inverseOf`/`implies` added or removed, a relation naming a custom + * meta-edge removed, or Operational Identity's `sameIdAcrossKinds` flip) all + * count. * - * A change is backwards compatible if: - * - No nodes or edges were removed - * - No required properties were added - * - No existing properties were removed + * "Backwards compatible" does NOT mean "will commit unconditionally": a + * `warning`-severity ontology change (see `ChangeSeverity`'s docblock) + * passes this check and then owes a commit-time data probe that can still + * refuse it with `MigrationError` `reason: "ontology-tightening-violated"`, + * and a `warning`-severity unique-constraint change one that can refuse it + * with `reason: "unique-constraint-requires-empty-kind"`. + * See docs/schema-evolution.md's "Ontology tightenings are checked against + * your data" section. */ export function isBackwardsCompatible(diff: SchemaDiff): boolean { return !diff.hasBreakingChanges; @@ -1511,7 +1577,13 @@ export function isBackwardsCompatible(diff: SchemaDiff): boolean { * How a proposed graph relates to the committed schema. * * - `identical` — a semantic no-op; committing it changes nothing. - * - `additive` — changes exist and are all backwards compatible. + * - `additive` — changes exist and are all backwards compatible + * (`isBackwardsCompatible`). This is a pre-flight classification, not a + * commit guarantee: an `additive` diff that carries a `warning`-severity + * ontology change (see `ChangeSeverity`) is still subject to the + * commit-time data probe and can be refused with `MigrationError` + * `reason: "ontology-tightening-violated"` if existing rows violate the + * tightened ontology. * - `incompatible` — at least one breaking change; needs a deliberate * migration decision. */ @@ -1557,6 +1629,11 @@ export function getMigrationActions(diff: SchemaDiff): readonly string[] { } } + const customRelations = removedCustomRelations(diff.ontology); + if (customRelations.length > 0) { + actions.push(moveCustomRelationsAction(customRelations)); + } + if (diff.identity !== undefined) { switch (diff.identity.type) { case "added": { @@ -1594,3 +1671,61 @@ export function getMigrationActions(diff: SchemaDiff): readonly string[] { return actions; } + +/** Every relation `changes` drops whose meta-edge is not built in, as written. */ +function removedCustomRelations( + changes: readonly OntologyChange[], +): readonly string[] { + return changes.flatMap((change) => + change.removedCustomRelation === undefined ? + [] + : [change.removedCustomRelation], + ); +} + +function moveCustomRelationsAction(relations: readonly string[]): string { + return ( + `MOVE custom ontology relations ${relations.join(", ")} ` + + `into defineGraph({ annotations }), then commit with migrateSchema() ` + + `(their meta-edges are not built in, and the committed schema drops them)` + ); +} + +/** + * Refuses a schema commit that would drop a stored custom-meta-edge relation, + * for the store verbs that commit without `ensureSchema`'s severity gate + * (`store.evolve()`, `store.planEvolution()`, `store.removeKinds()`). The + * removal is breaking (see `classifyOntologyChanges`), and `migrateSchema()` + * is the one path that acknowledges it. Call it with the ontology changes of + * the exact before/after documents the commit publishes, before any write. + * + * @throws MigrationError (`reason: "custom-relation-removal"`) naming every + * such relation and the migration action that moves it. + */ +export function assertNoRemovedCustomRelations( + params: SchemaCommitTransition & + Readonly<{ verb: string; changes: readonly OntologyChange[] }>, +): void { + const relations = removedCustomRelations(params.changes); + if (relations.length === 0) return; + throw new MigrationError( + `${params.verb} would drop ${String(relations.length)} stored custom ontology ` + + `relation(s) the graph can no longer declare. ${moveCustomRelationsAction(relations)}.`, + { + graphId: params.graphId, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + reason: "custom-relation-removal", + relations, + }, + ); +} + +/** + * A change to the ontology. + * + * Defined in `./ontology-change` (alongside the data-probe machinery that + * classifies it) and re-exported here so the public path + * (`src/schema/index.ts`) is unchanged. + */ +export { type OntologyChange } from "./ontology-change"; diff --git a/packages/typegraph/src/schema/ontology-change.ts b/packages/typegraph/src/schema/ontology-change.ts new file mode 100644 index 000000000..d9af31609 --- /dev/null +++ b/packages/typegraph/src/schema/ontology-change.ts @@ -0,0 +1,1413 @@ +/** + * Ontology change classification: what an ontology diff means for EXISTING + * DATA, not just for the schema document. + * + * Pure — no backend, no I/O, never throws for a reason of its own: a + * genuinely incoherent stored or proposed ontology propagates the + * `ConfigurationError` `buildRegistryFromSerializedSchema` throws, rather + * than this module adding a second, quieter answer to "can this ontology be + * interpreted at all". + * + * Only the AFTER (proposed) side is held to that. The BEFORE (stored) side + * is a delta input: a document persisted under an earlier validator can hold + * a hierarchy the structural-subsumption contract refuses, or a + * `partOf`/`hasPart` relation with no `via`, and enforcing either here would + * wedge the very commit that repairs it — removing or correcting the + * relation is a relation change, so the diff must be able to read the stored + * side to classify the fix. See `StructuralSubsumptionMode` + * (`src/registry/build-validated.ts`) for exactly what the stored side is + * excused from; every other stored-side incoherence still throws. + * + * The severity table below is the ontology half of `computeSchemaDiff`. + * Three meta-edges can change what data a commit invalidates: + * + * - `disjointWith` ADDED can make two already-live nodes of the pair's kinds + * mutually exclusive. + * - `subClassOf` / `equivalentTo` / `sameAs` ADDED can merge two previously + * independent `kindWithSubClasses` uniqueness components, propagate an + * existing `disjointWith` down to a kind that was not disjoint before, or + * make a kind a required-existence composition part its live rows never + * had to be (`newlyRequiredPartEdgeKinds`). + * - `subClassOf` / `equivalentTo` / `sameAs` REMOVED can shrink an edge + * kind's admitted endpoint pairs out from under live edges that relied on + * the subsumption the relation provided. + * + * `sameAs` is folded into the equivalence bucket by `collectOntologyRelations` + * (`src/registry/kind-registry.ts`), so classifying it differently from + * `equivalentTo` would make the classifier lie about the closure it produces. + * + * `differentFrom` reaches no arm of `collectOntologyRelations` — it feeds no + * closure and drives no write-path decision, so it is inert and `safe`. + * + * A custom meta-edge name (one outside `ALL_META_EDGE_NAMES`, declared by the + * removed `metaEdge()` factory) is just as inert to the engine, and ADDED it + * is `safe`. REMOVED it is `breaking`: no code path can declare such a + * relation any more (`metaEdge()` is gone and graph extensions refuse the + * name), so the stored document holds the application's last copy of it, + * and dropping that copy must wait for an explicit `migrateSchema()` after + * the relation is moved into `defineGraph({ annotations })`. The change + * names the relation in `removedCustomRelation` for `getMigrationActions` + * and for the store verbs that commit without `ensureSchema`, which refuse + * the drop (`assertNoRemovedCustomRelations`). + * + * `inverseOf` / `implies` change default traversal results (`expand: + * "inverse"` / `expand: "implying"`), which is a read-semantics change; the + * identity fold flip (`diffIdentity` in `./migration`) is the precedent for + * classifying that `breaking` so it cannot auto-migrate. + * + * `broader`, `narrower` and `relatedTo` never gate a write and never change + * which rows a claim contends for, so they stay `safe` in both directions. + * + * `partOf` / `hasPart` ADDED is a composition tightening: a newly declared + * composition pair can make an already-live part's second whole a violation + * of one whole per part (relation-wide) it never was before, so it is + * `warning`, probed by `compositionSingleWhole` — the single-whole audit, the + * composition acyclicity check over the PROPOSED composition relation, and + * the check that no row of the realizing edge kind starts or ends later than + * the commit, all run against the proposed registry in + * `prepareSchemaTighteningPreflight`. + * REMOVED is `breaking`: dropping a composition declaration is a + * read/write-semantics change (parts stop being deletable-with-their-whole, + * `parts()`/`wholes()` stop resolving), not a row-level fact a data check + * could falsify, so — like `inverseOf`/`implies` — it cannot auto-migrate. + * The exception is a stored relation with no `via`: it realizes no + * composition pair, so dropping it changes nothing a read or a write + * observes and is `safe`. That is what lets a document carrying the earlier + * three-part `partOf(part, whole)` move forward — to no relation, or to the + * same pair with a `via` (a `safe` removal plus the ordinary probed + * addition). + * + * A relation whose `from` or `to` names a kind THIS COMMIT REMOVES is itself + * `safe` with no probe — see `classifyRelation`'s removed-kind rule. + * + * An edge kind that stops admitting an endpoint pair between SURVIVING kinds + * is `warning`, probed by `edgeEndpointAssignability`, whatever caused the + * loss: a subsumption relation removed, a kind removed from the middle of a + * subclass chain, or the edge kind's own `from` / `to` declaration narrowed. + * `edgeEndpointAdmissionLosses` is the one owner of that decision; a relation + * removal carries its probe, and a loss no relation change carries is + * reported as its own `entity: "edgeRegistration"` change. + * + * This module has exactly ONE owner for "did the ontology change, and how + * severely": the RELATION-level diff above. `SerializedOntology.metaEdges` + * is derived state the serializer computes 1:1 from `relations` (one entry + * per meta-edge name any relation currently uses) — it is never an + * independent classification input. `classifyOntologyChanges` therefore + * diffs only `relations`; a meta-edge name disappearing from the catalog is + * always the same event as its last relation being removed, which the + * relation-level diff has already classified through the severity table + * above. An earlier revision diffed the catalog too, unconditionally + * `breaking` on any name leaving it — a second, contradicting owner of the + * same decision (it disagreed with the relation-level table for every + * `safe`/`warning` removal, `sameAs`'s and `differentFrom`'s included). + */ +import type { EdgeEndpointAllowance } from "../backend/types"; +import { uniquenessScopeSpansSubclasses } from "../core/unique-constraint-options"; +import { + ALL_META_EDGE_NAMES, + META_EDGE_BROADER, + META_EDGE_DIFFERENT_FROM, + META_EDGE_DISJOINT_WITH, + META_EDGE_EQUIVALENT_TO, + META_EDGE_HAS_PART, + META_EDGE_IMPLIES, + META_EDGE_INVERSE_OF, + META_EDGE_NARROWER, + META_EDGE_PART_OF, + META_EDGE_RELATED_TO, + META_EDGE_SAME_AS, + META_EDGE_SUB_CLASS_OF, + type MetaEdgeName, +} from "../ontology/constants"; +import { + isCompositionMetaEdge, + ontologyRelationIdentityKey, +} from "../registry/composition-relation"; +import { expandEdgeEndpointAllowance } from "../registry/edge-endpoint-allowance"; +import { + buildSerializedEdgeKindFacts, + type EdgeKindFacts, + lostEndpointPairs, +} from "../registry/edge-kind-facts"; +import { type KindRegistry } from "../registry/kind-registry"; +import { requiredCompositionPartKinds } from "../store/operations/composition-create"; +import { compareStrings, compareStringTuples } from "../utils/compare"; +import { hasOwnKey } from "../utils/object"; +import { requireDefined } from "../utils/presence"; +import { encodeTupleKey } from "../utils/tuple-key"; +import { buildRegistryFromSerializedSchema } from "./deserializer"; +import { type ChangeSeverity, type ChangeType } from "./migration"; +import { + type SerializedOntologyRelation, + type SerializedSchema, +} from "./types"; + +// ============================================================ +// Types +// ============================================================ + +/** + * One entry in a `nodeUniquenessComponent` probe: a `(constraint name, + * merged component)` pair the proposal creates — the exact fold + * `auditConstraintFences` (`src/store/claims/verify.ts`) uses to place a + * `uniques` row on an axis, minus the precomputed axis itself (the audit + * derives that from `coveredKinds` through `uniquenessAxisGroupFor`). + */ +export type UniquenessComponentProbeGroup = Readonly<{ + constraintName: string; + /** Every kind the merged `kindWithSubClasses` axis now covers, code-point order. */ + coveredKinds: readonly string[]; +}>; + +/** One data check a schema commit owes before it may publish an ontology tightening. */ +type OntologyDataProbeBody = + | Readonly<{ + kind: "nodeDisjointness"; + /** Disjoint kind pairs the proposal ADDS, after propagation. Canonical order. */ + pairs: readonly (readonly [string, string])[]; + }> + | Readonly<{ + kind: "nodeUniqueness"; + /** + * One entry per `(constraint name, merged component)` the proposal + * creates. + */ + groups: readonly UniquenessComponentProbeGroup[]; + }> + | Readonly<{ + kind: "edgeEndpointAssignability"; + /** Edge kinds whose admitted concrete endpoint pairs SHRANK, with what still remains. */ + allowances: readonly EdgeEndpointAllowance[]; + }> + | Readonly<{ + kind: "edgeAcyclicity"; + /** + * Edge kinds whose `acyclic` flag turned on this commit (present on + * both sides of the diff — a brand-new kind is vacuously safe, per + * `edgeAcyclicityDelta`). + */ + edgeKinds: readonly string[]; + }> + | Readonly<{ + kind: "composition"; + /** + * The realizing (`via`) edge kinds of the `partOf`/`hasPart` pairs + * ADDED this commit — delta-scoped like every other family here, so a + * commit that adds one new composition pair is checked against that + * pair alone, never against a pre-existing pair's already-tightened + * data. `prepareSchemaTighteningPreflight` derives the actual audit + * declarations and the acyclicity relation from the PROPOSED registry, + * restricted to these edge kinds. + */ + edgeKinds: readonly string[]; + }> + | Readonly<{ + kind: "compositionExistence"; + /** + * The realizing (`via`) edge kinds of the `partOf`/`hasPart` pairs + * added this commit that ALSO declare `existence: "required"`, and of + * the pairs under which an added `subClassOf`/`equivalentTo`/`sameAs` + * makes a kind a required part. + * `prepareSchemaTighteningPreflight` audits these edge kinds' + * required part kinds for a live part with no live whole — the same + * delta-scoping discipline as `compositionSingleWhole`. + */ + edgeKinds: readonly string[]; + }>; + +/** + * The violation families each probe can produce. `compositionSingleWhole` + * fans out into `composition`, `edgeAcyclicity` and `compositionFutureWindow` + * (a row of the pair's realizing edge kind whose window starts or ends later + * than the commit); every other probe + * names the family a `verifyConstraintFences()` row uses for the same fact. + * `edgeCardinality` has no ontology probe — it is a write-time fence, not a + * schema-commit check. {@link probeCoversViolationFamily} is the join. + */ +export const PROBE_VIOLATION_FAMILIES = { + nodeDisjointness: ["nodeDisjointness"], + nodeUniqueness: ["nodeUniqueness"], + edgeEndpointAssignability: ["edgeEndpointAssignability"], + edgeAcyclicity: ["edgeAcyclicity"], + composition: ["composition", "edgeAcyclicity", "compositionFutureWindow"], + compositionExistence: ["compositionExistence"], +} as const satisfies Record; + +export type OntologyDataProbe = OntologyDataProbeBody & + Readonly<{ + families: (typeof PROBE_VIOLATION_FAMILIES)[OntologyDataProbeBody["kind"]]; + }>; + +function stampProbeFamilies

( + probe: P, +): OntologyDataProbe { + return { + ...probe, + families: PROBE_VIOLATION_FAMILIES[probe.kind], + }; +} + +export function probeCoversViolationFamily( + probe: Pick, + family: string, +): boolean { + return (probe.families as readonly string[]).includes(family); +} + +/** + * A change to the ontology. Moved here from `migration.ts`, which + * re-exports it so the public path (`src/schema/index.ts`) is unchanged. + * + * `entity: "edgeRegistration"` covers an edge kind's own registration: + * `acyclic` and its admitted endpoint pairs are edge-registration + * properties, not ontology meta-edges/relations, but they share this classifier and the tightening-probe machinery rather than + * forking a second implementation of "what does a schema change do to + * existing data". + */ +export type OntologyChange = Readonly<{ + type: ChangeType; + entity: "relation" | "edgeRegistration"; + name: string; + severity: ChangeSeverity; + details: string; + /** + * The data checks a commit of this change must run. Absent on `safe` and + * on `breaking` changes (breaking already requires an explicit + * `migrateSchema()`). Declared per the severity table above regardless of + * whether the diff-wide payload happens to be empty for this particular + * relation — `ontologyTighteningProbes` is what decides whether a family + * has any actual work to fold into a commit preflight. + */ + probes?: readonly OntologyDataProbe[]; + /** + * Set only on the removal of a relation whose meta-edge is not built in, + * one a custom `metaEdge()` declared before that factory was removed: the + * relation as written, such as `governs(Team, Project)`. The removal is + * `breaking` because the stored document holds the last copy of a relation + * the graph can no longer declare; a relation dropped alongside a kind it + * names stays `safe` under the removed-kind rule but is still named here. + * Move it into `defineGraph({ annotations })`, then commit with + * `migrateSchema()`; `getMigrationActions` lists every such relation, and + * the store verbs that commit without `ensureSchema` refuse to drop one. + */ + removedCustomRelation?: string; +}>; + +/** Everything classification reads. Deliberately narrower than `SerializedSchema`. */ +export type OntologySnapshot = Pick< + SerializedSchema, + "ontology" | "nodes" | "edges" +>; + +// ============================================================ +// Relation keying +// ============================================================ + +/** Presentational label for `OntologyChange.name`. Not used as a lookup key. */ +function relationKey(relation: SerializedOntologyRelation): string { + return `${relation.metaEdge}:${relation.from}:${relation.to}`; +} + +/** + * Human-readable description of a relation for `OntologyChange.details`, + * naming `via` (and `partSide`, when present) so a composition relation + * re-pointed at a different realizing edge reads as a distinct change + * instead of two identical-looking "removed"/"added" entries. + * Also names `existence` when declared, so a rendered diff never shows a + * bare `partOf(...)`/`hasPart(...)` pair with no hint of its existence + * constraint. + */ +function relationDescription(relation: SerializedOntologyRelation): string { + const base = `${relation.metaEdge}(${relation.from}, ${relation.to})`; + const viaClause = relation.via === undefined ? "" : ` via "${relation.via}"`; + const partSideClause = + relation.partSide === undefined ? + "" + : ` (partSide: "${relation.partSide}")`; + const existenceClause = + relation.existence === undefined ? + "" + : ` (existence: "${relation.existence}")`; + return `${base}${viaClause}${partSideClause}${existenceClause}`; +} + +/** + * The relations of one side, keyed by {@link relationClassificationKey}. + * + * `via` and `partSide` are part of that identity, so a composition relation + * re-pointed at a different realizing edge diffs as remove + add rather than + * disappearing as a no-op. `existence` is deliberately NOT: a pair whose + * `existence` only flips (an in-place edit, not a re-declaration) is looked + * up as one relation present on both sides and classified as `modified` by + * `classifyExistenceChange` below. `partOf`/`hasPart`'s `removed` arm is + * unconditionally `breaking`, so an existence-only edit routed through it + * would refuse even a pure loosening, and refuse a tightening before + * `prepareSchemaTighteningPreflight` ever ran. + */ +function keyedRelations( + relations: readonly SerializedOntologyRelation[], +): ReadonlyMap { + const result = new Map(); + for (const relation of relations) { + result.set(relationClassificationKey(relation), relation); + } + return result; +} + +/** + * The meta-edges whose relation means the same thing with its endpoints + * swapped: `disjointWith(A, C)` is `disjointWith(C, A)`. + */ +const SYMMETRIC_META_EDGES: ReadonlySet = new Set([ + META_EDGE_DISJOINT_WITH, + META_EDGE_EQUIVALENT_TO, + META_EDGE_SAME_AS, + META_EDGE_DIFFERENT_FROM, + META_EDGE_RELATED_TO, + META_EDGE_INVERSE_OF, +]); + +/** + * {@link ontologyRelationIdentityKey}, read with a symmetric relation's + * endpoints in a fixed order, so restating one with its arguments + * swapped is the same relation on both sides of a diff rather than a + * removal beside an addition the commit would probe. + */ +function relationClassificationKey( + relation: SerializedOntologyRelation, +): string { + if ( + !SYMMETRIC_META_EDGES.has(relation.metaEdge) || + compareStrings(relation.from, relation.to) <= 0 + ) { + return ontologyRelationIdentityKey(relation); + } + return ontologyRelationIdentityKey({ + ...relation, + from: relation.to, + to: relation.from, + }); +} + +/** Which kinds a schema transition removes, keeps and adds. */ +export type SnapshotKindDelta = Readonly<{ + /** Node and edge kind names present `before` and absent `after` — kinds THIS COMMIT removes. */ + removedKindNames: ReadonlySet; + /** Edge kinds present on both sides, `compareStrings` order. */ + retainedEdgeKinds: readonly string[]; + /** Edge kinds absent `before` and present `after`, `compareStrings` order. */ + addedEdgeKinds: readonly string[]; +}>; + +/** THE before/after kind delta every per-transition decision reads. */ +export function snapshotKindDelta( + before: OntologySnapshot, + after: OntologySnapshot, +): SnapshotKindDelta { + const afterNames = new Set([ + ...Object.keys(after.nodes), + ...Object.keys(after.edges), + ]); + const removedKindNames = new Set( + [...Object.keys(before.nodes), ...Object.keys(before.edges)].filter( + (name) => !afterNames.has(name), + ), + ); + const afterEdgeKinds = Object.keys(after.edges).toSorted((left, right) => + compareStrings(left, right), + ); + return { + removedKindNames, + retainedEdgeKinds: afterEdgeKinds.filter((edgeKind) => + hasOwnKey(before.edges, edgeKind), + ), + addedEdgeKinds: afterEdgeKinds.filter( + (edgeKind) => !hasOwnKey(before.edges, edgeKind), + ), + }; +} + +// ============================================================ +// Diff-wide probe payloads +// ============================================================ + +/** + * Whether `after` no longer admits something `before` did. Deliberately NOT + * a proper-subset test of `after` in `before`: a proper subset additionally requires + * `after` to be strictly SHORTER than `before`, which a same-size swap (one + * allowed pair removed, a different one added in the same commit — e.g. + * `subClassOf(Company, Organization)` replaced by `subClassOf(Shop, + * Organization)` on an edge kind `from [Person] to [Organization]`) never + * satisfies even though it genuinely drops an admitted pair. + */ +function lostAnyMember( + before: readonly string[], + after: readonly string[], +): boolean { + const afterValues = new Set(after); + return before.some((value) => !afterValues.has(value)); +} + +/** + * Disjoint kind pairs the AFTER registry declares that the BEFORE registry + * did not — after propagation through subsumption and equivalence, so a + * `subClassOf` addition that propagates an existing `disjointWith` down to a + * new descendant produces a non-empty delta through the same computation + * that yields a fresh `disjointWith` addition's own pair. + */ +function nodeDisjointnessDelta( + beforeRegistry: KindRegistry, + afterRegistry: KindRegistry, +): readonly (readonly [string, string])[] { + const beforeLabels = new Set( + beforeRegistry + .disjointKindPairs() + .map(([left, right]) => beforeRegistry.disjointPairLabel(left, right)), + ); + return afterRegistry + .disjointKindPairs() + .filter( + ([left, right]) => + !beforeLabels.has(afterRegistry.disjointPairLabel(left, right)), + ) + .toSorted((left, right) => + compareStrings( + afterRegistry.disjointPairLabel(left[0], left[1]), + afterRegistry.disjointPairLabel(right[0], right[1]), + ), + ); +} + +/** + * Merged `kindWithSubClasses` uniqueness components: every node kind whose + * subsumption component GAINED a member between the two registries, paired + * with every `kindWithSubClasses` constraint name declared on any member of + * the component it now belongs to. + * + * `getSubClassComponent` folds `subClassOf` and `equivalentTo` / `sameAs` + * alike, so an equivalence addition merges components here exactly as a + * subclass addition does. + * + * The test is {@link lostAnyMember} read the other way — a member present + * AFTER that was absent BEFORE — not a strict size increase. A commit can + * re-pair kinds: with `subClassOf(A, P)` and `subClassOf(B, Q)` replaced by + * `subClassOf(A, B)`, the components of `A` and `B` stay the same size while + * the two kinds come to share one axis for the first time. Requiring growth + * would report no group for that diff and commit the merge unprobed. + * + * A component that only LOST members yields no group: a split cannot make two + * existing rows contend. It can move the claim axis of the kinds left behind + * away from their existing claims, which `uniquenessClaimGates` + * (`./uniqueness-claim-gate`) refuses while such a kind holds rows. + */ +function nodeUniquenessComponentGroups( + before: OntologySnapshot, + after: OntologySnapshot, + beforeRegistry: KindRegistry, + afterRegistry: KindRegistry, +): readonly UniquenessComponentProbeGroup[] { + const mergedComponents = new Map(); + for (const kind of Object.keys(after.nodes)) { + const afterComponent = afterRegistry.getSubClassComponent(kind); + const beforeComponent = beforeRegistry.getSubClassComponent(kind); + if (!lostAnyMember(afterComponent, beforeComponent)) continue; + mergedComponents.set(afterComponent.join(" "), afterComponent); + } + + const groups: UniquenessComponentProbeGroup[] = []; + for (const component of mergedComponents.values()) { + const constraintNames = new Set(); + for (const memberKind of component) { + const memberDef = + hasOwnKey(after.nodes, memberKind) ? + after.nodes[memberKind] + : undefined; + if (memberDef === undefined) continue; + for (const constraint of memberDef.uniqueConstraints) { + if (uniquenessScopeSpansSubclasses(constraint.scope)) { + constraintNames.add(constraint.name); + } + } + } + for (const constraintName of [...constraintNames].toSorted( + compareStrings, + )) { + groups.push({ constraintName, coveredKinds: component }); + } + } + return groups.toSorted( + (left, right) => + compareStrings(left.constraintName, right.constraintName) || + compareStrings(left.coveredKinds.join(" "), right.coveredKinds.join(" ")), + ); +} + +/** + * The realizing (`via`) edge kinds under which some concrete node kind is a + * required-existence part in the AFTER registry and was not one BEFORE. + * + * A kind becomes a required part without any composition relation changing: + * `subClassOf(Draft, Chapter)` makes every `Draft` a `Chapter`, and with it a + * part that must have a whole. {@link requiredCompositionPartKinds} is the + * one reading of "which kinds must have a whole" on both sides, so this + * delta is exactly what the write path starts refusing after the commit. + * + * Reported as edge kinds because that is what the `compositionExistence` + * probe names; the preflight resolves them back to the required part kinds + * under each. Only existence is probed for an inherited part: a kind that was + * not assignable to the part kind before could hold no realizing edge, so it + * can bring neither a second whole nor a cycle into the relation. + */ +function newlyRequiredPartEdgeKinds( + beforeRegistry: KindRegistry, + afterRegistry: KindRegistry, +): readonly string[] { + const requiredBefore = new Set(requiredCompositionPartKinds(beforeRegistry)); + const edgeKinds = new Set( + requiredPartKindsByViaEdgeKind(afterRegistry) + .filter(({ partKind }) => !requiredBefore.has(partKind)) + .map(({ viaEdgeKind }) => viaEdgeKind), + ); + return [...edgeKinds].toSorted(compareStrings); +} + +/** + * Every (realizing edge kind, concrete required-existence part kind) a + * composition pair places, in pair order and then `expandSubClasses` order: + * the one reading of "which required parts does this edge kind hold", + * shared by the classifier above and the commit preflight's required-whole + * check. + */ +export function requiredPartKindsByViaEdgeKind( + registry: KindRegistry, +): readonly Readonly<{ viaEdgeKind: string; partKind: string }>[] { + const requiredPartKinds = new Set(requiredCompositionPartKinds(registry)); + return registry.compositionRelation().pairs.flatMap((pair) => + registry + .expandSubClasses(pair.partKind) + .filter((partKind) => requiredPartKinds.has(partKind)) + .map((partKind) => ({ viaEdgeKind: pair.viaEdgeKind, partKind })), + ); +} + +/** One edge kind's admission loss: the pairs it stopped admitting, and what the probe still allows. */ +type EdgeEndpointAdmissionLoss = Readonly<{ + /** + * Admitted concrete pairs the proposal drops, between kinds that SURVIVE + * this commit. Sorted like `EdgeEndpointAllowance.allowedPairs`. + */ + lostPairs: readonly (readonly [string, string])[]; + /** + * What a live row may still sit on: the proposal's own admitted pairs, + * plus every previously admitted pair naming a node kind this commit + * removes. Rows of a removed kind are reclaimed by the removal itself, so + * the probe must not refuse the commit over them — while every other row + * of the edge kind is still checked. + */ + allowance: EdgeEndpointAllowance; +}>; + +function endpointPairKey(pair: readonly [string, string]): string { + return encodeTupleKey(pair); +} + +/** Both sides' edge kind facts, built once per classification. */ +type SnapshotEdgeKindFacts = Readonly<{ + before: ReadonlyMap; + after: ReadonlyMap; +}>; + +/** + * Whether any edge kind present on both sides DECLARES fewer endpoint pairs + * than before — read off the two documents alone, no registry. With no + * relation change the subsumption expansion is the same function on both + * sides, so a lost declared pair is the only way an admitted pair can be + * lost; this is the cheap gate deciding whether + * {@link edgeEndpointAdmissionLosses} needs registries at all. + */ +function edgeEndpointDeclarationsNarrowed( + endpoints: SnapshotEdgeKindFacts, +): boolean { + for (const [edgeKind, afterKinds] of endpoints.after) { + const beforeKinds = endpoints.before.get(edgeKind); + if (beforeKinds === undefined) continue; + if (lostEndpointPairs(beforeKinds.pairs, afterKinds.pairs).length > 0) { + return true; + } + } + return false; +} + +/** + * THE owner of "did an edge kind stop admitting an endpoint pair live rows + * may sit on": edge kinds present on both sides whose admitted concrete + * endpoint pairs SHRANK, whatever shrank them — a `subClassOf` / + * `equivalentTo` / `sameAs` relation removed, a kind removed from the middle + * of a subclass chain, or the edge kind's own `from` / `to` / + * source-dependent target declaration narrowed. + * + * A lost pair naming a node kind THIS COMMIT removes is not a loss: the + * removal reclaims that kind's rows, edges touching them included. Every + * other lost pair is, so removing `Mammal` from `Dog ⊂ Mammal ⊂ Animal` + * still reports `likes(Person → Dog)` losing its admission while never + * holding the commit to a `Mammal` row. + */ +function edgeEndpointAdmissionLosses( + endpoints: SnapshotEdgeKindFacts, + kindDelta: SnapshotKindDelta, + beforeRegistry: KindRegistry, + afterRegistry: KindRegistry, +): readonly EdgeEndpointAdmissionLoss[] { + const { removedKindNames } = kindDelta; + const namesRemovedKind = ([from, to]: readonly [string, string]): boolean => + removedKindNames.has(from) || removedKindNames.has(to); + + const losses: EdgeEndpointAdmissionLoss[] = []; + for (const edgeKind of kindDelta.retainedEdgeKinds) { + const beforeKinds = endpoints.before.get(edgeKind); + const afterKinds = endpoints.after.get(edgeKind); + if (beforeKinds === undefined || afterKinds === undefined) continue; + + const beforeAllowed = expandEdgeEndpointAllowance( + edgeKind, + beforeKinds, + beforeRegistry, + ); + const afterAllowed = expandEdgeEndpointAllowance( + edgeKind, + afterKinds, + afterRegistry, + ); + const afterPairKeys = new Set( + afterAllowed.allowedPairs.map((pair) => endpointPairKey(pair)), + ); + const droppedPairs = beforeAllowed.allowedPairs.filter( + (pair) => !afterPairKeys.has(endpointPairKey(pair)), + ); + const lostPairs = droppedPairs.filter((pair) => !namesRemovedKind(pair)); + if (lostPairs.length === 0) continue; + + losses.push({ + lostPairs, + allowance: { + edgeKind, + allowedPairs: [ + ...afterAllowed.allowedPairs, + ...droppedPairs.filter((pair) => namesRemovedKind(pair)), + ].toSorted((left, right) => compareStringTuples(left, right)), + }, + }); + } + return losses.toSorted((left, right) => + compareStrings(left.allowance.edgeKind, right.allowance.edgeKind), + ); +} + +/** + * The data checks an edge kind THIS commit adds owes, which are not changes + * the diff reports: a kind absent from `before` has no declaration to compare, + * yet it may still own rows a previous removal left in the table + * (`discardDroppedKindRows`), and those rows become live under the re-added + * declaration. So an added kind is probed as if its previous declaration had + * admitted every endpoint pair and declared no acyclicity — the same reading + * `newlyConstrainedEdgeAxes` (`./edge-cardinality-change`) gives an added + * kind's cardinality and `declaredKindUniqueConstraints` + * (`./uniqueness-claim-gate`) gives an added kind's unique constraints. A kind + * with no such rows answers both probes with one empty read. + */ +export type AddedEdgeKindProbes = Readonly<{ + endpointAllowances: readonly EdgeEndpointAllowance[]; + acyclicEdgeKinds: readonly string[]; +}>; + +export function addedEdgeKindProbes( + after: OntologySnapshot, + kindDelta: SnapshotKindDelta, + afterRegistry: KindRegistry, +): AddedEdgeKindProbes { + const { addedEdgeKinds } = kindDelta; + if (addedEdgeKinds.length === 0) { + return { endpointAllowances: [], acyclicEdgeKinds: [] }; + } + const afterEndpoints = buildSerializedEdgeKindFacts(after.edges); + return { + endpointAllowances: addedEdgeKinds.map((edgeKind) => + expandEdgeEndpointAllowance( + edgeKind, + requireDefined(afterEndpoints.get(edgeKind)), + afterRegistry, + ), + ), + acyclicEdgeKinds: addedEdgeKinds.filter( + (edgeKind) => after.edges[edgeKind]?.acyclic === true, + ), + }; +} + +/** + * Edge kinds present on both sides whose `acyclic` flag made the transition + * `wasTransition` selects for. A brand-new edge kind (absent from `before`) + * is not a CHANGE to an edge registration, so it qualifies for neither + * direction here; the rows a previous removal may have left under its name + * are probed by {@link addedEdgeKindProbes} instead. + * The one loop {@link edgeAcyclicityAddedDelta} and + * {@link edgeAcyclicityRemovedDelta} both filter, so the "present on both + * sides" test cannot drift between the two directions. + */ +function edgeAcyclicityTransitionDelta( + before: OntologySnapshot, + after: OntologySnapshot, + kindDelta: SnapshotKindDelta, + wasTransition: (wasAcyclic: boolean, isAcyclic: boolean) => boolean, +): readonly string[] { + return kindDelta.retainedEdgeKinds.filter((edgeKind) => + wasTransition( + before.edges[edgeKind]?.acyclic === true, + after.edges[edgeKind]?.acyclic === true, + ), + ); +} + +/** + * Edge kinds present on both sides whose `acyclic` flag went from + * absent/`false` to `true` this commit — an acyclic edge tightening. + */ +function edgeAcyclicityAddedDelta( + before: OntologySnapshot, + after: OntologySnapshot, + kindDelta: SnapshotKindDelta, +): readonly string[] { + return edgeAcyclicityTransitionDelta( + before, + after, + kindDelta, + (wasAcyclic, isAcyclic) => !wasAcyclic && isAcyclic, + ); +} + +/** + * Edge kinds present on both sides whose `acyclic` flag went from `true` to + * absent/`false` this commit — always `safe`, no probe: dropping the axiom + * can invalidate nothing already true of the data. + */ +function edgeAcyclicityRemovedDelta( + before: OntologySnapshot, + after: OntologySnapshot, + kindDelta: SnapshotKindDelta, +): readonly string[] { + return edgeAcyclicityTransitionDelta( + before, + after, + kindDelta, + (wasAcyclic, isAcyclic) => wasAcyclic && !isAcyclic, + ); +} + +// ============================================================ +// Severity table +// ============================================================ + +/** + * The probe kinds a META-EDGE/RELATION change can carry. Excludes + * `edgeAcyclicity`: that probe belongs to an `entity: "edgeRegistration"` + * change, classified in a separate arm of `classifyOntologyChanges` that + * never calls {@link buildProbe} — see `edgeAcyclicityAddedDelta`. + */ +type ProbeKind = Exclude; + +type RelationSeverity = Readonly<{ + severity: ChangeSeverity; + probeKinds: readonly ProbeKind[]; + /** Replaces the plain "was added/removed" details with what to do about it. */ + details?: string; + removedCustomRelation?: string; +}>; + +const KNOWN_META_EDGE_NAMES: ReadonlySet = new Set(ALL_META_EDGE_NAMES); + +function isKnownMetaEdgeName(name: string): name is MetaEdgeName { + return KNOWN_META_EDGE_NAMES.has(name); +} + +/** + * The severity table from the module docblock, as one exhaustive `switch`. + * `MetaEdgeName` is checked exhaustively (a `never` default catches a future + * addition to `ALL_META_EDGE_NAMES` at compile time); a custom or otherwise + * unregistered meta-edge name is classified by + * {@link classifyCustomRelationSeverity} before this runs. + */ +function classifyKnownRelationSeverity( + direction: "added" | "removed", + metaEdge: MetaEdgeName, + relation: SerializedOntologyRelation, +): RelationSeverity { + switch (metaEdge) { + case META_EDGE_DISJOINT_WITH: { + return direction === "added" ? + { severity: "warning", probeKinds: ["nodeDisjointness"] } + : { severity: "safe", probeKinds: [] }; + } + case META_EDGE_SUB_CLASS_OF: + case META_EDGE_EQUIVALENT_TO: + case META_EDGE_SAME_AS: { + return direction === "added" ? + { + severity: "warning", + probeKinds: [ + "nodeUniqueness", + "nodeDisjointness", + "compositionExistence", + ], + } + : { severity: "warning", probeKinds: ["edgeEndpointAssignability"] }; + } + case META_EDGE_INVERSE_OF: + case META_EDGE_IMPLIES: { + return { severity: "breaking", probeKinds: [] }; + } + case META_EDGE_PART_OF: + case META_EDGE_HAS_PART: { + if (direction !== "added") { + return { + severity: relation.via === undefined ? "safe" : "breaking", + probeKinds: [], + }; + } + // Composition tightening: this arm only ever sees a BRAND-NEW pair — flipping an + // already-declared pair's `existence` is classified separately by + // `classifyExistenceChange` (relation identity, `via`/`partSide` + // included, excludes `existence`; see `keyedRelations`' docblock), + // so it never reaches here as a synthetic remove + add. A brand-new + // pair is under-probed by `compositionSingleWhole` alone when it is + // ALSO declared `required`: the pair's data must additionally be + // checked for a live part with no live whole, which + // `compositionRequiredWhole` does. + return { + severity: "warning", + probeKinds: + relation.existence === "required" ? + ["composition", "compositionExistence"] + : ["composition"], + }; + } + case META_EDGE_BROADER: + case META_EDGE_NARROWER: + case META_EDGE_RELATED_TO: + case META_EDGE_DIFFERENT_FROM: { + return { severity: "safe", probeKinds: [] }; + } + default: { + const exhaustive: never = metaEdge; + return exhaustive; + } + } +} + +/** + * A relation whose meta-edge is not built in: inert to the engine, but + * removing it drops the stored document's last copy of a relation no code + * path can declare any more (see the module docblock). + */ +function classifyCustomRelationSeverity( + direction: "added" | "removed", + relation: SerializedOntologyRelation, +): RelationSeverity { + if (direction === "added") return { severity: "safe", probeKinds: [] }; + const description = relationDescription(relation); + return { + severity: "breaking", + probeKinds: [], + details: + `Relation ${description} was removed. Its meta-edge "${relation.metaEdge}" ` + + `is not built in and can no longer be declared, so the stored schema ` + + `holds its last copy: move it into defineGraph({ annotations }) before ` + + `committing with migrateSchema()`, + removedCustomRelation: description, + }; +} + +function classifyRelationSeverity( + direction: "added" | "removed", + relation: SerializedOntologyRelation, +): RelationSeverity { + if (!isKnownMetaEdgeName(relation.metaEdge)) { + return classifyCustomRelationSeverity(direction, relation); + } + return classifyKnownRelationSeverity(direction, relation.metaEdge, relation); +} + +// ============================================================ +// Classification +// ============================================================ + +type RelationClassificationContext = Readonly<{ + removedKindNames: ReadonlySet; + disjointnessPairs: readonly (readonly [string, string])[]; + uniquenessGroups: readonly UniquenessComponentProbeGroup[]; + endpointAllowances: readonly EdgeEndpointAllowance[]; + inheritedRequiredPartEdgeKinds: readonly string[]; +}>; + +function carriesEndpointAssignabilityProbe(change: OntologyChange): boolean { + return (change.probes ?? []).some( + (probe) => probe.kind === "edgeEndpointAssignability", + ); +} + +function describeEndpointPairs( + pairs: readonly (readonly [string, string])[], +): string { + return pairs.map(([from, to]) => `(${from} -> ${to})`).join(", "); +} + +/** + * The change an admission loss is reported as when no relation change in + * the same diff already carries the diff-wide `edgeEndpointAssignability` + * probe — a narrowed `from` / `to` declaration, or a removed kind whose + * relations the removed-kind rule classified `safe`. + */ +function classifyEndpointAdmissionLoss( + loss: EdgeEndpointAdmissionLoss, +): OntologyChange { + const { edgeKind } = loss.allowance; + return { + type: "modified", + entity: "edgeRegistration", + name: edgeKind, + severity: "warning", + details: `Edge "${edgeKind}" no longer admits endpoint pairs: ${describeEndpointPairs(loss.lostPairs)}`, + probes: [ + stampProbeFamilies({ + kind: "edgeEndpointAssignability", + allowances: [loss.allowance], + }), + ], + }; +} + +function buildProbe( + kind: ProbeKind, + context: RelationClassificationContext, + relation: SerializedOntologyRelation, +): OntologyDataProbe { + switch (kind) { + case "nodeDisjointness": { + return stampProbeFamilies({ kind, pairs: context.disjointnessPairs }); + } + case "nodeUniqueness": { + return stampProbeFamilies({ kind, groups: context.uniquenessGroups }); + } + case "edgeEndpointAssignability": { + return stampProbeFamilies({ + kind, + allowances: context.endpointAllowances, + }); + } + case "composition": + case "compositionExistence": { + // A composition relation names its own realizing edge. A subsumption + // relation names none: what it makes a required part is diff-wide. + const ownEdgeKinds = relation.via === undefined ? [] : [relation.via]; + return stampProbeFamilies({ + kind, + edgeKinds: + isCompositionMetaEdge(relation.metaEdge) ? ownEdgeKinds : ( + context.inheritedRequiredPartEdgeKinds + ), + }); + } + } +} + +function classifyRelation( + direction: "added" | "removed", + relation: SerializedOntologyRelation, + context: RelationClassificationContext, +): OntologyChange { + const name = relationKey(relation); + const verb = direction === "added" ? "added" : "removed"; + + // The removed-kind rule (load-bearing): a relation naming a kind THIS + // COMMIT removes is itself safe with no probe. Without it, + // `Store.removeKinds("Company")` would be refused for edges pointing at + // `Company` rows the removal itself reclaims. Same reasoning as + // `migrateSchema`'s dropped-kind handling. What the removal costs a + // SURVIVING kind is not decided here: `edgeEndpointAdmissionLosses` + // reports it, and `classifyOntologyChanges` attaches the probe. A custom + // relation dropped this way is still named, since the stored document + // holds its last copy whatever happens to the kind. + const classified = classifyRelationSeverity(direction, relation); + const removedCustomRelation = + classified.removedCustomRelation === undefined ? + {} + : { removedCustomRelation: classified.removedCustomRelation }; + if ( + context.removedKindNames.has(relation.from) || + context.removedKindNames.has(relation.to) + ) { + return { + type: direction, + entity: "relation", + name, + severity: "safe", + details: `Relation ${relationDescription(relation)} was ${verb} alongside a removed kind`, + ...removedCustomRelation, + }; + } + + const probes = classified.probeKinds.map((kind) => + buildProbe(kind, context, relation), + ); + + return { + type: direction, + entity: "relation", + name, + severity: classified.severity, + details: + classified.details ?? + `Relation ${relationDescription(relation)} was ${verb}`, + ...(probes.length > 0 ? { probes } : {}), + ...removedCustomRelation, + }; +} + +/** + * Classifies an in-place `existence` flip on an already-declared `partOf`/ + * `hasPart` pair (same identity — `ontologyRelationIdentityKey` excludes + * `existence`, so this is the ONLY place that edit is classified; see + * `keyedRelations`' docblock). Mirrors the tightening/loosening split for the + * whole-pair `added` case above, but keyed off the existence delta directly + * rather than off presence/absence of the pair itself: + * + * - optional (or unset) → required: TIGHTENING. A required part with no + * live whole can already exist in the data (nothing constrained it + * before), so this needs the same audit a brand-new required pair does — + * `compositionSingleWhole` and `compositionRequiredWhole`. + * - required → optional (or unset): LOOSENING. Every state the tightened + * constraint forbade is still admitted; nothing newly forbidden. Safe, + * no probe. + * + * `after` (not `before`) is used for the description and probes: the probe + * plan audits the graph as it will be, and the realizing edge kind + * (`via`) cannot itself change between these two entries (a `via` change + * makes `ontologyRelationIdentityKey` disagree, so it would classify as + * remove + add instead of reaching this function). + */ +function classifyExistenceChange( + before: SerializedOntologyRelation, + after: SerializedOntologyRelation, + context: RelationClassificationContext, +): OntologyChange { + const name = relationKey(after); + const beforeExistence = before.existence ?? "optional"; + const afterExistence = after.existence ?? "optional"; + + if (afterExistence === "optional") { + // required → optional: loosening. + return { + type: "modified", + entity: "relation", + name, + severity: "safe", + details: `Relation ${relationDescription(after)} loosened existence from "${beforeExistence}" to "${afterExistence}"`, + }; + } + + // optional (or unset) → required: tightening. + return { + type: "modified", + entity: "relation", + name, + severity: "warning", + details: `Relation ${relationDescription(after)} tightened existence from "${beforeExistence}" to "${afterExistence}"`, + probes: [ + buildProbe("composition", context, after), + buildProbe("compositionExistence", context, after), + ], + }; +} + +/** + * The registry of the BEFORE document of a schema diff — THE one place a + * registry is built without the declaration rules. + * + * BEFORE is a delta input (disjointness, uniqueness groups and claim axes, + * endpoint allowances), never a registry a store reads or writes through: + * enforcing structural subsumption or the composition declaration rules on + * it would wedge the fix-forward migration that repairs a persisted document + * they refuse (see `StructuralSubsumptionMode`'s docblock). + */ +export function buildSchemaBaselineRegistry( + before: OntologySnapshot, +): KindRegistry { + return buildRegistryFromSerializedSchema(before, "unenforced-baseline"); +} + +/** + * Classifies every ontology change between two schema snapshots, with the + * data probes a commit of a tightening change owes. + * + * @throws ConfigurationError when `buildRegistryFromSerializedSchema` cannot + * interpret `before` or `after`. Only reached when the diff contains at + * least one relation change or narrows an edge kind's declared endpoints; + * a diff that does neither never needs a registry and so can never throw + * for this reason. See the module docblock for what the BEFORE side is + * excused from. + */ +export function classifyOntologyChanges( + before: OntologySnapshot, + after: OntologySnapshot, +): readonly OntologyChange[] { + const changes: OntologyChange[] = []; + const kindDelta = snapshotKindDelta(before, after); + + // Edge-registration `acyclic`. Independent of whether any + // ontology relation changed — an edge kind's acyclicity axiom shares no + // relation with `disjointWith` / `subClassOf` / `equivalentTo`, so this + // never needs a `KindRegistry` and runs unconditionally, before the + // relation-change early return below. + for (const edgeKind of edgeAcyclicityAddedDelta(before, after, kindDelta)) { + changes.push({ + type: "modified", + entity: "edgeRegistration", + name: edgeKind, + severity: "warning", + details: `Edge "${edgeKind}" declared acyclic: true`, + probes: [ + stampProbeFamilies({ kind: "edgeAcyclicity", edgeKinds: [edgeKind] }), + ], + }); + } + for (const edgeKind of edgeAcyclicityRemovedDelta(before, after, kindDelta)) { + changes.push({ + type: "modified", + entity: "edgeRegistration", + name: edgeKind, + severity: "safe", + details: `Edge "${edgeKind}" dropped acyclic: true`, + }); + } + + const beforeRelations = keyedRelations(before.ontology.relations); + const afterRelations = keyedRelations(after.ontology.relations); + const removedRelations = [...beforeRelations.entries()] + .filter(([key]) => !afterRelations.has(key)) + .map(([, relation]) => relation); + const addedRelations = [...afterRelations.entries()] + .filter(([key]) => !beforeRelations.has(key)) + .map(([, relation]) => relation); + // Same identity on both sides (`ontologyRelationIdentityKey` excludes + // `existence`) but the `existence` value itself differs: an in-place edit, + // classified by `classifyExistenceChange` rather than falling out of the + // remove/add sets above. + const modifiedExistenceRelations = [...afterRelations.entries()] + .filter(([key]) => beforeRelations.has(key)) + .map( + ([key, afterRelation]) => + [requireDefined(beforeRelations.get(key)), afterRelation] as const, + ) + .filter(([beforeRelation, afterRelation]) => { + return ( + (beforeRelation.existence ?? "optional") !== + (afterRelation.existence ?? "optional") + ); + }); + + const endpoints: SnapshotEdgeKindFacts = { + before: buildSerializedEdgeKindFacts(before.edges), + after: buildSerializedEdgeKindFacts(after.edges), + }; + if ( + removedRelations.length === 0 && + addedRelations.length === 0 && + modifiedExistenceRelations.length === 0 && + !edgeEndpointDeclarationsNarrowed(endpoints) + ) { + return changes; + } + + // Built only when a relation changed or an edge kind's declared endpoints + // narrowed: a diff that does neither (the overwhelming majority of schema + // commits) never pays for a registry build, and never risks surfacing an + // incoherent legacy ontology's `ConfigurationError` on a commit that has + // nothing to do with it. + // AFTER stays enforced (the default): a proposal that INTRODUCES an + // incompatible hierarchy is exactly what the preflight requires this diff + // to catch before an upgrade. + const beforeRegistry = buildSchemaBaselineRegistry(before); + const afterRegistry = buildRegistryFromSerializedSchema(after); + + const endpointAdmissionLosses = edgeEndpointAdmissionLosses( + endpoints, + kindDelta, + beforeRegistry, + afterRegistry, + ); + const context: RelationClassificationContext = { + removedKindNames: kindDelta.removedKindNames, + disjointnessPairs: nodeDisjointnessDelta(beforeRegistry, afterRegistry), + uniquenessGroups: nodeUniquenessComponentGroups( + before, + after, + beforeRegistry, + afterRegistry, + ), + endpointAllowances: endpointAdmissionLosses.map((loss) => loss.allowance), + inheritedRequiredPartEdgeKinds: newlyRequiredPartEdgeKinds( + beforeRegistry, + afterRegistry, + ), + }; + + for (const relation of removedRelations) { + changes.push(classifyRelation("removed", relation, context)); + } + for (const relation of addedRelations) { + changes.push(classifyRelation("added", relation, context)); + } + for (const [beforeRelation, afterRelation] of modifiedExistenceRelations) { + changes.push( + classifyExistenceChange(beforeRelation, afterRelation, context), + ); + } + + // The probe payload is diff-wide, so one relation change carrying it + // already covers every loss. Otherwise each loss is its own change, so + // the admission loss is never committed unprobed. + if (!changes.some((change) => carriesEndpointAssignabilityProbe(change))) { + for (const loss of endpointAdmissionLosses) { + changes.push(classifyEndpointAdmissionLoss(loss)); + } + } + + return changes; +} + +// ============================================================ +// The fold +// ============================================================ + +/** + * The probe families whose payload is a flat edge-kind list, in the + * deterministic order `ontologyTighteningProbes` emits them. + */ +const EDGE_KIND_PROBE_KINDS = [ + "edgeAcyclicity", + "composition", + "compositionExistence", +] as const; + +type EdgeKindProbeKind = (typeof EDGE_KIND_PROBE_KINDS)[number]; + +function pairTuple(pair: readonly [string, string]): readonly string[] { + return pair; +} + +function groupTuple(group: UniquenessComponentProbeGroup): readonly string[] { + return [group.constraintName, ...group.coveredKinds]; +} + +/** Map identity for a probe; ordering uses {@link compareStringTuples}. */ +function pairKey(pair: readonly [string, string]): string { + return encodeTupleKey(pairTuple(pair)); +} + +function groupKey(group: UniquenessComponentProbeGroup): string { + return encodeTupleKey(groupTuple(group)); +} + +/** + * The probe plan for a whole diff: every probe the classified changes carry, + * unioned per kind and deduped, with a probe kind DROPPED when its + * aggregated payload is empty — there is nothing for a commit preflight to + * check, so there is nothing to run. This is what makes + * `prepareSchemaTighteningPreflight` return `undefined` (no preflight + * owed, no atomic-backend requirement) for a tightening that happens to + * touch no live data, rather than for every `warning`-severity change + * unconditionally. + * + * Deterministic order: `nodeDisjointness`, `nodeUniquenessComponent`, then + * `edgeEndpointAssignability`, then `edgeAcyclicity`, then + * `compositionSingleWhole`, then `compositionRequiredWhole`. + */ +export function ontologyTighteningProbes( + changes: readonly OntologyChange[], +): readonly OntologyDataProbe[] { + const pairs = new Map(); + const groups = new Map(); + const allowances = new Map(); + const edgeKindProbes = new Map>(); + + for (const change of changes) { + for (const probe of change.probes ?? []) { + switch (probe.kind) { + case "nodeDisjointness": { + for (const pair of probe.pairs) pairs.set(pairKey(pair), pair); + break; + } + case "nodeUniqueness": { + for (const group of probe.groups) groups.set(groupKey(group), group); + break; + } + case "edgeEndpointAssignability": { + for (const allowance of probe.allowances) { + allowances.set(allowance.edgeKind, allowance); + } + break; + } + case "edgeAcyclicity": + case "composition": + case "compositionExistence": { + const collected = edgeKindProbes.get(probe.kind) ?? new Set(); + for (const edgeKind of probe.edgeKinds) collected.add(edgeKind); + edgeKindProbes.set(probe.kind, collected); + break; + } + } + } + } + + const result: OntologyDataProbe[] = []; + if (pairs.size > 0) { + result.push( + stampProbeFamilies({ + kind: "nodeDisjointness", + pairs: [...pairs.values()].toSorted((left, right) => + compareStringTuples(pairTuple(left), pairTuple(right)), + ), + }), + ); + } + if (groups.size > 0) { + result.push( + stampProbeFamilies({ + kind: "nodeUniqueness", + groups: [...groups.values()].toSorted((left, right) => + compareStringTuples(groupTuple(left), groupTuple(right)), + ), + }), + ); + } + if (allowances.size > 0) { + result.push( + stampProbeFamilies({ + kind: "edgeEndpointAssignability", + allowances: [...allowances.values()].toSorted((left, right) => + compareStrings(left.edgeKind, right.edgeKind), + ), + }), + ); + } + for (const kind of EDGE_KIND_PROBE_KINDS) { + const collected = edgeKindProbes.get(kind); + if (collected === undefined || collected.size === 0) continue; + result.push( + stampProbeFamilies({ + kind, + edgeKinds: [...collected].toSorted(compareStrings), + }), + ); + } + return result; +} diff --git a/packages/typegraph/src/schema/serializer.ts b/packages/typegraph/src/schema/serializer.ts index 613e56655..10c9607f3 100644 --- a/packages/typegraph/src/schema/serializer.ts +++ b/packages/typegraph/src/schema/serializer.ts @@ -13,6 +13,7 @@ import { type GraphDef, } from "../core/define-graph"; import { isEdgeTargetMap, projectTargetKinds } from "../core/edge-endpoints"; +import { serializedTargetCardinalityField } from "../core/edge-integrity-options"; import { canonicalAnnotations } from "../core/json-value"; import { type EdgeRegistration, @@ -20,6 +21,10 @@ import { type NullCheckOp, type UniqueConstraint, } from "../core/types"; +import { + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + withUniqueConstraintDefaults, +} from "../core/unique-constraint-options"; import { ConfigurationError } from "../errors"; import { type GraphExtension, @@ -36,11 +41,21 @@ import { type MetaEdge, type OntologyRelation, } from "../ontology/types"; -import { computeClosuresFromOntology } from "../registry/kind-registry"; +import { type NamedOntologyRelation } from "../ontology/validation"; +import { + canonicalCompositionPartSide, + compositionRelationFields, +} from "../registry/composition-relation"; +import { buildSerializedEdgeKindFacts } from "../registry/edge-kind-facts"; +import { + computeClosuresFromNamedOntology, + KindRegistry, + type RegistryClosures, +} from "../registry/kind-registry"; import { nowIso } from "../utils/date"; import { sha256Hex } from "../utils/hash"; import { createDataKeyedBag } from "../utils/object"; -import { sortedReplacer } from "./canonical"; +import { canonicalJson } from "./canonical"; import { type JsonSchema, type SchemaHash, @@ -71,7 +86,7 @@ export function serializeSchema( ): SerializedSchema { const nodes = serializeNodes(graph); const edges = serializeEdges(graph); - const ontology = serializeOntology(graph.ontology); + const ontology = serializeOntology(graph.ontology, edges); const indexes = serializeIndexes(graph.indexes); const extension = canonicalExtension(graph.extension); const deprecatedKinds = serializeDeprecatedKinds(graph.deprecatedKinds); @@ -354,14 +369,22 @@ function serializeNodeDef(registration: NodeRegistration): SerializedNodeDef { function serializeUniqueConstraints( constraints: readonly UniqueConstraint[], ): readonly SerializedUniqueConstraint[] { - return constraints.map((constraint) => ({ - name: constraint.name, - fields: [...constraint.fields], - where: - constraint.where ? serializeWherePredicate(constraint.where) : undefined, - scope: constraint.scope, - collation: constraint.collation, - })); + return constraints.map((constraint) => { + const { scope, collation } = withUniqueConstraintDefaults( + constraint, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + ); + return { + name: constraint.name, + fields: [...constraint.fields], + where: + constraint.where ? + serializeWherePredicate(constraint.where) + : undefined, + scope, + collation, + }; + }); } /** @@ -512,6 +535,13 @@ function serializeEdgeDef(registration: EdgeRegistration): SerializedEdgeDef { ...(targetKindsBySource === undefined ? {} : { targetKindsBySource }), properties: serializeZodSchema(edge.schema), cardinality: registration.cardinality ?? "many", + // Omitted (rather than always written, like `cardinality`) when the + // registration leaves it undeclared or declares the default explicitly: + // a graph that never uses this option must serialize byte-identically, + // and hash byte-identically, to a document produced before this option + // existed — the deserializer's `.default("many")` (`src/schema/types.ts`) + // is what reads an absent key back as unconstrained. + ...serializedTargetCardinalityField(registration.targetCardinality), endpointExistence: registration.endpointExistence ?? "notDeleted", ...(registration.matchIdentity === undefined ? {} @@ -521,6 +551,10 @@ function serializeEdgeDef(registration: EdgeRegistration): SerializedEdgeDef { fields: [...registration.matchIdentity.fields], }, }), + // Emitted only when true: `computeSchemaHash` hashes this document, so + // emitting `acyclic: false` on every edge would move the hash of every + // existing graph on next open (see `SerializedEdgeDef.acyclic`). + ...(registration.acyclic === true ? { acyclic: true } : {}), description: edge.description, ...(annotations === undefined ? {} : { annotations }), }; @@ -532,9 +566,15 @@ function serializeEdgeDef(registration: EdgeRegistration): SerializedEdgeDef { /** * Serializes the complete ontology. + * + * `metaEdges` is derived 1:1 from `relations` — see + * `SerializedOntology.metaEdges`'s docblock — so it is computed here purely + * for introspection and carries no information `classifyOntologyChanges` + * (`src/schema/ontology-change.ts`) needs; that module diffs `relations`. */ function serializeOntology( relations: readonly OntologyRelation[], + edges: Record, ): SerializedOntology { // Collect unique meta-edges const metaEdgeMap = new Map(); @@ -552,18 +592,24 @@ function serializeOntology( metaEdges[name] = serializeMetaEdge(metaEdge); } - // Serialize relations - const serializedRelations = relations.map((relation) => + const namedRelations = relations.map((relation) => serializeOntologyRelation(relation), ); + if (namedRelations.length === 0) { + return { + metaEdges: { ...metaEdges }, + relations: namedRelations, + closures: emptySerializedClosures(), + }; + } - // Compute and serialize closures - const closures = serializeClosures(relations); - + // Closures read only each relation's meta-edge and endpoints, so the + // relations before `partSide` canonicalization compute the same ones. + const closures = computeClosuresFromNamedOntology(namedRelations); return { metaEdges: { ...metaEdges }, - relations: serializedRelations, - closures, + relations: withCanonicalPartSides(namedRelations, edges, closures), + closures: serializeComputedClosures(closures), }; } @@ -571,15 +617,18 @@ function serializeOntology( * Serializes a meta-edge. */ function serializeMetaEdge(metaEdge: MetaEdge): SerializedMetaEdge { - return { - name: metaEdge.name, - transitive: metaEdge.properties.transitive, - symmetric: metaEdge.properties.symmetric, - reflexive: metaEdge.properties.reflexive, - inverse: metaEdge.properties.inverse, - inference: metaEdge.properties.inference, - description: metaEdge.properties.description, - }; + return serializedMetaEdge(metaEdge.name, metaEdge.properties.description); +} + +/** + * The fields a serialized meta-edge carries — everything else an earlier + * release wrote is dropped (see {@link canonicalStoredSchemaDocument}). + */ +function serializedMetaEdge( + name: string, + description: string | undefined, +): SerializedMetaEdge { + return { name, description }; } /** @@ -592,34 +641,73 @@ function serializeOntologyRelation( metaEdge: relation.metaEdge.name, from: getTypeName(relation.from), to: getTypeName(relation.to), + ...compositionRelationFields(relation), }; } /** - * Serializes precomputed closures. + * `relations` with each composition `partSide` reduced to + * `canonicalCompositionPartSide`: a side the realizing edge's endpoints + * already decide is omitted, so spelling it out does not change the document, + * its hash, or the relation's identity in a diff. A side the edge admits both + * ways is kept, since it is what decides the relation. + */ +function withCanonicalPartSides( + relations: readonly SerializedOntologyRelation[], + edges: Record, + closures: RegistryClosures, +): readonly SerializedOntologyRelation[] { + if (!relations.some((relation) => relation.partSide !== undefined)) { + return relations; + } + const registry = new KindRegistry(new Map(), new Map(), closures); + const edgeFacts = buildSerializedEdgeKindFacts(edges); + return relations.map((relation) => { + if (relation.partSide === undefined) return relation; + const { partSide: _declared, ...rest } = relation; + const partSide = canonicalCompositionPartSide( + relation, + edgeFacts, + registry, + ); + return partSide === undefined ? rest : { ...rest, partSide }; + }); +} + +/** + * Serializes the closures precomputed from a set of relations. Takes the + * relations by name, so a stored document's own relations recompute exactly + * the closures this release writes for them (see + * {@link canonicalStoredSchemaDocument}). */ function serializeClosures( - relations: readonly OntologyRelation[], + relations: readonly NamedOntologyRelation[], ): SerializedClosures { - if (relations.length === 0) { - return { - subClassAncestors: {}, - subClassDescendants: {}, - broaderClosure: {}, - narrowerClosure: {}, - equivalenceSets: {}, - disjointPairs: [], - partOfClosure: {}, - hasPartClosure: {}, - iriToKind: {}, - edgeInverses: {}, - edgeImplicationsClosure: {}, - edgeImplyingClosure: {}, - }; - } + return relations.length === 0 ? + emptySerializedClosures() + : serializeComputedClosures(computeClosuresFromNamedOntology(relations)); +} - const computed = computeClosuresFromOntology(relations); +function emptySerializedClosures(): SerializedClosures { + return { + subClassAncestors: {}, + subClassDescendants: {}, + broaderClosure: {}, + narrowerClosure: {}, + equivalenceSets: {}, + disjointPairs: [], + partOfClosure: {}, + hasPartClosure: {}, + iriToKind: {}, + edgeInverses: {}, + edgeImplicationsClosure: {}, + edgeImplyingClosure: {}, + }; +} +function serializeComputedClosures( + computed: RegistryClosures, +): SerializedClosures { return { subClassAncestors: mapToRecord(computed.subClassAncestors), subClassDescendants: mapToRecord(computed.subClassDescendants), @@ -695,14 +783,46 @@ export function serializeSchemaProperties(schema: z.ZodType): JsonSchema { return computed; } +/** + * What a schema `z.toJSONSchema` cannot convert projects as: `z.set()`, + * `z.map()`, `z.date()`, `z.bigint()`, a `.transform()`, a `z.custom()` + * (which is what `embedding()` is), and any other construct with no JSON + * Schema spelling. ONE such field collapses the whole kind to this + * placeholder, so it states nothing about the kind's properties: it is the + * absence of a projection, in the persisted form documents have always + * carried (changing that form would change every such graph's schema hash). + * A live graph's structural-subsumption check does not compare this form: it + * marks each unconvertible node on its own (`structuralSubtypeProjection`). + * + * A genuine projection is never mistaken for it, because Zod stamps every + * converted schema with `$schema` — an empty `z.object({})` included. + */ +const UNPROJECTABLE_SCHEMA_PROJECTION: JsonSchema = Object.freeze({ + type: "object", +}); + +/** + * Whether `projection` is the unprojectable placeholder rather than a + * schema. The one owner of that decision, for a live graph's projection and + * a persisted document's alike: a consumer that compares projections must + * ask this first, since the placeholder compared as a schema reads as an + * open object declaring no properties — a false statement about the kind. + */ +export function isUnprojectableSchemaProjection( + projection: JsonSchema, +): boolean { + const keys = Object.keys(projection); + return ( + keys.length === 1 && + projection.type === UNPROJECTABLE_SCHEMA_PROJECTION.type + ); +} + function serializeZodSchema(schema: z.ZodType): JsonSchema { try { - // Zod 4 has toJSONSchema as a standard export - const jsonSchema = z.toJSONSchema(schema); - return jsonSchema as JsonSchema; + return z.toJSONSchema(schema) as JsonSchema; } catch { - // Fallback for schemas that can't be converted - return { type: "object" }; + return UNPROJECTABLE_SCHEMA_PROJECTION; } } @@ -775,6 +895,74 @@ export async function getGraphDefinitionHash( return getSchemaHash(graph, DEFINITION_HASH_VERSION); } +/** + * A stored schema document in the form this release's serializer writes for + * the schema it describes, so its {@link computeSchemaHash} is comparable with + * the hash of a graph serialized today. + * + * A document written by an earlier release, or read back through the parser, + * differs from that form only in derived or defaulted content, never in what + * the schema states: + * + * - `ontology.metaEdges` entries carried `transitive`/`symmetric`/`reflexive`/ + * `inverse`/`inference`, which this release no longer writes. + * - `ontology.closures` are derived from `ontology.relations`; an earlier + * release derived some of them differently (the `equivalentTo` closures). + * They are recomputed from the document's own relations. + * - An absent edge `targetCardinality` means `"many"`, which the serializer + * omits. + * - An absent unique-constraint `scope` or `collation` means what + * `NODE_UNIQUE_CONSTRAINT_DEFAULTS` says, which `defineGraph` writes into + * the declaration an untyped caller left them out of, so the serializer + * writes them out. + * + * The document must be the stored JSON as written, not the output of the + * schema parser: the parser fills in defaults, and the canonical form is + * defined over what was stored. + * + * Everything else is kept verbatim, so a document that differs from today's + * serialization in anything the schema states still hashes differently. + */ +export function canonicalStoredSchemaDocument( + document: SerializedSchema, +): SerializedSchema { + // Data-keyed: node and edge kind names and meta-edge names. + const nodes = createDataKeyedBag(); + for (const [kind, node] of Object.entries(document.nodes)) { + nodes[kind] = { + ...node, + uniqueConstraints: node.uniqueConstraints.map((constraint) => + withUniqueConstraintDefaults( + constraint, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + ), + ), + }; + } + const edges = createDataKeyedBag(); + for (const [kind, edge] of Object.entries(document.edges)) { + const { targetCardinality, ...rest } = edge; + edges[kind] = { + ...rest, + ...serializedTargetCardinalityField(targetCardinality), + }; + } + const metaEdges = createDataKeyedBag(); + for (const [name, metaEdge] of Object.entries(document.ontology.metaEdges)) { + metaEdges[name] = serializedMetaEdge(metaEdge.name, metaEdge.description); + } + return { + ...document, + nodes: { ...nodes }, + edges: { ...edges }, + ontology: { + ...document.ontology, + metaEdges: { ...metaEdges }, + closures: serializeClosures(document.ontology.relations), + }, + }; +} + /** * Computes a hash of the schema content for change detection. * @@ -803,6 +991,5 @@ export async function computeSchemaHash( }; // Serialize with sorted keys for deterministic output - const json = JSON.stringify(hashable, sortedReplacer); - return sha256Hex(json); + return sha256Hex(canonicalJson(hashable)); } diff --git a/packages/typegraph/src/schema/structural-subtype-projection.ts b/packages/typegraph/src/schema/structural-subtype-projection.ts new file mode 100644 index 000000000..deafb19bf --- /dev/null +++ b/packages/typegraph/src/schema/structural-subtype-projection.ts @@ -0,0 +1,728 @@ +/** + * The projection a live graph's structural-subsumption check compares: the + * kind's persisted JSON Schema projection, plus a marker wherever the Zod + * schema constrains values in a way that projection cannot state. + * + * `z.toJSONSchema` drops eleven such facts, each of which can make + * `isStructuralSubtype` answer `subtype` for a pair whose child admits a value + * its parent refuses: + * + * - A regex's flags, on whichever check carries the pattern (`.regex()`, + * `z.stringFormat()`, `z.email({ pattern })`). `/^abc$/i` and `/^abc$/` both + * project as `pattern: "^abc$"`. Only `g`, which `RegExp.prototype.test` semantics + * under Zod do not depend on, is harmless. + * - A `.refine()` / `.superRefine()` / `.check()` refinement, which projects + * as its base schema. On a CHILD it only narrows, which the verdict may + * ignore; on a PARENT it is a constraint the child is never compared with. + * - A strict object (`z.strictObject`, `.strict()`). It projects exactly like + * a stripping `z.object()`, but refuses a key the child may add. + * - A `.meta()` key spelled like a constraining keyword. It is merged into + * the projection as if it were one, though it constrains nothing. + * - A string format a later format replaced. Only the last `format` is kept, + * so `z.url().startsWith("https://")` loses its URL check. + * - An overwrite (`.trim()`, `.toLowerCase()`, `.toUpperCase()`, + * `.normalize()`, `.overwrite()`), which projects nothing, though the + * checks after it judge the transformed value and the node stores it. + * - A literal `NaN` or `±Infinity`, which projects as `null`. + * - A pipe's input side (`.pipe()`, `z.preprocess()`, `z.codec()`): only the + * output side projects, so a bound on the input, or a function that + * rewrites the value first, is lost. Marked on the parent side unless the + * pipe carries no transform and its input is `z.unknown()`/`z.any()` or a + * check-free primitive of the output's own type, which the output then + * judges in full. + * - Which keys an object stores and which it requires: Zod decides both + * from each property's `optin`/`optout`, which the projection's single + * `required` does not follow in either direction (an exhaustive record + * whose values are optional lists keys it may leave out; + * `z.unknown().pipe(z.string().optional())` refuses a missing key it does + * not list), so each property's are recorded. + * - A `.default()` / `.catch()` value wrapped by another one: only the + * outermost reaches `default`, though each is stored unvalidated, so all + * are listed under {@link STORED_FALLBACKS_KEYWORD}. + * - A schema that is itself a check (`z.int64()`, `z.uint64()`): its bound + * sits on its own definition, not among its chained checks, so it is never + * read as a plain unconvertible node. + * + * The persisted projection itself is not changed: it is the schema document + * and its hash, and none of these facts was ever part of it. The markers live + * only in this comparison input, so a stored document's own registry (which + * has no Zod schema) never refuses what a live graph accepts — the live check + * is the stricter of the two, and every commit path, `createStore` and the + * `getSchemaChanges` pre-flight build it. + * + * A marker's value names the Zod instance that carries the fact, so a child + * that reuses its parent's schema (or a field schema they share) still + * compares equal under rule 2 of `isStructuralSubtype`, while two different + * refinements never do. + * + * A node `z.toJSONSchema` has no spelling for at all (`embedding()` and any + * other `z.custom()`, `z.date()`, `z.set()`, `z.map()`, `z.bigint()`, a + * `.transform()`) makes the persisted projection give up on the whole kind, + * which projects as the unprojectable placeholder. Here an `embedding()` is + * written as the fixed-length number array it validates + * (`embeddingProjection`), and every other such node is replaced by + * {@link UNPROJECTABLE_NODE_KEYWORD} instead, carrying the + * node's identity (`stampUnprojectableIdentity`), so every other field of + * the kind is still compared. `isStructuralSubtype` accepts such a node only + * under a parent node that admits every value, or beside a node of the same + * identity. A node Zod converts only in part (a dynamic `.catch()` + * keeps its `type`) is marked as carrying a Zod-only constraint instead. The + * persisted placeholder is kept when this projection cannot do better: a + * conversion that fails for another reason. + */ +import { z } from "zod"; + +import { EMBEDDING_DIMENSIONS_KEY, isEmbeddingSchema } from "../core/embedding"; +import { + isUnprojectableSchemaProjection, + serializeSchemaProperties, +} from "./serializer"; +import { + COMPARABLE_KEYWORDS, + KEY_OPTIONALITY_KEYWORD, + type KeyOptionality, + STORED_FALLBACKS_KEYWORD, + UNMODELED_CONSTRAINING_KEYWORDS, + UNPROJECTABLE_NODE_KEYWORD, + ZOD_ONLY_CONSTRAINT_KEYWORD, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, +} from "./structural-subtype"; +import { type JsonSchema } from "./types"; + +const PROJECTION_CACHE = new WeakMap(); + +/** A stable token per Zod object, so equal markers mean the same instance. */ +const INSTANCE_TOKENS = new WeakMap(); +let nextInstanceToken = 0; + +function instanceToken(instance: object): string { + const existing = INSTANCE_TOKENS.get(instance); + if (existing !== undefined) return existing; + nextInstanceToken += 1; + const token = String(nextInstanceToken); + INSTANCE_TOKENS.set(instance, token); + return token; +} + +/** The parts of Zod's internal check definition this module reads. */ +type ZodCheckDef = Readonly<{ + check?: string; + format?: string; + pattern?: RegExp; +}>; + +type ZodNodeDef = Readonly<{ + type?: string; + pattern?: RegExp; + values?: readonly unknown[]; + checks?: readonly Readonly<{ _zod: Readonly<{ def: ZodCheckDef }> }>[]; + catchall?: Readonly<{ _zod: Readonly<{ def: Readonly<{ type?: string }> }> }>; + in?: z.core.$ZodTypes; + out?: z.core.$ZodTypes; + valueType?: z.ZodType; + shape?: Readonly>; + transform?: unknown; +}>; + +const CUSTOM_CHECK = "custom"; +const OVERWRITE_CHECK = "overwrite"; +const STRING_FORMAT_CHECK = "string_format"; +const LITERAL_NODE_TYPE = "literal"; + +/** + * A node's checks in the order Zod runs them. A schema that is itself a + * check (`z.email()`, `z.url()`, `z.int()`) runs its own check before the + * checks chained onto it. + */ +function checksInRunOrder(zodSchema: z.core.$ZodTypes): readonly ZodCheckDef[] { + const def = zodSchema._zod.def as ZodNodeDef; + const chained = (def.checks ?? []).map((check) => check._zod.def); + return zodSchema._zod.traits.has("$ZodCheck") ? [def, ...chained] : chained; +} + +/** + * Whether a node states more than one string format. `z.toJSONSchema` keeps + * only the last one, so `z.url().startsWith("https://")` projects as a + * `starts_with` format and loses the URL check entirely. + */ +function overwritesFormat(checks: readonly ZodCheckDef[]): boolean { + return ( + checks.filter((check) => check.check === STRING_FORMAT_CHECK).length > 1 + ); +} + +type OverwritePlacement = Readonly<{ + /** An overwrite runs before a check, which then judges the new value. */ + beforeCheck: boolean; + /** An overwrite runs after a check, so the output may fail that check. */ + afterCheck: boolean; +}>; + +/** + * Where a node's `overwrite` checks (`.trim()`, `.toLowerCase()`, + * `.toUpperCase()`, `.normalize()`, `.overwrite()`) run relative to the + * checks the projection states. None projects anything. A refinement is + * left out: it is marked on its own, and only a parent's matters. + */ +function overwritePlacement( + checks: readonly ZodCheckDef[], +): OverwritePlacement { + const overwrites: number[] = []; + const stated: number[] = []; + for (const [index, check] of checks.entries()) { + if (check.check === OVERWRITE_CHECK) overwrites.push(index); + else if (check.check !== CUSTOM_CHECK) stated.push(index); + } + if (overwrites.length === 0 || stated.length === 0) { + return { beforeCheck: false, afterCheck: false }; + } + return { + beforeCheck: Math.min(...overwrites) < Math.max(...stated), + afterCheck: Math.min(...stated) < Math.max(...overwrites), + }; +} + +/** + * Whether a literal holds a value JSON cannot carry: the projection writes + * `NaN` and `±Infinity` as `null`. + */ +function hasNonJsonLiteralValue(def: ZodNodeDef): boolean { + return ( + def.type === LITERAL_NODE_TYPE && + (def.values ?? []).some( + (value) => typeof value === "number" && !Number.isFinite(value), + ) + ); +} + +/** Regex flags that change which strings match. */ +function hasSemanticRegexFlags(pattern: RegExp): boolean { + return pattern.flags.replaceAll("g", "") !== ""; +} + +function isConstrainingKeyword(key: string): boolean { + return ( + COMPARABLE_KEYWORDS.has(key) || UNMODELED_CONSTRAINING_KEYWORDS.has(key) + ); +} + +function markOpaque( + jsonSchema: Record, + keyword: string, + token: string, +): void { + const existing = jsonSchema[keyword]; + jsonSchema[keyword] = + typeof existing === "string" ? `${existing} ${token}` : token; +} + +function markSemanticRegexFlags( + jsonSchema: Record, + pattern: RegExp | undefined, +): void { + if (pattern === undefined || !hasSemanticRegexFlags(pattern)) return; + markOpaque(jsonSchema, ZOD_ONLY_CONSTRAINT_KEYWORD, `flags:${pattern.flags}`); +} + +/** + * Marks the checks `z.toJSONSchema` drops without trace: a string format a + * later format replaced, an overwrite, and a literal value JSON cannot carry. + * + * A replaced format only matters on a parent: the child's projection then + * states less than the child checks, which can only cost a refusal. A + * literal `NaN` or `±Infinity` matters on both sides, since its projection + * names a different value set altogether. + * + * An overwrite matters on a parent when a stated check runs after it, since + * that check judges the transformed value, not the child's. It matters on a + * child when a stated check runs before it, since the child then stores a + * value that check never saw: `z.string().min(2).trim()` stores `"a"`. A + * child whose overwrites all run first stores only values its stated checks + * accepted, so it stays comparable. + */ +function markLostChecks( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const checks = checksInRunOrder(context.zodSchema); + const token = instanceToken(context.zodSchema); + if (overwritesFormat(checks)) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, + `formats:${token}`, + ); + } + const overwrites = overwritePlacement(checks); + if (overwrites.afterCheck) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_CONSTRAINT_KEYWORD, + `overwrite:${token}`, + ); + } + if (overwrites.beforeCheck) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, + `overwrite:${token}`, + ); + } + if (hasNonJsonLiteralValue(context.zodSchema._zod.def as ZodNodeDef)) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_CONSTRAINT_KEYWORD, + `literal:${token}`, + ); + } +} + +/** + * Input nodes a pipe can hand to its output without the projection losing + * anything: one that admits every value, or a check-free primitive of the + * output's own type, which the output then judges in full. + */ +const PASS_THROUGH_PIPE_INPUT_TYPES: ReadonlySet = new Set([ + "unknown", + "any", +]); +const PASS_THROUGH_PRIMITIVE_TYPES: ReadonlySet = new Set([ + "string", + "number", + "boolean", + "bigint", + "null", +]); + +function passesInputThrough( + input: z.core.$ZodTypes, + output: z.core.$ZodTypes, +): boolean { + const inputType = input._zod.def.type; + if (PASS_THROUGH_PIPE_INPUT_TYPES.has(inputType)) return true; + return ( + PASS_THROUGH_PRIMITIVE_TYPES.has(inputType) && + inputType === output._zod.def.type && + checksInRunOrder(input).length === 0 + ); +} + +/** + * A pipe (`.pipe()`, `z.preprocess()`, `z.codec()`) projects as its output + * side alone, so whatever its input side requires — a bound, a format, a + * preprocessing function that rewrites the value before the output judges + * it — is lost. On a parent that is a constraint the child is never compared + * with. A child's pipe stores only what its output side admits, so the + * output projection states it in full. + */ +function markLossyPipe( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const def = context.zodSchema._zod.def as ZodNodeDef; + if (def.type !== "pipe" || def.in === undefined || def.out === undefined) { + return; + } + // A codec's decode function rewrites the value between the two sides. + if (def.transform === undefined && passesInputThrough(def.in, def.out)) { + return; + } + markOpaque( + context.jsonSchema, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, + `pipe:${instanceToken(context.zodSchema)}`, + ); +} + +type ZodOptionality = Readonly<{ + optin?: "optional" | "defaulted"; + optout?: "optional"; +}>; + +function zodOptionality(schema: z.core.$ZodType): ZodOptionality { + const { optin, optout } = schema._zod as unknown as ZodOptionality; + return { + ...(optin === undefined ? {} : { optin }), + ...(optout === undefined ? {} : { optout }), + }; +} + +/** Node types whose output may be `undefined` whatever their optionality. */ +const UNDEFINED_YIELDING_TYPES: ReadonlySet = new Set([ + "unknown", + "any", + "undefined", + "void", + "custom", + "transform", + "lazy", +]); +const INNER_DECIDED_TYPES: ReadonlySet = new Set([ + "nullable", + "readonly", + "prefault", +]); + +/** + * Whether a schema can output `undefined` for an `undefined` input or from a + * function: an `unknown`, a transform or custom function, or a pipe or codec + * whose output side can. Decided from the schema's structure; no schema code + * runs. + */ +function mayYieldUndefined(schema: z.core.$ZodType): boolean { + const def = schema._zod.def as unknown as Readonly<{ + type?: string; + transform?: unknown; + out?: z.core.$ZodType; + innerType?: z.core.$ZodType; + options?: readonly z.core.$ZodType[]; + }>; + const type = def.type ?? ""; + if (UNDEFINED_YIELDING_TYPES.has(type)) return true; + if (type === "pipe") { + return ( + def.transform !== undefined || + (def.out !== undefined && mayYieldUndefined(def.out)) + ); + } + if (INNER_DECIDED_TYPES.has(type) && def.innerType !== undefined) { + return mayYieldUndefined(def.innerType); + } + if (type === "union") { + return (def.options ?? []).some((option) => mayYieldUndefined(option)); + } + return false; +} + +/** + * Whether an object always stores a property, read off the same + * `optin`/`optout` Zod's object parser decides it from: a property whose + * input is required is always present, and a defaulted one is filled in + * unless its output may be absent or `undefined`. (A transform that returns `undefined` + * for a present value is the one case this does not see; such a node is + * unconvertible, so it narrows only a parent field that admits anything.) + */ +function objectStoresProperty(field: z.core.$ZodType): boolean { + const { optin, optout } = zodOptionality(field); + if (optin === undefined) return true; + // A defaulted union runs every member on the missing value, and one that + // admits `undefined` (`z.union([z.unknown(), x.default(v)])`) supplies it. + return ( + optin === "defaulted" && optout !== "optional" && !mayYieldUndefined(field) + ); +} + +/** + * Whether an exhaustive record always stores a key. Zod runs the value + * schema on a missing key's `undefined` and stores what it returns, so the + * key is stored unless that may be `undefined`. + */ +function recordStoresKey(value: z.core.$ZodType): boolean { + return ( + zodOptionality(value).optout !== "optional" && !mayYieldUndefined(value) + ); +} + +/** + * Whether a value schema surely admits a missing value: optional on its + * input and its output, as `.optional()` is. Anything else may refuse it, + * so a parent record's key is read as required — the stricter reading. + */ +function admitsMissingValue(value: z.core.$ZodType): boolean { + const { optin, optout } = zodOptionality(value); + return optin !== undefined && optout === "optional"; +} + +/** + * Records, for each property of an object or each key an exhaustive record + * lists, whether it is always stored and whether its absence is refused + * (see {@link KEY_OPTIONALITY_KEYWORD}). Read off Zod's own metadata and the + * schema's structure; no schema code runs. Zod's `required` for an + * exhaustive record lists every key, so a record's key is read as required + * unless its value is optional on both sides ({@link admitsMissingValue}). + */ +function recordKeyOptionality( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const def = context.zodSchema._zod.def as ZodNodeDef; + const { jsonSchema } = context; + const declared = new Set( + Array.isArray(jsonSchema["required"]) ? jsonSchema["required"] : [], + ); + if (def.type === "object" && def.shape !== undefined) { + jsonSchema[KEY_OPTIONALITY_KEYWORD] = Object.fromEntries( + Object.entries(def.shape).map(([key, field]) => [ + key, + { + ...zodOptionality(field), + stored: objectStoresProperty(field), + required: + declared.has(key) || zodOptionality(field).optin === undefined, + } satisfies KeyOptionality, + ]), + ); + return; + } + if (def.type === "record" && def.valueType !== undefined) { + const entry = { + ...zodOptionality(def.valueType), + stored: recordStoresKey(def.valueType), + required: !admitsMissingValue(def.valueType), + } satisfies KeyOptionality; + jsonSchema[KEY_OPTIONALITY_KEYWORD] = Object.fromEntries( + [...declared].map((key) => [String(key), entry]), + ); + } +} + +function inheritedFallbacks( + jsonSchema: Record, +): readonly unknown[] { + const inherited = jsonSchema[STORED_FALLBACKS_KEYWORD] as + Readonly<{ values?: unknown }> | undefined; + return Array.isArray(inherited?.values) ? inherited.values : []; +} + +/** + * Lists every value a node stores unvalidated, with the outermost + * `.default()`/`.catch()` instance that stores them (see + * {@link STORED_FALLBACKS_KEYWORD}). Zod visits a wrapper after the node it + * wraps and copies the wrapped result, so each one adds its own value to the + * list it inherited. + */ +function listStoredFallbacks( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const type = context.zodSchema._zod.def.type; + if (type !== "default" && type !== "catch") return; + const { jsonSchema } = context; + if (!Object.hasOwn(jsonSchema, "default")) return; + jsonSchema[STORED_FALLBACKS_KEYWORD] = { + instance: instanceToken(context.zodSchema), + values: [...inheritedFallbacks(jsonSchema), jsonSchema["default"]], + }; +} + +function markZodOnlyConstraints( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const def = context.zodSchema._zod.def as ZodNodeDef; + // A string-format schema (`z.email()`, `z.stringFormat()`) carries its + // pattern on its own definition rather than on a check. + markSemanticRegexFlags(context.jsonSchema, def.pattern); + for (const check of def.checks ?? []) { + const checkDef = check._zod.def; + if (checkDef.check === CUSTOM_CHECK) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, + `refinement:${instanceToken(check)}`, + ); + } + markSemanticRegexFlags(context.jsonSchema, checkDef.pattern); + } + markLostChecks(context); + if (def.type === "object" && def.catchall?._zod.def.type === "never") { + markOpaque( + context.jsonSchema, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, + "strict", + ); + } + const meta = z.globalRegistry.get(context.zodSchema); + if ( + meta !== undefined && + Object.keys(meta).some((key) => isConstrainingKeyword(key)) + ) { + markOpaque( + context.jsonSchema, + ZOD_ONLY_CONSTRAINT_KEYWORD, + `meta:${instanceToken(context.zodSchema)}`, + ); + } +} + +/** + * Zod also reports as unconvertible a fact of a node it otherwise converts (a + * dynamic `.catch()`, a `multipleOf` of zero, a bigint default) and merges the + * marker beside the keywords it did write. Accepting that node unjudged would + * skip the `type` it still states, so the lost fact is marked as a Zod-only + * constraint instead, which leaves the node comparable only to itself. + */ +function markPartlyConvertedNode( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const { jsonSchema } = context; + const nodeType = jsonSchema[UNPROJECTABLE_NODE_KEYWORD]; + if (typeof nodeType !== "string") return; + if (!Object.keys(jsonSchema).some((key) => isConstrainingKeyword(key))) { + return; + } + Reflect.deleteProperty(jsonSchema, UNPROJECTABLE_NODE_KEYWORD); + markOpaque( + jsonSchema, + ZOD_ONLY_CONSTRAINT_KEYWORD, + `unconvertible:${nodeType}:${instanceToken(context.zodSchema)}`, + ); +} + +/** + * Zod node types whose values an unconvertible node with no checks fully + * describes: two such nodes of one type admit exactly the same values. + */ +const PLAIN_UNPROJECTABLE_NODE_TYPES: ReadonlySet = new Set([ + "date", + "bigint", + "symbol", + "undefined", + "void", + "nan", +]); + +/** + * Wrappers that store exactly what the node they wrap stores, so a node + * keeps the identity of the one it wraps. Optionality is compared by the + * enclosing object's `required`. + */ +const IDENTITY_PRESERVING_WRAPPER_TYPES: ReadonlySet = new Set([ + "optional", + "readonly", + "lazy", +]); + +const UNPROJECTABLE_IDENTITY_SEPARATOR = "#"; + +/** + * What an `embedding()` validates, as JSON Schema: an array of exactly its + * dimension count of numbers. Its validator also requires each number to be + * finite, which every JSON number is and `type: "number"` already states to + * Zod, and it transforms nothing, so this states exactly what it admits. + * Its index options (metric, index type) describe how rows are searched, + * not which values are valid, so they are not part of it. `undefined` for + * any other node; an `embedding()` refined or described afresh is a new + * Zod instance without the dimension marker, and stays unprojectable. + */ +function embeddingProjection( + zodSchema: z.core.$ZodTypes, +): z.core.JSONSchema.JSONSchema | undefined { + if (!isEmbeddingSchema(zodSchema)) return undefined; + const dimensions = zodSchema[EMBEDDING_DIMENSIONS_KEY]; + return { + type: "array", + items: { type: "number" }, + minItems: dimensions, + maxItems: dimensions, + }; +} + +/** + * Restates an unconvertible node's marker as what makes two such nodes + * provably admit the same values (see {@link UNPROJECTABLE_NODE_KEYWORD}): + * + * - a plain node — a type of {@link PLAIN_UNPROJECTABLE_NODE_TYPES} with no + * check — is its node type alone, so `z.date()` matches any other + * `z.date()`; + * - every other node (a check, refinement, transform, pipe, default or + * custom function) is the Zod instance that carries it, so it matches only + * a field shared through `.extend()` or a reused schema. + * + * Zod visits a wrapper after the node it wraps, with the wrapped marker + * copied into its own result, so the outermost node's identity is the one + * that stays. + */ +function stampUnprojectableIdentity( + context: Readonly<{ + zodSchema: z.core.$ZodTypes; + jsonSchema: Record; + }>, +): void { + const { jsonSchema, zodSchema } = context; + const marker = jsonSchema[UNPROJECTABLE_NODE_KEYWORD]; + if (typeof marker !== "string") return; + const def = zodSchema._zod.def as ZodNodeDef; + if ( + def.type !== undefined && + IDENTITY_PRESERVING_WRAPPER_TYPES.has(def.type) + ) { + return; + } + const [nodeType = marker] = marker.split(UNPROJECTABLE_IDENTITY_SEPARATOR); + // A schema that is itself a check (`z.int64()`, `z.uint64()`) carries its + // bound on its own definition, so its run-order checks decide plainness. + const isPlain = + def.type === nodeType && + PLAIN_UNPROJECTABLE_NODE_TYPES.has(nodeType) && + checksInRunOrder(zodSchema).length === 0; + jsonSchema[UNPROJECTABLE_NODE_KEYWORD] = + isPlain ? nodeType : ( + `${nodeType}${UNPROJECTABLE_IDENTITY_SEPARATOR}${instanceToken(zodSchema)}` + ); +} + +/** + * `schema` converted with every Zod-only constraint and every unconvertible + * node marked, or the persisted placeholder when that conversion cannot + * replace it (see the module docblock). + */ +function projectForComparison(schema: z.ZodType): JsonSchema { + const unconvertibleNodeTypes: string[] = []; + const project = (): JsonSchema => + z.toJSONSchema(schema, { + override: (context) => { + markZodOnlyConstraints(context); + markLossyPipe(context); + recordKeyOptionality(context); + listStoredFallbacks(context); + markPartlyConvertedNode(context); + stampUnprojectableIdentity(context); + }, + unrepresentable: (context) => { + const nodeType = context.zodSchema._zod.def.type; + unconvertibleNodeTypes.push(nodeType); + return ( + embeddingProjection(context.zodSchema) ?? { + [UNPROJECTABLE_NODE_KEYWORD]: nodeType, + } + ); + }, + }) as JsonSchema; + + const persisted = serializeSchemaProperties(schema); + if (!isUnprojectableSchemaProjection(persisted)) return project(); + try { + const projection = project(); + return unconvertibleNodeTypes.length > 0 ? projection : persisted; + } catch { + return persisted; + } +} + +/** + * The comparison projection of `schema` (see the module docblock). Where it + * falls back to the unprojectable placeholder, the placeholder is returned + * as is, so `isUnprojectableSchemaProjection` still recognizes it. Memoized + * by schema identity. + */ +export function structuralSubtypeProjection(schema: z.ZodType): JsonSchema { + const cached = PROJECTION_CACHE.get(schema); + if (cached !== undefined) return cached; + const projection = projectForComparison(schema); + PROJECTION_CACHE.set(schema, projection); + return projection; +} diff --git a/packages/typegraph/src/schema/structural-subtype.ts b/packages/typegraph/src/schema/structural-subtype.ts new file mode 100644 index 000000000..44a52bd73 --- /dev/null +++ b/packages/typegraph/src/schema/structural-subtype.ts @@ -0,0 +1,2061 @@ +/** + * Structural subtyping over the projected JSON Schema fragment. + * + * `isStructuralSubtype(child, parent)` answers one question: is every value + * that satisfies `child` guaranteed to also satisfy `parent`, judged + * ENTIRELY on the projected JSON Schema (`serializeSchemaProperties`, + * src/schema/serializer.ts) — never on the originating Zod schema and never + * on stored data. Because the projection erases refinements, transforms, + * and brands (`.refine`, `.superRefine`, `.brand`, `.pipe`, `.lazy`, + * `.toLowerCase()` all collapse to their base schema), this predicate is + * necessarily blind to them too: a Zod-level narrowing with no JSON-Schema + * trace cannot be judged. A live graph's registry check therefore compares + * `structuralSubtypeProjection` (./structural-subtype-projection.ts), which + * marks where the projection dropped a regex flag, a refinement, a strict + * object, a pipe's input side (a projectable `.pipe()` or `z.preprocess()` + * included), or read a `.meta()` key as a constraint, so such a pair answers + * `incomparable` (rule 3) instead of a guess. It also marks each node the + * projection cannot convert at all, which rule 2 accepts only under an + * unconstrained parent node or beside a node of the same identity, and + * lists every value a node stores unvalidated (`.default()`, `.catch()`), + * each of which must satisfy the parent the way a literal child would + * unless the field is the parent's own. Soundness throughout means the + * child stores nothing the parent could not store itself, so a field shared + * through `.extend()` is accepted as identical storage. It also records, + * per key, whether the node always stores it and whether it refuses to see + * it missing (`KEY_OPTIONALITY_KEYWORD`), read in place of `required`. + * + * This is a different question from `isBreakingPropertyChange` + * (./migration.ts), which answers "do rows that were valid before a schema + * edit stay valid after it" — and the two disagree in both directions over + * the same pair of schemas: + * - a child that ADDS a required property is a structural subtype (every + * child-valid value already satisfies the parent, which asks less) but + * IS a breaking migration (an existing row lacks the new field); + * - a child that relaxes a parent-required property to optional is a SAFE + * migration (every existing row still validates) but is NOT a + * structural subtype (the parent's guarantee no longer holds). + * Do not reach for one where the other is asked. + * + * ## The rule set (in the order `compareSchemas` applies it) + * + * 1. A pair nested past `MAX_STRUCTURAL_SUBTYPE_DEPTH` is `incomparable` + * ("max-depth-exceeded") — schemas arrive from persisted documents, and + * the guard mirrors `MAX_JSON_POINTER_DEPTH` (src/query/json-pointer.ts). + * 2. If child and parent are the SAME schema — every keyword equal, once only + * non-constraining metadata (`description`/`title`/`default`/`$schema`) + * is dropped and projection spellings are folded (`propertySchemasEqual`: + * a union of bare primitives as `anyOf` or a `type` token array, a tuple + * with or without its arity restated) — child is trivially a subtype of + * parent, regardless of which keywords either side carries. This runs before rules 3–4 on + * purpose: it is what lets a child that copies a `$ref` (recursive + * `z.lazy`) or `allOf` (`z.intersection`) property VERBATIM from its + * parent still compare as a subtype for that property, even though rules + * 3–4 can never judge `$ref`/`allOf` on their own. Two DIFFERENT `$ref` + * targets, or a child that merely narrows an `allOf` member, still fall + * through to rule 3/4 and are refused. Equal `$ref` STRINGS are not equal + * schemas, though: a reference is resolved against its own side's root + * (`#`, or `#/$defs/`), and the rule fires only when every reference + * the pair reaches, transitively, resolves to an equal definition on both + * sides. The projection names a recursive definition by encounter order, + * so two unrelated recursive types share a name; an unresolvable + * reference is refused the same way. Rule 6 decides through the same test. + * Right after it, a node marked {@link UNPROJECTABLE_NODE_KEYWORD} on + * either side (a Zod construct with no JSON Schema spelling, such as + * `z.date()` or a `.transform()`) states nothing to compare, so the pair + * is accepted only where no comparison is needed: the parent node admits + * every value (`{}`, from `z.unknown()`/`z.any()`, carrying no + * constraining keyword), or both nodes carry the same marker identity — + * the same Zod instance, or plain check-free nodes of one type + * (`z.date()` and `z.date()`). An `embedding()` carries no marker: the + * comparison projection writes it as the fixed-length number array it + * validates, so embeddings compare as arrays and their index options do + * not affect subsumption. Every other pair with an unprojectable side is + * `incomparable` ("unsupported-construct"), except that a union on the + * other side is compared member by member first. The node's siblings are + * still compared by the enclosing object or union. Only a live graph's + * comparison projection carries the marker. + * 3. Either side carrying `$ref` is `incomparable` ("schema-reference"): the + * projection emits `$ref` exactly at a self- or mutually-recursive cycle, + * which is why recursion here cannot diverge. So is a node carrying a + * constraint its Zod source states and the projection cannot + * ("unsupported-construct"): a regex flag or a constraint-shaped `.meta()` + * key on either side ({@link ZOD_ONLY_CONSTRAINT_KEYWORD}), a refinement + * or a strict object on the parent + * ({@link ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD}). Only a live graph's + * comparison projection carries these markers; a bare persisted + * projection has lost the facts they record. + * 4. Either side carrying a keyword this predicate recognizes as + * CONSTRAINING but does not model the semantics of — `not` (`z.never()`), + * `allOf` (`z.intersection`, whose subtyping is a named follow-up), + * `contentEncoding` (`z.file()`), or a standard JSON Schema 2020-12 + * vocabulary keyword this predicate's rule set never modeled + * (`uniqueItems`, `contains`/`minContains`/`maxContains`, + * `minProperties`/`maxProperties`, `patternProperties`, + * `dependentRequired`/`dependentSchemas`, `if`/`then`/`else`, + * `unevaluatedProperties`/`unevaluatedItems` — none emitted by the Zod + * projection today, but reachable through a hand-written `JsonSchema`) — + * is `incomparable` ("unsupported-keyword"). + * 5. Any OTHER keyword outside `COMPARABLE_KEYWORDS` is silently IGNORED for + * subtyping. This is deliberately the opposite of `isBreakingPropertyChange`'s + * diff rule, where an unrecognized key is user data whose change must be + * surfaced: standard JSON Schema semantics treat a keyword a reader does + * not understand as adding no constraint, and this predicate follows that + * reading. A `searchable()` field's `_searchableField` tag + * (src/core/searchable.ts:120) and an arbitrary `.meta()` key both fall + * out of this rule with no special case. `format` is NOT covered by this + * rule — it is compared as a constraint, see {@link COMPARABLE_KEYWORDS}. + * 6. If the two schemas are identical once irrelevant keywords are dropped, + * they are mutual subtypes — a NARROWER identity check than rule 2 above + * (it tolerates schemas that differ only in an annotation/unrecognized + * key), reachable only once rules 3–4 have already cleared both sides of + * `$ref` and any unmodeled constraining keyword. + * 7. If either side is a union (`anyOf`, or `oneOf` — read as `anyOf`. For a + * PARENT that reading is sound only when no value can match two members, + * so a parent `oneOf` must have pairwise provably disjoint members: + * different `type` tokens, or one required property pinned to disjoint + * `const`/`enum` sets. `z.discriminatedUnion` always qualifies; `z.xor`, + * which projects `oneOf` too, qualifies only when its members happen to + * be exclusive, and is otherwise `incomparable` + * ("unsupported-construct"), as is a parent carrying both `anyOf` and + * `oneOf`. A CHILD `oneOf` needs no proof: it admits a subset of what its + * members admit together — or a `type` + * token array, which the projection emits for a union of bare primitives + * such as `z.string().nullable()` and which is read as one member per + * token, each carrying every sibling keyword), every child + * member must find SOME parent member it subtypes — checking every parent + * member before concluding `incomparable`, so the verdict never depends on + * the order the parent declared its members in (a member that cannot be + * judged does not stop the search for a later member that matches). This + * is also what handles nullability with no special case: `z.string()` is a + * subtype of `z.string().nullable()` (`type: ["string", "null"]`, or + * `anyOf: [string, null]` in a document stored by an earlier projection) + * because the lone child member matches the union's first member; the reverse is not, + * because the union's `null` member matches nothing on a bare `string` + * parent. A union keyword is ANDed with any sibling keyword on the same + * schema object, not replaced by it — `{ type: "string", anyOf: [...] }` + * admits only strings that also match a member — so once the member check + * passes, the parent's sibling keywords (with `anyOf`/`oneOf` removed) are + * compared too, through the ordinary rule set; the child's own sibling + * keywords are not, since dropping them is conservative (see + * `compareUnionSiblingConstraints`). + * 8. Otherwise the two are compared as leaves: value sets (`const`/`enum`) + * must narrow, type tokens must agree (`integer` narrows `number`), + * objects compare property-by-property with the child allowed to add or + * drop optional properties (width subtyping — the parent's + * `additionalProperties: false` is NOT applied to the child's added + * keys, only an explicit non-`false` `additionalProperties` schema + * constrains them — the projection writes `false` for every Zod object, + * stripping or strict, and a strict parent is caught at rule 3; and a parent-declared property the child leaves to its + * own OPEN additional-properties schema is compared against that schema + * rather than skipped, since an open child does not actually omit the + * property — it just declares no NAMED constraint for it), arrays compare + * bounds/items/tuple prefixes (a closed parent tuple — `prefixItems` with + * `items: false`, or with no `items` at all in a document stored by an + * earlier projection — additionally requires the child be closed at the + * same arity), strings compare + * length bounds, `pattern`, and `format` (a constraint, not an + * annotation — see {@link COMPARABLE_KEYWORDS}), and numbers compare + * bounds folding `exclusiveMinimum` / + * `exclusiveMaximum` in. A record's key set compares as a subtype too: + * the child's `propertyNames` must narrow the parent's. Within these + * leaf comparisons, a child pinned to a finite value set (`const` or + * `enum`: `z.literal`, `z.enum`) is also judged value by value. A parent + * `type`, length bound, `pattern`, numeric bound, or `multipleOf` the + * child's own keywords do not imply is still satisfied when every value + * the child admits satisfies it, so `z.literal("x")` narrows + * `z.string().min(1)` and `z.literal(5)` narrows `z.int()`. Each value is + * judged under every reading a validator applies — JSON Schema's and + * Zod's, which differ on string length, `pattern` flags, and `multipleOf` + * rounding — and a value a reading cannot judge faithfully (an + * uncompilable pattern, a standard `format` such as `email`) leaves the + * declared-keyword refusal standing. + * + * An unmodeled construct always yields `{ verdict: "incomparable" }` and is + * never silently accepted as a match. + */ +import { decodeJsonPointerSegment } from "../query/json-pointer"; +import { createDataKeyedBag, hasOwnKey } from "../utils/object"; +import { requireDefined } from "../utils/presence"; +import { canonicalJson } from "./canonical"; +import { + closesTupleByOmission, + isObjectSchema, + propertySchemasEqual, + stripSchemaMetadata, +} from "./migration"; +import { type JsonSchema } from "./types"; + +// ============================================================ +// Public API +// ============================================================ + +/** Why a comparable pair is not a subtype. */ +export type StructuralSubtypeReason = + | "missing-required-property" + | "optional-in-child-required-in-parent" + | "type-token-mismatch" + | "unconstrained-where-parent-constrains" + | "value-set-not-subset" + | "string-length-not-tighter" + | "pattern-mismatch" + | "format-mismatch" + | "numeric-bound-not-tighter" + | "multiple-of-mismatch" + | "array-bounds-not-tighter" + | "tuple-arity-mismatch" + | "no-matching-union-member" + | "property-names-mismatch"; + +/** Why the pair cannot be decided at all. */ +export type StructuralIncomparableReason = + | "unsupported-keyword" + | "schema-reference" + | "unsupported-construct" + | "max-depth-exceeded"; + +/** + * The result of comparing a `child` schema against a `parent` schema. A + * single discriminant (`verdict`) distinguishes all three outcomes: use + * `result.verdict === "subtype"` for the happy path, and the `reason` / + * `path` fields (present on both failure shapes) to explain a refusal. + * `path` is a human-readable trail — property names and the segment + * constants below — not a JSON Pointer. + */ +export type StructuralSubtypeResult = + | Readonly<{ verdict: "subtype" }> + | Readonly<{ + verdict: "not-subtype"; + reason: StructuralSubtypeReason; + path: readonly string[]; + }> + | Readonly<{ + verdict: "incomparable"; + reason: StructuralIncomparableReason; + path: readonly string[]; + }>; + +/** + * Is every value that satisfies `child` guaranteed to also satisfy `parent`? + * See the module doc comment above for the complete rule set and its + * relationship to `isBreakingPropertyChange`. + */ +export function isStructuralSubtype( + child: JsonSchema, + parent: JsonSchema, +): StructuralSubtypeResult { + return compareSchemas(child, parent, [], { + depth: 0, + roots: { child, parent }, + }); +} + +// ============================================================ +// Compile-time / runtime agreement projection +// ============================================================ + +/** + * Exactly the JSON Schema keywords TypeScript's structural assignability + * over `z.infer` can see: the shape (`type`, `properties`, `required`, + * `items`, `prefixItems`, `anyOf`, `oneOf`, `additionalProperties`) and + * literal value sets (`const`, `enum`) — both of which round-trip through a + * Zod schema's inferred TYPE. Every value-level constraint this predicate + * also compares (`minLength`/`maxLength`, `pattern`, `format`, + * `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`, + * `minItems`/`maxItems`) narrows a value at RUNTIME without narrowing its + * static TYPE — `z.string()` and `z.string().min(3)` share the type + * `string` — so the compile-time check is blind to them. + */ +const TYPE_VISIBLE_KEYWORDS: ReadonlySet = new Set([ + "type", + "const", + "enum", + "properties", + "required", + "items", + "prefixItems", + "anyOf", + "oneOf", + "additionalProperties", +]); + +function projectTypeVisibleValue(key: string, value: unknown): unknown { + switch (key) { + case "properties": { + const projected = createDataKeyedBag(); + for (const [propertyName, propertySchema] of Object.entries( + value as Record, + )) { + projected[propertyName] = projectTypeVisible(propertySchema); + } + return projected; + } + case "prefixItems": + case "anyOf": + case "oneOf": { + return (value as readonly JsonSchema[]).map((member) => + projectTypeVisible(member), + ); + } + case "items": + case "additionalProperties": { + return typeof value === "boolean" ? value : ( + projectTypeVisible(value as JsonSchema) + ); + } + default: { + return value; + } + } +} + +/** + * `schema`, recursively reduced to only {@link TYPE_VISIBLE_KEYWORDS} — the + * fragment of a JSON Schema that TypeScript's structural assignability over + * `z.infer` can actually see. Feeds {@link isTypeLevelSubtype}. + */ +export function projectTypeVisible(schema: JsonSchema): JsonSchema { + const projected = createDataKeyedBag(); + for (const [key, value] of Object.entries(schema)) { + if (!TYPE_VISIBLE_KEYWORDS.has(key)) continue; + projected[key] = projectTypeVisibleValue(key, value); + } + return projected; +} + +/** + * The RUNTIME prediction of what `subClassOf(child, parent)`'s + * compile-time conditional-type check resolves to: `isStructuralSubtype` applied to each + * side's {@link projectTypeVisible} projection, rather than the full schema. + * + * One comparison engine (`isStructuralSubtype`), one projection + * (`projectTypeVisible`) — this is not a second walk of the schema, only a + * narrower view fed into the same predicate the runtime check uses. + * + * `isStructuralSubtype(c, p).verdict === "subtype"` implies + * `isTypeLevelSubtype(c, p).verdict === "subtype"` — a hierarchy the runtime check accepts + * always compiles under the compile-time check + * (`tests/property/typed-subsumption-agreement.test.ts`). The converse does + * NOT hold: TypeScript cannot see a value-level constraint, so + * `isTypeLevelSubtype` accepts pairs `isStructuralSubtype` refuses (a bare + * `z.string()` child under a `z.string().min(3)` parent erases to identical + * types but is not a runtime subtype) — this is "the compile-time check is a + * filter, the runtime check is the authority", not a defect in either predicate. + */ +export function isTypeLevelSubtype( + child: JsonSchema, + parent: JsonSchema, +): StructuralSubtypeResult { + return isStructuralSubtype( + projectTypeVisible(child), + projectTypeVisible(parent), + ); +} + +// ============================================================ +// Named constants +// ============================================================ + +const MAX_STRUCTURAL_SUBTYPE_DEPTH = 64; + +const ARRAY_ITEM_SEGMENT = "[]"; +const ADDITIONAL_PROPERTIES_SEGMENT = "{additionalProperties}"; +const PROPERTY_NAMES_SEGMENT = "{propertyNames}"; + +function tupleIndexSegment(index: number): string { + return `[${index}]`; +} + +function unionMemberSegment(index: number): string { + return `anyOf[${index}]`; +} + +function typeTokenSegment(index: number): string { + return `type[${index}]`; +} + +const OBJECT_TYPE_TOKEN = "object"; +const ARRAY_TYPE_TOKEN = "array"; +const STRING_TYPE_TOKEN = "string"; +const NUMBER_TYPE_TOKEN = "number"; +const INTEGER_TYPE_TOKEN = "integer"; +const BOOLEAN_TYPE_TOKEN = "boolean"; +const NULL_TYPE_TOKEN = "null"; + +/** + * Keywords the Zod projection can emit that DO constrain the value space but + * this predicate does not model the semantics of. Unlike an ordinary + * unrecognized keyword (silently ignored per rule 4 in the module doc + * comment above), these are keywords this predicate KNOWS constrain values — + * treating them as decoration would accept subtype verdicts that are not + * actually sound (e.g. reading `z.never()`'s `not: {}` as "no constraint" + * would make every schema a subtype of the bottom type). `allOf` + * (`z.intersection`) is named explicitly because intersection subtyping is + * real and deliberately deferred, not merely undecorated. + * + * The remaining entries are standard JSON Schema 2020-12 vocabulary keywords + * this predicate's rule set does not model at all, even though the Zod + * projection never emits them today — the public `isStructuralSubtype` + * surface accepts a hand-written `JsonSchema`, and silently treating an + * unimplemented constraint as decoration would be unsound for that caller + * the same way it was for `format` before this predicate learned to compare + * it (see `COMPARABLE_KEYWORDS`). + */ +export const UNMODELED_CONSTRAINING_KEYWORDS: ReadonlySet = new Set([ + "not", + "allOf", + "contentEncoding", + "uniqueItems", + "contains", + "minContains", + "maxContains", + "minProperties", + "maxProperties", + "patternProperties", + "dependentRequired", + "dependentSchemas", + "if", + "then", + "else", + "unevaluatedProperties", + "unevaluatedItems", +]); + +/** + * Exactly the constraining keywords this predicate actively compares. Every + * other key surviving `stripSchemaMetadata` and `UNMODELED_CONSTRAINING_KEYWORDS` + * — an arbitrary `.meta()` key or `searchable()`'s `_searchableField` tag — + * is dropped by `comparableKeywords` and never blocks a verdict. That drop is + * rule 4 above, deliberately the opposite of `isBreakingPropertyChange`'s + * diff rule (there, an unrecognized key is user data whose change must be + * surfaced; here, an unrecognized key constrains nothing, so it cannot make + * two schemas differ for subtyping purposes) because the two predicates + * answer different questions. `format` is NOT one of the dropped keys: the + * Zod projection emits it with no accompanying `pattern` for `z.url()` and + * `z.jwt()`, so ignoring it would accept a bare `z.string()` as a subtype of + * either — it is compared exactly like `pattern` (present on the child, + * absent or identical on the parent, or satisfied by every value a finite + * child admits — see `satisfiesFormat`). + */ +export const COMPARABLE_KEYWORDS: ReadonlySet = new Set([ + "type", + "const", + "enum", + "properties", + "required", + "additionalProperties", + "propertyNames", + "items", + "prefixItems", + "minItems", + "maxItems", + "minLength", + "maxLength", + "pattern", + "format", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "anyOf", + "oneOf", +]); + +/** + * Standard JSON Schema annotation keywords the Zod projection is known to + * emit that carry no constraint (unlike `UNMODELED_CONSTRAINING_KEYWORDS`) + * and so fall out of rule 4 with no special case, the same way an arbitrary + * `.meta()` key or `searchable()`'s `_searchableField` tag does. Listed here + * — rather than left to fall silently through rule 4 unnamed — only so the + * projection-coverage test (`tests/structural-subtype.test.ts`) can tell a + * keyword the projection is KNOWN to emit today from one a future Zod + * upgrade adds that no one has classified yet; adding an entry here does not + * change this module's runtime behavior in any way. `readOnly` (`.readonly()`) + * is the only keyword in this bucket at present. + */ +export const KNOWN_IGNORED_KEYWORDS: ReadonlySet = new Set([ + "readOnly", +]); + +/** + * Marks a schema node whose Zod source constrains values in a way the JSON + * Schema projection cannot state, on either side of a comparison: a regex + * flag, or a `.meta()` key spelled like a constraining keyword. Written only + * by `structuralSubtypeProjection` (`./structural-subtype-projection`), never + * into a persisted document. + */ +export const ZOD_ONLY_CONSTRAINT_KEYWORD = "x-typegraph-zod-only-constraint"; + +/** + * Marks a schema node whose Zod source narrows values in a way the JSON + * Schema projection cannot state, but only in a direction that matters when + * the node is the PARENT: a refinement, or a strict object. On a child the + * narrowing only makes the child stricter, which a `subtype` verdict may + * ignore. + */ +export const ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD = + "x-typegraph-zod-only-parent-constraint"; + +/** + * Marks a schema node `z.toJSONSchema` has no spelling for at all — + * a `z.custom()` other than `embedding()`, `z.date()`, `z.set()`, `z.map()`, + * `z.bigint()`, a `.transform()` — where the persisted projection would + * collapse the whole kind to its placeholder instead. Such a node states + * nothing to compare, so it narrows only a parent node that admits every + * value, and matches another unprojectable node only when the two carry the + * same value here: its IDENTITY, written by the projection as the node type + * alone for a plain check-free node of a type whose values it fully + * describes (`z.date()`, `z.bigint()`), and as the Zod instance otherwise. + * An `embedding()` is projected as the fixed-length number array it + * validates instead of this marker. Every other node of the + * pair is still judged. A node Zod converts only in part (a + * dynamic `.catch()` keeps its `type`) does not carry this marker; the + * projection marks it with {@link ZOD_ONLY_CONSTRAINT_KEYWORD} instead. + * Written only by `structuralSubtypeProjection` + * (`./structural-subtype-projection`), never into a persisted document. + */ +export const UNPROJECTABLE_NODE_KEYWORD = "x-typegraph-unprojectable"; + +/** + * For each property of an object, or each key an exhaustive record lists, + * whether the node always stores it (read when it is the child) and whether + * it refuses to see it missing (read when it is the parent), with the Zod + * metadata both were read from. Zod decides + * both from each property's `optin`/`optout` and what its value schema can + * output, which the projection's one `required` follows in neither + * direction. Written only by `structuralSubtypeProjection`; a persisted + * document is read through `required`. + */ +export const KEY_OPTIONALITY_KEYWORD = "x-typegraph-key-optionality"; + +/** + * One key's two readings, as {@link KEY_OPTIONALITY_KEYWORD} records them, + * beside the Zod `optin`/`optout` they were read from, so two equal entries + * mean the same parsing of that key. + */ +export type KeyOptionality = Readonly<{ + stored: boolean; + required: boolean; + optin?: "optional" | "defaulted"; + optout?: "optional"; +}>; + +const SUBTYPE: StructuralSubtypeResult = { verdict: "subtype" }; + +// ============================================================ +// Result builders +// ============================================================ + +function notSubtype( + reason: StructuralSubtypeReason, + path: readonly string[], +): StructuralSubtypeResult { + return { verdict: "not-subtype", reason, path }; +} + +function incomparable( + reason: StructuralIncomparableReason, + path: readonly string[], +): StructuralSubtypeResult { + return { verdict: "incomparable", reason, path }; +} + +// ============================================================ +// Keyword partitioning +// ============================================================ + +/** + * `undefined` when `stripped` (a schema after {@link stripSchemaMetadata}) + * carries no keyword this predicate knows constrains values but cannot model; + * otherwise the `incomparable` result to return immediately. + */ +function unmodeledConstruct( + stripped: Readonly>, + path: readonly string[], +): StructuralSubtypeResult | undefined { + for (const key of Object.keys(stripped)) { + if (UNMODELED_CONSTRAINING_KEYWORDS.has(key)) { + return incomparable("unsupported-keyword", path); + } + } + return undefined; +} + +/** + * `stripped` (a schema after {@link stripSchemaMetadata}, which drops the + * migration metadata description/title/default/$schema) reduced to only the + * keywords this predicate compares — any keyword outside + * {@link COMPARABLE_KEYWORDS} is dropped. Used only for the equality fast + * path below; the per-construct comparison functions read the original schema + * directly. + */ +function comparableKeywords( + stripped: Readonly>, +): Record { + const kept = createDataKeyedBag(); + for (const [key, value] of Object.entries(stripped)) { + if (COMPARABLE_KEYWORDS.has(key) || key === KEY_OPTIONALITY_KEYWORD) { + kept[key] = value; + } + } + return kept; +} + +function keyOptionality( + schema: JsonSchema, +): Readonly> | undefined { + const recorded = schema[KEY_OPTIONALITY_KEYWORD]; + return recorded !== null && typeof recorded === "object" ? + (recorded as Readonly>) + : undefined; +} + +/** + * The keys `schema` always stores, as a child: those its properties' + * optionality guarantees, or `required` where none is recorded. + */ +function storedKeys(schema: JsonSchema): readonly string[] { + const recorded = keyOptionality(schema); + if (recorded === undefined) return schema.required ?? []; + return Object.entries(recorded) + .filter(([, optionality]) => optionality.stored) + .map(([key]) => key); +} + +/** + * The keys `schema` refuses to see missing, as a parent, as the projection + * recorded them: for an object `required` and every property whose input is + * required even where its output may be absent + * (`z.unknown().pipe(z.string().optional())`), for a record every key unless + * its value is optional on both sides. `required` where none is recorded. + */ +function requiredKeys(schema: JsonSchema): readonly string[] { + const recorded = keyOptionality(schema); + if (recorded === undefined) return schema.required ?? []; + return Object.entries(recorded) + .filter(([, optionality]) => optionality.required) + .map(([key]) => key); +} + +function hasKeywordOutsideValueSet(keywords: Record): boolean { + for (const key of Object.keys(keywords)) { + if (key !== "const" && key !== "enum") return true; + } + return false; +} + +// ============================================================ +// Entry point +// ============================================================ + +/** + * Where a comparison stands: how deep it has recursed, and the two documents + * it started from. The roots are what a `$ref` resolves against — a nested + * schema does not carry its own definitions. + */ +type Walk = Readonly<{ + depth: number; + roots: Readonly<{ child: JsonSchema; parent: JsonSchema }>; +}>; + +function descend(walk: Walk): Walk { + return { depth: walk.depth + 1, roots: walk.roots }; +} + +// ============================================================ +// References +// ============================================================ + +const REFERENCE_KEYWORD = "$ref"; +const DEFINITIONS_KEYWORD = "$defs"; +const ROOT_REFERENCE = "#"; +const LOCAL_DEFINITION_PREFIX = `${ROOT_REFERENCE}/${DEFINITIONS_KEYWORD}/`; + +/** Every `$ref` target named anywhere inside `value`, added to `into`. */ +function collectReferences(value: unknown, into: Set): void { + if (Array.isArray(value)) { + for (const entry of value) collectReferences(entry, into); + return; + } + if (value === null || typeof value !== "object") return; + for (const [key, entry] of Object.entries(value)) { + if (key === REFERENCE_KEYWORD && typeof entry === "string") { + into.add(entry); + } else { + collectReferences(entry, into); + } + } +} + +/** + * What `reference` names in `root`: the document itself, or one of its + * `$defs` — the two forms the projection emits. `undefined` for a reference + * this predicate cannot follow (a remote document, a deeper pointer) or + * whose definition is absent. + */ +function resolveReference( + reference: string, + root: JsonSchema, +): JsonSchema | undefined { + if (reference === ROOT_REFERENCE) return root; + if (!reference.startsWith(LOCAL_DEFINITION_PREFIX)) return undefined; + const definitions = root[DEFINITIONS_KEYWORD]; + if (definitions === null || typeof definitions !== "object") return undefined; + const byName = definitions as Record; + const name = decodeJsonPointerSegment( + reference.slice(LOCAL_DEFINITION_PREFIX.length), + ); + return hasOwnKey(byName, name) ? byName[name] : undefined; +} + +/** + * Every reference reachable from `schema` — its own, and those of each + * definition they lead to — with what each resolves to in `root`. + */ +function reachableDefinitions( + schema: unknown, + root: JsonSchema, +): ReadonlyMap { + const resolved = new Map(); + const pending = new Set(); + collectReferences(schema, pending); + for (const reference of pending) { + if (resolved.has(reference)) continue; + const target = resolveReference(reference, root); + resolved.set(reference, target); + // Appending while iterating is what walks the closure: a `Set` visits + // entries added during iteration, and never one it already holds. + if (target !== undefined) collectReferences(target, pending); + } + return resolved; +} + +/** + * Whether two schemas already known to be EQUAL also mean the same thing: + * every reference either one reaches must resolve, on its own side, to an + * equal definition. + * + * A `$ref` is a name, and the name says nothing about the definition. The + * projection numbers a recursive schema's definition by encounter order, so + * two unrelated recursive types in a child and a parent both land on the same + * name; equal reference strings over different `$defs` are different schemas. + * A reference neither side can resolve is not evidence of anything and is + * refused the same way. + */ +function referencesResolveIdentically( + child: unknown, + parent: unknown, + roots: Walk["roots"], +): boolean { + const childDefinitions = reachableDefinitions(child, roots.child); + if (childDefinitions.size === 0) return true; + // One document compared with itself resolves every reference identically, + // whether or not this predicate can follow it. + if (roots.child === roots.parent) return true; + const parentDefinitions = reachableDefinitions(parent, roots.parent); + if (parentDefinitions.size !== childDefinitions.size) return false; + for (const [reference, childTarget] of childDefinitions) { + const parentTarget = parentDefinitions.get(reference); + if (childTarget === undefined || parentTarget === undefined) return false; + if ( + !propertySchemasEqual( + stripSchemaMetadata(childTarget), + stripSchemaMetadata(parentTarget), + ) + ) { + return false; + } + } + return true; +} + +/** + * THE identity test of this module: the two schemas are equal as written AND + * every reference they reach resolves to an equal definition. Both identity + * rules (2 and 6) decide through it, so neither can accept a shared reference + * name the other would look behind. + */ +function sameSchema(child: unknown, parent: unknown, walk: Walk): boolean { + return ( + propertySchemasEqual(child, parent) && + referencesResolveIdentically(child, parent, walk.roots) + ); +} + +// ============================================================ +// Unprojectable nodes +// ============================================================ + +/** + * Keywords that make a schema node constrain values: every keyword this + * predicate compares or refuses as unmodeled, a reference, and each marker + * the comparison projection writes. + */ +function isConstraintBearingKeyword(key: string): boolean { + return ( + COMPARABLE_KEYWORDS.has(key) || + UNMODELED_CONSTRAINING_KEYWORDS.has(key) || + key === REFERENCE_KEYWORD || + key === ZOD_ONLY_CONSTRAINT_KEYWORD || + key === ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD || + key === UNPROJECTABLE_NODE_KEYWORD + ); +} + +/** + * Whether `stripped` (a schema after {@link stripSchemaMetadata}) admits + * every value: `z.unknown()`, `z.any()`, or a node carrying only + * annotations and keywords rule 5 ignores. + */ +function isUnconstrained(stripped: Readonly>): boolean { + return Object.keys(stripped).every((key) => !isConstraintBearingKeyword(key)); +} + +/** + * What an unprojectable node stands for (see + * {@link UNPROJECTABLE_NODE_KEYWORD}), if it is one. + */ +function unprojectableIdentity(schema: JsonSchema): string | undefined { + if (!hasOwnKey(schema, UNPROJECTABLE_NODE_KEYWORD)) return undefined; + return String(schema[UNPROJECTABLE_NODE_KEYWORD]); +} + +/** + * The verdict for a pair where either side is an unprojectable node, or + * `undefined` when neither is, or when the other side is a union whose + * members must each be compared on their own. + * + * An unprojectable node states nothing about the values it admits, so no + * comparison can show it narrows a parent that constrains anything, nor that + * a child satisfies a parent it stands for. It is accepted only where that + * needs no comparison: the parent admits every value, or both sides are + * unprojectable nodes with the same identity — provably the same values. + */ +function compareUnprojectable( + child: JsonSchema, + parent: JsonSchema, + strippedParent: Readonly>, + path: readonly string[], +): StructuralSubtypeResult | undefined { + const childIdentity = unprojectableIdentity(child); + const parentIdentity = unprojectableIdentity(parent); + if (childIdentity === undefined && parentIdentity === undefined) { + return undefined; + } + if (isUnconstrained(strippedParent)) return SUBTYPE; + if (childIdentity === undefined) { + return isUnionSchema(child) ? undefined : ( + incomparable("unsupported-construct", path) + ); + } + if (parentIdentity === undefined) { + return isUnionSchema(parent) ? undefined : ( + incomparable("unsupported-construct", path) + ); + } + return childIdentity === parentIdentity ? SUBTYPE : ( + incomparable("unsupported-construct", path) + ); +} + +// ============================================================ +// Stored fallbacks +// ============================================================ + +const DEFAULT_KEYWORD = "default"; + +/** + * Every value a node stores without validating it — its `.default()` and + * `.catch()` values, the outer one and each one it wraps — as `{ instance, + * values }`, `instance` naming the outermost such Zod instance. The + * projection writes only the outermost value as `default`, so the + * comparison projection lists them all here. Written only by + * `structuralSubtypeProjection`, never into a persisted document. + */ +export const STORED_FALLBACKS_KEYWORD = "x-typegraph-stored-fallbacks"; + +/** The outermost `.default()`/`.catch()` instance `schema` stores through. */ +function fallbackInstance(schema: JsonSchema): string | undefined { + const listed = schema[STORED_FALLBACKS_KEYWORD] as + Readonly<{ instance?: unknown }> | undefined; + return typeof listed?.instance === "string" ? listed.instance : undefined; +} + +/** The values `schema` stores unvalidated, as the projection states them. */ +function storedFallbacks(schema: JsonSchema): readonly unknown[] { + const listed = schema[STORED_FALLBACKS_KEYWORD] as + Readonly<{ values?: unknown }> | undefined; + if (Array.isArray(listed?.values)) return listed.values; + return hasOwnKey(schema, DEFAULT_KEYWORD) ? [schema[DEFAULT_KEYWORD]] : []; +} + +/** + * The verdict for a child node that stores a value it never validates, or + * `undefined` when it stores none, the parent stores the same ones, or each + * one satisfies the parent. + * + * Soundness here means the child stores nothing the parent could not store + * itself. Zod's `.default(v)` stores `v` for an absent input and `.catch(v)` + * stores it for an input that fails, both without running the node's own + * checks. A child field that IS the parent's (the same Zod instance, shared + * through `.extend()`) stores exactly what the parent stores, fallbacks + * included, so it is accepted whatever they are: equal markers, or the + * identity rule's equal schemas, say so. A field the child declares + * separately has each fallback judged as a one-value child, under both + * readings of the finite-value comparison; one the comparison cannot judge + * by value (a format, a date) is refused. `.prefault(v)` is parsed like any + * input and projects nothing. + */ +/** + * The schema admitting exactly `value`: a scalar pinned with `const`, an + * array as the closed tuple of its members, an object as the closed object + * of its members, so the comparison walks into a structured value instead + * of refusing an unjudgeable `const`. + */ +function singletonSchema(value: unknown): JsonSchema { + if (Array.isArray(value)) { + return { + type: ARRAY_TYPE_TOKEN, + prefixItems: value.map((member) => singletonSchema(member)), + items: false, + minItems: value.length, + maxItems: value.length, + }; + } + if (value !== null && typeof value === "object") { + const properties = createDataKeyedBag(); + for (const [key, member] of Object.entries(value)) { + properties[key] = singletonSchema(member); + } + return { + type: OBJECT_TYPE_TOKEN, + properties, + required: Object.keys(value), + additionalProperties: false, + }; + } + return { const: value }; +} + +function compareStoredFallback( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult | undefined { + // The instance alone decides: Zod evaluates a default factory each time it + // projects, so one shared `z.date().default(() => new Date())` lists a + // different value on each side. + const childInstance = fallbackInstance(child); + if ( + childInstance !== undefined && + childInstance === fallbackInstance(parent) + ) { + return undefined; + } + for (const value of storedFallbacks(child)) { + const result = compareSchemas( + singletonSchema(value), + parent, + path, + descend(walk), + ); + if (result.verdict !== "subtype") return result; + } + return undefined; +} + +function compareSchemas( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + if (walk.depth > MAX_STRUCTURAL_SUBTYPE_DEPTH) { + return incomparable("max-depth-exceeded", path); + } + + const fallbackResult = compareStoredFallback(child, parent, path, walk); + if (fallbackResult !== undefined) return fallbackResult; + + // A schema is trivially a subtype of itself, regardless of which keywords + // it carries — including `$ref` (recursive `z.lazy`) and `allOf` + // (`z.intersection`), which the guards just below cannot judge at all. + // This full-schema identity check (every keyword, not just + // COMPARABLE_KEYWORDS) must run BEFORE those guards, or a child that + // copies a parent's `$ref`/`allOf` property verbatim is refused as + // SCHEMA_INCOMPARABLE instead of accepted as an identical, and therefore + // trivially compatible, property. + const strippedChild = stripSchemaMetadata(child); + const strippedParent = stripSchemaMetadata(parent); + if (sameSchema(strippedChild, strippedParent, walk)) return SUBTYPE; + + const unprojectableResult = compareUnprojectable( + child, + parent, + strippedParent, + path, + ); + if (unprojectableResult !== undefined) return unprojectableResult; + + if ( + hasOwnKey(child, REFERENCE_KEYWORD) || + hasOwnKey(parent, REFERENCE_KEYWORD) + ) { + return incomparable("schema-reference", path); + } + + if ( + hasOwnKey(child, ZOD_ONLY_CONSTRAINT_KEYWORD) || + hasOwnKey(parent, ZOD_ONLY_CONSTRAINT_KEYWORD) || + hasOwnKey(parent, ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD) + ) { + return incomparable("unsupported-construct", path); + } + + const childUnmodeled = unmodeledConstruct(strippedChild, path); + if (childUnmodeled !== undefined) return childUnmodeled; + const parentUnmodeled = unmodeledConstruct(strippedParent, path); + if (parentUnmodeled !== undefined) return parentUnmodeled; + + if ( + sameSchema( + comparableKeywords(strippedChild), + comparableKeywords(strippedParent), + walk, + ) + ) { + return SUBTYPE; + } + + if (!exclusiveUnionReadsAsUnion(parent)) { + return incomparable("unsupported-construct", path); + } + + if (isUnionSchema(child) || isUnionSchema(parent)) { + const unionResult = compareUnion(child, parent, path, walk); + if (unionResult.verdict !== "subtype") return unionResult; + return compareUnionSiblingConstraints(child, parent, path, walk); + } + + return compareLeaf(child, parent, path, walk); +} + +/** + * Whether `schema` itself carries a union keyword (`anyOf` or `oneOf`). + * Checked directly on the keyword rather than through migration's + * `propertyTypeSignature` — that helper is the diff's construct SELECTOR + * (which reads `type` before `anyOf`/`oneOf`, so a schema carrying both would + * silently hide its union from a caller using it as a union detector) and is + * not the right owner for this question. + */ +function declaresUnionMembers(schema: JsonSchema): boolean { + return hasOwnKey(schema, "anyOf") || hasOwnKey(schema, "oneOf"); +} + +/** + * Whether `schema` admits more than one alternative: declared members, or a + * `type` token array. A token array is a union in its own right — `{ type: + * ["string", "null"] }` admits exactly what `anyOf: [{ type: "string" }, { + * type: "null" }]` does — and it is how the projection writes a union of bare + * primitives. + */ +function isUnionSchema(schema: JsonSchema): boolean { + return declaresUnionMembers(schema) || Array.isArray(schema.type); +} + +// ============================================================ +// Unions +// ============================================================ + +/** + * Whether two schemas provably admit no common value because their `type` + * tokens differ. `integer` is a `number`, so that pair overlaps; a schema + * stating no single token proves nothing. + */ +function disjointByTypeToken(left: JsonSchema, right: JsonSchema): boolean { + const leftType = left.type; + const rightType = right.type; + if (typeof leftType !== "string" || typeof rightType !== "string") { + return false; + } + return ( + !typeTokenNarrows(leftType, rightType) && + !typeTokenNarrows(rightType, leftType) + ); +} + +/** Whether every value of type `child` is a value of type `parent`: the same token, or `integer` under `number`. */ +function typeTokenNarrows( + child: NonNullable, + parent: NonNullable, +): boolean { + return ( + child === parent || + (child === INTEGER_TYPE_TOKEN && parent === NUMBER_TYPE_TOKEN) + ); +} + +/** The value set a schema pins one REQUIRED property to, if it does. */ +function requiredPropertyValues( + schema: JsonSchema, + propertyName: string, +): ReadonlySet | undefined { + if (!requiredKeys(schema).includes(propertyName)) return undefined; + const properties = schema.properties; + if (properties === undefined || !hasOwnKey(properties, propertyName)) { + return undefined; + } + return allowedValues(requireDefined(properties[propertyName])); +} + +/** + * Whether two object schemas provably admit no common value because some + * property both REQUIRE is pinned to value sets that share nothing — the + * shape `z.discriminatedUnion` projects. + */ +function disjointByDiscriminator(left: JsonSchema, right: JsonSchema): boolean { + for (const propertyName of Object.keys(left.properties ?? {})) { + const leftValues = requiredPropertyValues(left, propertyName); + const rightValues = requiredPropertyValues(right, propertyName); + if (leftValues === undefined || rightValues === undefined) continue; + if ([...leftValues].every((value) => !rightValues.has(value))) return true; + } + return false; +} + +function provablyDisjoint(left: JsonSchema, right: JsonSchema): boolean { + return ( + disjointByTypeToken(left, right) || disjointByDiscriminator(left, right) + ); +} + +/** + * Whether a PARENT's `oneOf` may be read as `anyOf` ({@link unionMembers}). + * + * `oneOf` admits a value matching EXACTLY one member, so a child value that + * matches two is rejected by the parent while a member-wise reading accepts + * it. The two readings coincide only when no value can match two members, + * which this proves pairwise or not at all: `z.discriminatedUnion` always + * passes, `z.xor` passes only when its members happen to be exclusive. A + * schema carrying both `anyOf` and `oneOf` is the conjunction of the two and + * is not read through either one. + * + * Only the parent is asked. A child's `oneOf` admits a SUBSET of what its + * members admit together, so reading it as `anyOf` can only cost a refusal. + */ +function exclusiveUnionReadsAsUnion(parent: JsonSchema): boolean { + const members = parent.oneOf; + if (members === undefined) return true; + if (parent.anyOf !== undefined) return false; + return members.every((left, index) => + members.slice(index + 1).every((right) => provablyDisjoint(left, right)), + ); +} + +type UnionMember = Readonly<{ + schema: JsonSchema; + segment: string | undefined; +}>; + +/** + * The alternatives `schema` admits, each with the path segment that names it. + * Declared members win when a schema carries both forms: its token array is + * then a sibling keyword, compared like any other. A schema that is not a + * union is its own single alternative and has no segment: one would point a + * caller at a path the schema never actually has. + * + * A token-array member is the whole schema narrowed to one token. Every + * other keyword rides along, because a keyword constrains only values of the + * type it applies to: `{ type: ["string", "null"], minLength: 3 }` is a + * string of at least three characters, or null. + */ +function unionMembers(schema: JsonSchema): readonly UnionMember[] { + const declared = schema.anyOf ?? schema.oneOf; + if (declared !== undefined) { + return declared.map((member, index) => ({ + schema: member, + segment: unionMemberSegment(index), + })); + } + const type = schema.type; + if (typeof type === "string" || type === undefined) { + return [{ schema, segment: undefined }]; + } + return type.map((token, index) => ({ + schema: { ...schema, type: token }, + segment: typeTokenSegment(index), + })); +} + +/** + * `schema` with `anyOf`/`oneOf` removed, everything else untouched. Used to + * isolate a union schema's SIBLING keywords — the constraints ANDed alongside + * the union rather than expressed by it. + */ +function withoutUnionKeywords(schema: JsonSchema): JsonSchema { + const { anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema; + return rest; +} + +/** + * A union keyword (`anyOf`/`oneOf`) is ANDed with every sibling keyword on + * the same schema object, never replaced by it: `{ type: "string", anyOf: + * [...] }` admits only strings that ALSO match one of the `anyOf` members. + * `compareUnion` decides the member-matching half of that AND; this decides + * the other half by re-running the ordinary comparison against the PARENT's + * sibling keywords with `anyOf`/`oneOf` stripped off. + * + * Only the parent side needs this. Dropping the CHILD's own sibling keywords + * when it is a union is conservative — treating the child as looser than it + * really is can only produce a false `incomparable`/`not-subtype` refusal, + * never a false `subtype` accept — so no equivalent check runs for `child`. + * Dropping the PARENT's sibling keywords is unsound: it silently narrows what + * the parent constraint set actually excludes, which is exactly the defect + * this function closes. + */ +function compareUnionSiblingConstraints( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + // A token array's members already carry every sibling keyword. + if (!declaresUnionMembers(parent)) return SUBTYPE; + const parentSiblings = withoutUnionKeywords(parent); + if (Object.keys(stripSchemaMetadata(parentSiblings)).length === 0) { + return SUBTYPE; + } + return compareSchemas(child, parentSiblings, path, walk); +} + +function compareUnion( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + const parentMembers = unionMembers(parent); + + for (const childMember of unionMembers(child)) { + const memberPath = + childMember.segment === undefined ? path : [...path, childMember.segment]; + let matchedParentMember = false; + // A parent member that is itself incomparable does not end the search: + // the schema is order-insensitive by construction (unions are sets), so + // a LATER parent member that matches must still be found before this + // reports `incomparable` — the top-level verdict for a given + // pair must not depend on the declared order of the parent's members. + let firstIncomparable: StructuralSubtypeResult | undefined; + for (const parentMember of parentMembers) { + const probeResult = compareSchemas( + childMember.schema, + parentMember.schema, + memberPath, + descend(walk), + ); + if (probeResult.verdict === "subtype") { + matchedParentMember = true; + break; + } + if ( + probeResult.verdict === "incomparable" && + firstIncomparable === undefined + ) { + firstIncomparable = probeResult; + } + } + if (!matchedParentMember) { + return ( + firstIncomparable ?? notSubtype("no-matching-union-member", memberPath) + ); + } + } + + return SUBTYPE; +} + +// ============================================================ +// Leaves +// ============================================================ + +function compareLeaf( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + const valueSetResult = compareValueSets(child, parent, path); + if (valueSetResult.verdict !== "subtype") return valueSetResult; + + // Never a token array here: `compareSchemas` reads one as a union. + const childType = child.type; + const parentType = parent.type; + + // A child pinned to values of the parent's type satisfies that type + // whatever token, if any, the child states itself. + const pinnedToParentType = + typeof parentType === "string" && + everyAdmittedValue(child, (value) => scalarHasTypeToken(value, parentType)); + + if ( + parentType !== undefined && + childType === undefined && + !pinnedToParentType + ) { + return notSubtype("unconstrained-where-parent-constrains", path); + } + + if ( + childType !== undefined && + parentType !== undefined && + !typeTokenNarrows(childType, parentType) && + !pinnedToParentType + ) { + return notSubtype("type-token-mismatch", path); + } + + if (isObjectSchema(child) && isObjectSchema(parent)) { + return compareObject(child, parent, path, walk); + } + + // The parent's token picks the comparison: it is the type whose keywords + // the child must satisfy. + switch (parentType ?? childType) { + case ARRAY_TYPE_TOKEN: { + return compareArray(child, parent, path, walk); + } + case STRING_TYPE_TOKEN: { + return compareStringConstraints(child, parent, path); + } + case NUMBER_TYPE_TOKEN: + case INTEGER_TYPE_TOKEN: { + return compareNumericConstraints(child, parent, path); + } + case BOOLEAN_TYPE_TOKEN: + case NULL_TYPE_TOKEN: { + return SUBTYPE; + } + case OBJECT_TYPE_TOKEN: { + return compareObject(child, parent, path, walk); + } + // `undefined` here means neither side carries a `type` token at all — + // both are `{}`, or both carry only `const`/`enum` (already checked + // above by `compareValueSets`). + case undefined: + default: { + if ( + hasKeywordOutsideValueSet( + comparableKeywords(stripSchemaMetadata(child)), + ) || + hasKeywordOutsideValueSet( + comparableKeywords(stripSchemaMetadata(parent)), + ) + ) { + return incomparable("unsupported-construct", path); + } + return SUBTYPE; + } + } +} + +/** The values a schema's `const` or `enum` pins it to, as written. */ +function pinnedValues(schema: JsonSchema): readonly unknown[] | undefined { + if (hasOwnKey(schema, "const")) return [schema.const]; + return schema.enum; +} + +function allowedValues(schema: JsonSchema): ReadonlySet | undefined { + const values = pinnedValues(schema); + if (values === undefined) return undefined; + return new Set(values.map((value) => canonicalJson(value))); +} + +function compareValueSets( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], +): StructuralSubtypeResult { + const parentValues = allowedValues(parent); + if (parentValues === undefined) return SUBTYPE; + + const childValues = allowedValues(child); + if (childValues === undefined) { + return notSubtype("value-set-not-subset", path); + } + for (const value of childValues) { + if (!parentValues.has(value)) { + return notSubtype("value-set-not-subset", path); + } + } + return SUBTYPE; +} + +// ============================================================ +// Finite value sets +// ============================================================ + +/** A JSON value the value-by-value comparison judges. */ +type ScalarValue = string | number | boolean | null; + +function isScalarValue(value: unknown): value is ScalarValue { + return ( + value === null || + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ); +} + +/** + * Whether `value` is a value of type `token`. A scalar is never an `object` + * or an `array`, and an unknown token is not judged, so both are `false`. + */ +function scalarHasTypeToken(value: ScalarValue, token: string): boolean { + switch (token) { + case STRING_TYPE_TOKEN: { + return typeof value === "string"; + } + case NUMBER_TYPE_TOKEN: { + return typeof value === "number" && Number.isFinite(value); + } + case INTEGER_TYPE_TOKEN: { + return Number.isInteger(value); + } + case BOOLEAN_TYPE_TOKEN: { + return typeof value === "boolean"; + } + case NULL_TYPE_TOKEN: { + return value === null; + } + default: { + return false; + } + } +} + +/** + * The values `schema` pins with `const` or `enum`, when this comparison can + * judge them: a non-empty set of scalars, each a value of the schema's own + * `type`. `undefined` otherwise. A pinned value of another type would make the + * set describe less than the schema's source may admit — the projection + * writes a literal `NaN` or `Infinity` as `null` under `type: "number"` — and + * an empty set would satisfy every keyword vacuously. + */ +function admittedScalarValues( + schema: JsonSchema, +): readonly ScalarValue[] | undefined { + const pinned = pinnedValues(schema); + if (pinned === undefined || pinned.length === 0) return undefined; + if (!pinned.every((value) => isScalarValue(value))) return undefined; + const { type } = schema; + if ( + typeof type === "string" && + !pinned.every((value) => scalarHasTypeToken(value, type)) + ) { + return undefined; + } + return pinned; +} + +/** + * Whether `schema` pins a finite value set every admitted member of which + * passes `test`. + * + * A finite child satisfies a parent keyword it does not state itself when + * each of its values does: `z.literal("x")` satisfies `minLength: 1`, while + * `z.string()` does not. Each comparison asks this only after the child's + * own declared keyword failed to imply the parent's, so the two answers are + * alternatives: a value that does not pass `test` leaves the declared + * comparison's refusal standing, and a `test` that cannot judge a value + * faithfully returns `false` for it. + */ +function everyAdmittedValue( + schema: JsonSchema, + test: (value: ScalarValue) => boolean, +): boolean { + const values = admittedScalarValues(schema); + return values?.every((value) => test(value)) === true; +} + +function everyAdmittedString( + schema: JsonSchema, + test: (value: string) => boolean, +): boolean { + return everyAdmittedValue( + schema, + (value) => typeof value === "string" && test(value), + ); +} + +function everyAdmittedNumber( + schema: JsonSchema, + test: (value: number) => boolean, +): boolean { + return everyAdmittedValue( + schema, + (value) => + typeof value === "number" && Number.isFinite(value) && test(value), + ); +} + +// ============================================================ +// Objects +// ============================================================ + +/** + * The single owner of "does this side permit undeclared keys, and under what + * schema": `undefined` when the side is CLOSED (an explicit + * `additionalProperties: false` — every `z.object` projects this), otherwise + * the schema those undeclared keys must satisfy (`{}`, the top type, when + * `additionalProperties` is `true` or absent; the catchall schema itself + * otherwise). Every caller in `compareObject` reads this one function rather + * than re-spelling the `!== undefined && !== false` / `!== false` checks — + * two such inline re-spellings previously disagreed with each other (one + * treating an ABSENT `additionalProperties` as open, the other as closed), + * which is exactly the seam that let a `z.looseObject`/`z.record`/`.catchall` + * child evade a parent-declared property by leaving it undeclared. + */ +function openExtrasSchema(schema: JsonSchema): JsonSchema | undefined { + const additionalProperties = schema.additionalProperties; + if (additionalProperties === false) return undefined; + if (additionalProperties === undefined || additionalProperties === true) { + return {}; + } + return additionalProperties; +} + +function compareObject( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + const childProps = child.properties ?? {}; + const parentProps = parent.properties ?? {}; + const childRequired = new Set(storedKeys(child)); + const parentRequired = new Set(requiredKeys(parent)); + const childExtrasSchema = openExtrasSchema(child); + + // A key the child does not name may still be one it always stores, under + // its additional properties (an exhaustive record), which the comparison + // below then holds to the parent's schema for it. + for (const name of parentRequired) { + if (childRequired.has(name)) continue; + // The same property schema with the same optionality on both sides + // stores, and omits, exactly what the parent's does: a field shared + // through `.extend()`. + const childOptionality = keyOptionality(child)?.[name]; + const parentOptionality = keyOptionality(parent)?.[name]; + if ( + childOptionality !== undefined && + parentOptionality !== undefined && + canonicalJson(childOptionality) === canonicalJson(parentOptionality) && + hasOwnKey(childProps, name) && + hasOwnKey(parentProps, name) && + sameSchema( + stripSchemaMetadata(requireDefined(childProps[name])), + stripSchemaMetadata(requireDefined(parentProps[name])), + walk, + ) + ) { + continue; + } + return notSubtype( + hasOwnKey(childProps, name) ? + "optional-in-child-required-in-parent" + : "missing-required-property", + [...path, name], + ); + } + + for (const [name, parentProperty] of Object.entries(parentProps)) { + if (hasOwnKey(childProps, name)) { + const propertyResult = compareSchemas( + requireDefined(childProps[name]), + parentProperty, + [...path, name], + descend(walk), + ); + if (propertyResult.verdict !== "subtype") return propertyResult; + continue; + } + // The child does not declare `name` by name. A CLOSED child (the common + // `z.object` case) genuinely omits it — the "child may omit optional + // parent properties" rule. An OPEN child (`z.looseObject`, `z.record`, + // `.catchall`) does not omit it: any value satisfying the child could + // still carry `name`, so the child's additional-properties schema must + // itself narrow the parent's declared property, not be skipped (this is + // the fix for the width-subtyping evasion above). + if (childExtrasSchema === undefined) continue; + const extraResult = compareSchemas( + childExtrasSchema, + parentProperty, + [...path, name], + descend(walk), + ); + if (extraResult.verdict !== "subtype") return extraResult; + } + + // Width subtyping for keys NEITHER side declares by name. The parent's + // `additionalProperties: false` (or its absence) is not applied to the + // child's extra keys — every `z.object` projects `false`, so this branch is + // skipped for the common case, and the child's added properties are + // unconstrained rather than refused. + const parentExtrasSchema = openExtrasSchema(parent); + if (parentExtrasSchema !== undefined) { + for (const [name, childProperty] of Object.entries(childProps)) { + if (hasOwnKey(parentProps, name)) continue; + const extraResult = compareSchemas( + childProperty, + parentExtrasSchema, + [...path, name], + descend(walk), + ); + if (extraResult.verdict !== "subtype") return extraResult; + } + + if (childExtrasSchema !== undefined) { + const additionalResult = compareSchemas( + childExtrasSchema, + parentExtrasSchema, + [...path, ADDITIONAL_PROPERTIES_SEGMENT], + descend(walk), + ); + if (additionalResult.verdict !== "subtype") return additionalResult; + } + } + + return comparePropertyNames(child, parent, path, walk); +} + +/** + * Every key the child admits must be a key the parent admits: the child's + * `propertyNames` is compared as a subtype of the parent's, so + * `z.record(z.enum(["a", "b"]), V)` narrows `z.record(z.string(), V)`. A + * JSON object's keys are always strings, so a child with no explicit + * `propertyNames` (every construct but `z.record`) is exactly as tight as one + * declaring `{"type":"string"}`. + */ +function comparePropertyNames( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + if (parent.propertyNames === undefined) return SUBTYPE; + const keyResult = compareSchemas( + child.propertyNames ?? { type: STRING_TYPE_TOKEN }, + parent.propertyNames, + [...path, PROPERTY_NAMES_SEGMENT], + descend(walk), + ); + if (keyResult.verdict !== "not-subtype") return keyResult; + return notSubtype("property-names-mismatch", keyResult.path); +} + +// ============================================================ +// Arrays and tuples +// ============================================================ + +const CLOSED_TAIL = "closed"; +const OPEN_TAIL = "open"; + +/** What an array admits past its `prefixItems`: nothing, anything, or a schema. */ +type ArrayTail = typeof CLOSED_TAIL | typeof OPEN_TAIL | JsonSchema; + +/** + * The projection closes a tuple that has no rest element with `items: false`. + * A document stored by an earlier projection closes one by omitting `items` + * beside `prefixItems`, so that spelling is read as closed too; the current + * projection never emits it. + */ +function arrayTail(schema: JsonSchema): ArrayTail { + const { items } = schema; + if (items === false) return CLOSED_TAIL; + if (items === true) return OPEN_TAIL; + if (items !== undefined) return items; + return closesTupleByOmission(schema) ? CLOSED_TAIL : OPEN_TAIL; +} + +function isTailSchema(tail: ArrayTail): tail is JsonSchema { + return typeof tail !== "string"; +} + +/** A closed array with no declared prefix is the empty tuple. */ +function tuplePrefix( + schema: JsonSchema, + tail: ArrayTail, +): readonly JsonSchema[] | undefined { + return schema.prefixItems ?? (tail === CLOSED_TAIL ? [] : undefined); +} + +function compareArray( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], + walk: Walk, +): StructuralSubtypeResult { + const childTail = arrayTail(child); + const parentTail = arrayTail(parent); + const childPrefix = tuplePrefix(child, childTail); + const parentPrefix = tuplePrefix(parent, parentTail); + + // Tuple shape before length bounds: the projection states a closed tuple's + // arity as `minItems`/`maxItems` too, and a mismatched arity is the more + // precise refusal. + if (parentPrefix !== undefined) { + if (childPrefix?.length !== parentPrefix.length) { + return notSubtype("tuple-arity-mismatch", path); + } + for (const [index, parentMember] of parentPrefix.entries()) { + const memberResult = compareSchemas( + requireDefined(childPrefix[index]), + parentMember, + [...path, tupleIndexSegment(index)], + descend(walk), + ); + if (memberResult.verdict !== "subtype") return memberResult; + } + // A closed parent tuple has no elements beyond its declared prefix. A + // child of equal prefix arity that is not closed too (a rest element, + // e.g. `z.tuple([string], number)`) permits values longer than the parent + // ever allows, even though the prefix lengths matched above. + if (parentTail === CLOSED_TAIL && childTail !== CLOSED_TAIL) { + return notSubtype("tuple-arity-mismatch", path); + } + } else if (childPrefix !== undefined && isTailSchema(parentTail)) { + // A tuple narrowing a homogeneous parent array: every prefix member must + // itself narrow the parent's item type. + for (const [index, childMember] of childPrefix.entries()) { + const memberResult = compareSchemas( + childMember, + parentTail, + [...path, tupleIndexSegment(index)], + descend(walk), + ); + if (memberResult.verdict !== "subtype") return memberResult; + } + } + + const childMinItems = child.minItems ?? 0; + const parentMinItems = parent.minItems ?? 0; + if (childMinItems < parentMinItems) { + return notSubtype("array-bounds-not-tighter", path); + } + + const childMaxItems = child.maxItems ?? Number.POSITIVE_INFINITY; + const parentMaxItems = parent.maxItems ?? Number.POSITIVE_INFINITY; + if (childMaxItems > parentMaxItems) { + return notSubtype("array-bounds-not-tighter", path); + } + + // A closed child has no elements beyond its prefix, so there is no tail + // left to compare against the parent's. + if (!isTailSchema(parentTail) || childTail === CLOSED_TAIL) return SUBTYPE; + if (childTail === OPEN_TAIL) { + return notSubtype("unconstrained-where-parent-constrains", [ + ...path, + ARRAY_ITEM_SEGMENT, + ]); + } + return compareSchemas( + childTail, + parentTail, + [...path, ARRAY_ITEM_SEGMENT], + descend(walk), + ); +} + +// ============================================================ +// Strings +// ============================================================ + +/** + * Length under each reading a validator applies: JSON Schema counts Unicode + * code points, Zod's `.min()`/`.max()`/`.length()` count UTF-16 code units. A + * string holding an astral character has more units than points, so a lower + * bound is judged on points and an upper bound on units — the reading that + * admits less, which both readings then agree to. + */ +function satisfiesMinLength(value: string, minLength: number): boolean { + // eslint-disable-next-line @typescript-eslint/no-misused-spread -- code points are what JSON Schema counts + return [...value].length >= minLength; +} + +function satisfiesMaxLength(value: string, maxLength: number): boolean { + return value.length <= maxLength; +} + +/** + * The flags a projected `pattern` is read under. Zod tests the flagless + * `RegExp` it was given (a flagged one never reaches this comparison — the + * projection marks it {@link ZOD_ONLY_CONSTRAINT_KEYWORD}); a JSON Schema + * validator commonly compiles the pattern with Unicode semantics (`u`). A + * value satisfies the pattern only when it matches under both readings. + */ +const PATTERN_READING_FLAGS = ["", "u"] as const; + +function compilePattern(pattern: string, flags: string): RegExp | undefined { + try { + return new RegExp(pattern, flags); + } catch (error) { + if (error instanceof SyntaxError) return undefined; + throw error; + } +} + +/** A pattern either reading cannot compile is satisfied by nothing. */ +function satisfiesPattern(value: string, pattern: string): boolean { + return PATTERN_READING_FLAGS.every( + (flags) => compilePattern(pattern, flags)?.test(value) === true, + ); +} + +/** + * The `format` names Zod projects beside a `pattern` that states the whole + * check: `.startsWith()`, `.endsWith()`, `.includes()`, `.lowercase()`, + * `.uppercase()`. Zod enforces each through `startsWith`/`endsWith`/ + * `includes` or that same pattern, which a matching value satisfies; to a + * JSON Schema validator each is an unknown format, which asserts nothing. + * Every other format — `email`, `uuid`, `date-time`, `uri`, a custom + * `z.stringFormat()` — states a check of its own that is not evaluated here, + * so a finite child satisfies it only by declaring the same format. + */ +const PATTERN_STATED_FORMATS: ReadonlySet = new Set([ + "starts_with", + "ends_with", + "includes", + "lowercase", + "uppercase", +]); + +function satisfiesFormat(value: string, parent: JsonSchema): boolean { + return ( + parent.format !== undefined && + PATTERN_STATED_FORMATS.has(parent.format) && + parent.pattern !== undefined && + satisfiesPattern(value, parent.pattern) + ); +} + +function compareStringConstraints( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], +): StructuralSubtypeResult { + const parentMinLength = parent.minLength ?? 0; + if ( + (child.minLength ?? 0) < parentMinLength && + !everyAdmittedString(child, (value) => + satisfiesMinLength(value, parentMinLength), + ) + ) { + return notSubtype("string-length-not-tighter", path); + } + + const parentMaxLength = parent.maxLength ?? Number.POSITIVE_INFINITY; + if ( + (child.maxLength ?? Number.POSITIVE_INFINITY) > parentMaxLength && + !everyAdmittedString(child, (value) => + satisfiesMaxLength(value, parentMaxLength), + ) + ) { + return notSubtype("string-length-not-tighter", path); + } + + const parentPattern = parent.pattern; + if ( + parentPattern !== undefined && + child.pattern !== parentPattern && + !everyAdmittedString(child, (value) => + satisfiesPattern(value, parentPattern), + ) + ) { + return notSubtype("pattern-mismatch", path); + } + + // `format` is a constraint, not an annotation — see COMPARABLE_KEYWORDS. + if ( + parent.format !== undefined && + child.format !== parent.format && + !everyAdmittedString(child, (value) => satisfiesFormat(value, parent)) + ) { + return notSubtype("format-mismatch", path); + } + + return SUBTYPE; +} + +// ============================================================ +// Numbers +// ============================================================ + +type NumericBound = Readonly<{ value: number; exclusive: boolean }>; + +function readNumber(schema: JsonSchema, keyword: string): number | undefined { + const value = schema[keyword]; + return typeof value === "number" ? value : undefined; +} + +function lowerBound(schema: JsonSchema): NumericBound | undefined { + const minimum = readNumber(schema, "minimum"); + const exclusiveMinimum = readNumber(schema, "exclusiveMinimum"); + if (minimum === undefined && exclusiveMinimum === undefined) return undefined; + if (exclusiveMinimum === undefined) + return { value: requireDefined(minimum), exclusive: false }; + if (minimum === undefined) + return { value: exclusiveMinimum, exclusive: true }; + return exclusiveMinimum >= minimum ? + { value: exclusiveMinimum, exclusive: true } + : { value: minimum, exclusive: false }; +} + +function upperBound(schema: JsonSchema): NumericBound | undefined { + const maximum = readNumber(schema, "maximum"); + const exclusiveMaximum = readNumber(schema, "exclusiveMaximum"); + if (maximum === undefined && exclusiveMaximum === undefined) return undefined; + if (exclusiveMaximum === undefined) + return { value: requireDefined(maximum), exclusive: false }; + if (maximum === undefined) + return { value: exclusiveMaximum, exclusive: true }; + return exclusiveMaximum <= maximum ? + { value: exclusiveMaximum, exclusive: true } + : { value: maximum, exclusive: false }; +} + +/** Is `child`'s lower bound at least as tight as `parent`'s? */ +function lowerBoundTighterOrEqual( + child: NumericBound, + parent: NumericBound, +): boolean { + if (child.value !== parent.value) return child.value > parent.value; + return child.exclusive || !parent.exclusive; +} + +/** Is `child`'s upper bound at least as tight as `parent`'s? */ +function upperBoundTighterOrEqual( + child: NumericBound, + parent: NumericBound, +): boolean { + if (child.value !== parent.value) return child.value < parent.value; + return child.exclusive || !parent.exclusive; +} + +function satisfiesLowerBound(value: number, bound: NumericBound): boolean { + return bound.exclusive ? value > bound.value : value >= bound.value; +} + +function satisfiesUpperBound(value: number, bound: NumericBound): boolean { + return bound.exclusive ? value < bound.value : value <= bound.value; +} + +/** A finite number as `coefficient × 10^exponent`, exactly. */ +type ExactDecimal = Readonly<{ coefficient: bigint; exponent: number }>; + +const DECIMAL_NUMERAL = /^(-?)(\d+)(?:\.(\d+))?(?:e([+-]\d+))?$/; + +/** + * `value` read as the shortest decimal numeral that round-trips it — the + * numeral its JSON form carries, and so the number a JSON Schema validator + * reading the stored document sees. + */ +function exactDecimal(value: number): ExactDecimal | undefined { + const match = DECIMAL_NUMERAL.exec(String(value)); + if (match === null) return undefined; + const [, sign = "", integerDigits = "", fractionDigits = "", exponent = "0"] = + match; + return { + coefficient: BigInt(`${sign}${integerDigits}${fractionDigits}`), + exponent: Number(exponent) - fractionDigits.length, + }; +} + +const SMALLEST_NORMAL_DOUBLE = 2 ** -1022; + +function isNormalOrZero(value: number): boolean { + return value === 0 || Math.abs(value) >= SMALLEST_NORMAL_DOUBLE; +} + +/** + * Whether `value` is a multiple of `divisor` under both readings of + * `multipleOf`. + * + * JSON Schema's: dividing the two decimals yields an integer, decided here + * with no rounding — `0.3` is a multiple of `0.1`, `0.30000000000000004` is + * not. Zod's (`floatSafeRemainder`, zod/v4/core): the floating-point quotient + * lies within `4 × Number.EPSILON` (relative) of an integer. An exact + * multiple's floating-point quotient is off its integer by at most about + * `1.5 × Number.EPSILON` relative, so Zod accepts it, provided both operands + * are normal doubles and the quotient is a safe integer; outside that range + * the value is not judged. + */ +function isDecimalMultiple(value: number, divisor: number): boolean { + if (!(divisor > 0) || !isNormalOrZero(divisor) || !isNormalOrZero(value)) { + return false; + } + const numerator = exactDecimal(value); + const denominator = exactDecimal(divisor); + if (numerator === undefined || denominator === undefined) return false; + const exponent = Math.min(numerator.exponent, denominator.exponent); + const scaledNumerator = + numerator.coefficient * 10n ** BigInt(numerator.exponent - exponent); + const scaledDenominator = + denominator.coefficient * 10n ** BigInt(denominator.exponent - exponent); + if (scaledNumerator % scaledDenominator !== 0n) return false; + const quotient = scaledNumerator / scaledDenominator; + const largestSafeQuotient = BigInt(Number.MAX_SAFE_INTEGER); + return quotient <= largestSafeQuotient && quotient >= -largestSafeQuotient; +} + +function compareNumericConstraints( + child: JsonSchema, + parent: JsonSchema, + path: readonly string[], +): StructuralSubtypeResult { + const parentLower = lowerBound(parent); + if (parentLower !== undefined) { + const childLower = lowerBound(child); + const declaredTighter = + childLower !== undefined && + lowerBoundTighterOrEqual(childLower, parentLower); + if ( + !declaredTighter && + !everyAdmittedNumber(child, (value) => + satisfiesLowerBound(value, parentLower), + ) + ) { + return notSubtype("numeric-bound-not-tighter", path); + } + } + + const parentUpper = upperBound(parent); + if (parentUpper !== undefined) { + const childUpper = upperBound(child); + const declaredTighter = + childUpper !== undefined && + upperBoundTighterOrEqual(childUpper, parentUpper); + if ( + !declaredTighter && + !everyAdmittedNumber(child, (value) => + satisfiesUpperBound(value, parentUpper), + ) + ) { + return notSubtype("numeric-bound-not-tighter", path); + } + } + + const parentMultipleOf = parent.multipleOf; + if ( + parentMultipleOf !== undefined && + child.multipleOf !== parentMultipleOf && + !everyAdmittedNumber(child, (value) => + isDecimalMultiple(value, parentMultipleOf), + ) + ) { + return notSubtype("multiple-of-mismatch", path); + } + + return SUBTYPE; +} diff --git a/packages/typegraph/src/schema/tightening-preflight.ts b/packages/typegraph/src/schema/tightening-preflight.ts new file mode 100644 index 000000000..c7056e316 --- /dev/null +++ b/packages/typegraph/src/schema/tightening-preflight.ts @@ -0,0 +1,705 @@ +/** + * The data preflight a schema tightening owes, run INSIDE the schema-commit + * transaction. + * + * `classifyOntologyChanges` + `ontologyTighteningProbes` (`./ontology-change`) + * decide WHAT the ontology half must check; `newlyConstrainedEdgeAxes` decides + * the same for the edge-cardinality half. `auditConstraintFences` + * (`src/store/claims/verify.ts`) is the ONE reader that actually checks + * either, shared with `store.verifyConstraintFences()`. This module's only + * job is to turn a proposed schema transition into the one + * `ConstraintFenceAuditPlan` that reader needs, and to turn a non-empty + * violation report into the `MigrationError` a caller refuses the commit + * with. + * + * Generalizes the ontology-only preflight rather than forking it: + * `OntologySnapshot` already carries `edges`, so no input change was needed + * to add the cardinality half. + */ +import { populatedSchemaKinds } from "../backend/schema-kind-emptiness"; +import { type SchemaCommitPreflightBackend } from "../backend/types"; +import { MigrationError } from "../errors"; +import { createSqlSchema } from "../query/compiler/schema"; +import { getDialect } from "../query/dialect"; +import { type KindRegistry } from "../registry/kind-registry"; +import { + type AcyclicEdgeRelation, + declaredAcyclicEdgeRelations, + readEdgeAcyclicityViolations, +} from "../store/acyclicity"; +import { COMPOSITION_RELATION_NAME } from "../store/claims/axis"; +import { compositionEdgeCardinalityDeclarations } from "../store/claims/composition-claims"; +import { + axisRowMembership, + rowMembershipWidens, +} from "../store/claims/edge-claims"; +import { + auditConstraintFences, + compositionFutureWindowViolations, + type ConstraintFenceViolation, + uniquenessAxisGroupFor, +} from "../store/claims/verify"; +import { readCompositionUnattachedParts } from "../store/operations/composition-create"; +import { groupBy } from "../utils/array"; +import { buildRegistryFromSerializedSchema } from "./deserializer"; +import { + type EdgeCardinalityDeclaration, + newlyConstrainedEdgeAxes, +} from "./edge-cardinality-change"; +import { assertNoRemovedCustomRelations } from "./migration"; +import { + type AddedEdgeKindProbes, + addedEdgeKindProbes, + buildSchemaBaselineRegistry, + classifyOntologyChanges, + type OntologyChange, + type OntologyDataProbe, + type OntologySnapshot, + ontologyTighteningProbes, + requiredPartKindsByViaEdgeKind, + snapshotKindDelta, +} from "./ontology-change"; +import { type SchemaCommitTransition } from "./types"; +import { + assertUniquenessClaimGatesOpen, + declaredKindUniqueConstraints, + mayGateUniquenessClaims, + type UniquenessClaimGate, + uniquenessClaimGates, +} from "./uniqueness-claim-gate"; + +/** + * What a caller of `commitNewSchemaVersionWithPreflight` refuses with when + * the backend cannot commit a preflight atomically. Reusing IDENTITY's code + * for a tightening-only commit would misdirect an operator on a graph with + * identity disabled, so `prepareSchemaTighteningPreflight` hands back the + * SPECIFIC bag its own decision earned, rather than a caller re-deriving + * which half (ontology or edge-cardinality) is actually why the atomic + * primitive is needed. + */ +export type AtomicPreflightCapabilityError = Readonly<{ + code: string; + message: string; + suggestion?: string; + /** What the refusal's `details` carry beside `code` and `graphId`. */ + details?: Readonly>; +}>; + +const ATOMIC_PREFLIGHT_BACKEND_SUGGESTION = + "Run this migration through a backend built by `createSqliteBackend` or " + + "`createPostgresBackend`, or implement `commitSchemaVersionWithPreflight`."; + +/** + * Thrown when an ontology tightening needs the atomic preflight-commit + * primitive and the backend does not implement it. + */ +const ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR: AtomicPreflightCapabilityError = + { + code: "ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND", + message: + "This backend cannot atomically validate an ontology tightening against existing data as part of a schema transition.", + suggestion: ATOMIC_PREFLIGHT_BACKEND_SUGGESTION, + }; + +/** + * Thrown when an edge-cardinality tightening (a commit that newly declares a + * constrained `cardinality` or `targetCardinality` on an edge kind, INCLUDING + * a brand-new kind — see {@link newlyConstrainedEdgeAxes}) needs the atomic + * preflight-commit primitive and the backend does not implement it. Kept + * distinct from {@link ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR} + * so the refusal names the axis a caller actually declared, rather than + * blaming "ontology" for a commit that touched no disjointness, uniqueness, + * or endpoint-assignability axiom at all. + */ +const EDGE_CARDINALITY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR: AtomicPreflightCapabilityError = + { + code: "EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND", + message: + "This backend cannot atomically validate a newly-constrained edge cardinality against existing data as part of a schema transition.", + suggestion: ATOMIC_PREFLIGHT_BACKEND_SUGGESTION, + }; + +/** + * Thrown when a commit that changes where — or whether — existing rows hold + * uniqueness claims needs the atomic preflight-commit primitive and the + * backend does not implement it. The emptiness probe that decides the commit + * must run in the transaction that publishes it; a backend that cannot do + * that cannot make the decision at all. + */ +const UNIQUENESS_CLAIM_GATE_ATOMIC_PREFLIGHT_CAPABILITY_ERROR: AtomicPreflightCapabilityError = + { + code: "UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND", + message: + "This backend cannot atomically verify that a kind is empty while changing a uniqueness constraint that covers it as part of a schema transition.", + suggestion: ATOMIC_PREFLIGHT_BACKEND_SUGGESTION, + }; + +export type SchemaTighteningPreflightParams = SchemaCommitTransition & + Readonly<{ + before: OntologySnapshot; + after: OntologySnapshot; + /** + * The already-classified diff, when a caller computed one (`ensureSchema` + * and `Store.evolve` both diff `before`/`after` before reaching this + * preflight). Reusing it avoids reclassifying the identical + * `before`/`after` pair a second time in the same commit; `migrateSchema` + * and `evolve`'s dropped-kinds branch, which never compute a diff, omit + * this and classification runs internally, once. + */ + changes?: readonly OntologyChange[]; + }>; + +/** + * The probe of one kind, or `undefined` when this diff carries none — + * `ontologyTighteningProbes` emits at most one probe per kind, so the first + * match is the only one. + */ +function probeOfKind( + probes: readonly OntologyDataProbe[], + kind: K, +): Extract | undefined { + return probes.find((probe) => probe.kind === kind) as + Extract | undefined; +} + +/** The first couple of violations, rendered for a refusal message. */ +function previewViolations( + violations: readonly ConstraintFenceViolation[], +): string { + return violations + .slice(0, 2) + .map((violation) => JSON.stringify(violation)) + .join("; "); +} + +/** + * Required-existence check. Resolves the delta's `via` edge kinds to their + * REQUIRED-existence part kinds against the PROPOSED registry through + * {@link requiredPartKindsByViaEdgeKind} (`./ontology-change`), the same + * reading the classifier used to name those edge kinds, so this probe can + * never admit an `existence: "optional"` pair's part kind (which can never + * violate `compositionExistence`) or miss a subclass of a declared required + * part kind — then reads {@link readCompositionUnattachedParts} for the + * result. + */ +async function readCompositionUnattachedPartsForEdgeKinds( + proposedRegistry: KindRegistry, + target: SchemaCommitPreflightBackend, + graphId: string, + edgeKinds: readonly string[], +): Promise { + const partKinds = new Set( + requiredPartKindsByViaEdgeKind(proposedRegistry) + .filter(({ viaEdgeKind }) => edgeKinds.includes(viaEdgeKind)) + .map(({ partKind }) => partKind), + ); + if (partKinds.size === 0) return []; + const unattached = await readCompositionUnattachedParts( + proposedRegistry, + target, + graphId, + [...partKinds], + ); + const byPartKind = groupBy(unattached, (part) => part.kind); + return [...byPartKind.entries()].map(([partKind, parts]) => ({ + family: "compositionExistence" as const, + partKind, + parts, + })); +} + +/** + * The probes an added edge kind owes, narrowed to the kinds that hold rows: a + * kind with none (the ordinary case, a kind that never existed) has nothing + * either probe could find, and is settled by one emptiness read rather than + * by the audit families. + */ +async function populatedAddedEdgeKindProbes( + target: SchemaCommitPreflightBackend, + graphId: string, + probes: AddedEdgeKindProbes, +): Promise { + const populatedProbes = await populatedSchemaKinds( + target, + graphId, + probes.endpointAllowances.map( + ({ edgeKind }) => + ({ entity: "edge", kind: edgeKind, rows: "nonDeleted" }) as const, + ), + ); + const populated = new Set(populatedProbes.map((probe) => probe.kind)); + return { + endpointAllowances: probes.endpointAllowances.filter((allowance) => + populated.has(allowance.edgeKind), + ), + acyclicEdgeKinds: probes.acyclicEdgeKinds.filter((edgeKind) => + populated.has(edgeKind), + ), + }; +} + +/** The edge kinds a schema snapshot declares `acyclic: true`. */ +function standaloneAcyclicEdgeKinds( + snapshot: OntologySnapshot, +): readonly string[] { + return Object.entries(snapshot.edges) + .filter(([, edge]) => edge.acyclic === true) + .map(([edgeKind]) => edgeKind); +} + +/** + * Whether the proposed composition relation holds a row the baseline's did + * not: a member edge kind it lacked, a member read in the other orientation, + * or a member whose ended rows now count (`"openRows"` → `"allRows"` — a + * `oneActive` realizing edge redeclared `one`, or declared `acyclic: true`). + * Such a commit can close a cycle out of rows that were legal under the + * baseline, so the proposed relation owes the probe. + */ +function compositionRelationWidened( + baseline: AcyclicEdgeRelation | undefined, + proposed: AcyclicEdgeRelation, +): boolean { + const baselineMembers = new Map( + (baseline?.members ?? []).map((member) => [member.edgeKind, member]), + ); + return proposed.members.some((member) => { + const prior = baselineMembers.get(member.edgeKind); + return ( + prior?.reversed !== member.reversed || + rowMembershipWidens(prior.membership, member.membership) + ); + }); +} + +/** + * The composition occupancy declarations this commit must audit, built by + * the one owner of the per-edge-kind declaration list + * ({@link compositionEdgeCardinalityDeclarations}) over the PROPOSED schema. + * + * Delta-scoped: a declaration is audited only when its edge kind realizes a + * `partOf`/`hasPart` pair THIS commit adds (`addedPairEdgeKinds`, the + * `composition` probe's edge kinds) or when its row membership widens from + * the baseline's ({@link rowMembershipWidens}: a `oneActive` realizing edge + * redeclared `one`, whose ended rows start holding the part). A pre-existing, + * unchanged pair's data is never re-walked by an unrelated commit, the same + * discipline `edgeCardinalities`/`disjointKindPairs` honor. The delta only + * selects which edge kinds' rows are the OUTER (reported) rows: each + * declaration's `scope.holders` is still the whole graph's composition + * holder list, so a changed edge kind's row is found contending against an + * unchanged edge kind's live row — two wholes held through two realizing + * edges. + */ +function compositionDeclarationsToAudit( + baselineRegistry: KindRegistry, + proposedRegistry: KindRegistry, + addedPairEdgeKinds: readonly string[], +): readonly EdgeCardinalityDeclaration[] { + const baselineMembership = new Map( + compositionEdgeCardinalityDeclarations(baselineRegistry).map( + (declaration) => [declaration.edgeKind, axisRowMembership(declaration)], + ), + ); + return compositionEdgeCardinalityDeclarations(proposedRegistry).filter( + (declaration) => { + if (addedPairEdgeKinds.includes(declaration.edgeKind)) return true; + const prior = baselineMembership.get(declaration.edgeKind); + return ( + prior !== undefined && + rowMembershipWidens(prior, axisRowMembership(declaration)) + ); + }, + ); +} + +/** + * The acyclic relations this commit must probe, built by the one owner of the + * relation list ({@link declaredAcyclicEdgeRelations}) over the PROPOSED + * schema — the relations the write path enforces once this version is live: + * + * - each edge kind this commit newly declares `acyclic: true` on, as its own + * standalone relation; + * - the composition relation, whole, when the commit adds a + * `partOf`/`hasPart` pair or widens the relation's population + * ({@link compositionRelationWidened}). Whole rather than delta-scoped: a + * cycle can span a pre-existing pair and the changed one. + */ +function acyclicRelationsToProbe( + params: SchemaTighteningPreflightParams, + baselineRegistry: KindRegistry, + proposedRegistry: KindRegistry, + newlyAcyclicEdgeKinds: readonly string[], + addsCompositionPair: boolean, +): readonly AcyclicEdgeRelation[] { + const proposed = declaredAcyclicEdgeRelations( + proposedRegistry, + standaloneAcyclicEdgeKinds(params.after), + ); + const proposedComposition = proposed.find( + (relation) => relation.name === COMPOSITION_RELATION_NAME, + ); + const probesComposition = + proposedComposition !== undefined && + (addsCompositionPair || + compositionRelationWidened( + declaredAcyclicEdgeRelations( + baselineRegistry, + standaloneAcyclicEdgeKinds(params.before), + ).find((relation) => relation.name === COMPOSITION_RELATION_NAME), + proposedComposition, + )); + return proposed.filter((relation) => + relation.name === COMPOSITION_RELATION_NAME ? + probesComposition + : newlyAcyclicEdgeKinds.includes(relation.name), + ); +} + +function buildOntologyTighteningViolatedError( + params: SchemaTighteningPreflightParams, + probedChanges: readonly OntologyChange[], + violations: readonly ConstraintFenceViolation[], +): MigrationError { + const shown = previewViolations(violations); + return new MigrationError( + `Ontology tightening refused: ${String(violations.length)} existing row(s) violate the proposed ontology. ` + + `${shown}. Run store.verifyConstraintFences() to list them, resolve the rows, then retry.`, + { + graphId: params.graphId, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + reason: "ontology-tightening-violated", + changes: probedChanges, + violations, + }, + ); +} + +function buildEdgeCardinalityTighteningViolatedError( + params: SchemaTighteningPreflightParams, + axes: readonly EdgeCardinalityDeclaration[], + violations: readonly ConstraintFenceViolation[], +): MigrationError { + const shown = previewViolations(violations); + return new MigrationError( + `Edge cardinality tightening refused: ${String(violations.length)} existing row(s) violate the proposed cardinality. ` + + `${shown}. Run store.verifyConstraintFences() to list them, resolve the rows, then retry.`, + { + graphId: params.graphId, + fromVersion: params.fromVersion, + toVersion: params.toVersion, + reason: "edge-cardinality-tightening-violated", + axes, + violations, + }, + ); +} + +/** + * The data preflight a schema tightening owes, alongside the capability + * error a caller refuses with when its backend cannot commit that preflight + * atomically. + */ +export type SchemaTighteningPreflight = Readonly<{ + run: (target: SchemaCommitPreflightBackend) => Promise; + /** + * Only the ontology changes that owe a data check. A `safe` or `breaking` + * change in the same diff (`relatedTo` added alongside a `disjointWith`, + * say) carries no `probes` and is not implicated by a refusal — see + * `MigrationErrorDetails`'s `"ontology-tightening-violated"` docblock. + */ + probedChanges: readonly OntologyChange[]; + /** The edge-cardinality axes this transition newly constrains. */ + newlyConstrainedAxes: readonly EdgeCardinalityDeclaration[]; + /** + * The uniqueness constraints this transition can only publish while the + * kind declaring them holds no rows: `run` refuses the commit when one + * does, before any other check. + */ + uniquenessClaimGates: readonly UniquenessClaimGate[]; + /** + * {@link EDGE_CARDINALITY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR} + * when this commit newly constrains an edge cardinality — on either axis, + * including a brand-new kind — else {@link + * UNIQUENESS_CLAIM_GATE_ATOMIC_PREFLIGHT_CAPABILITY_ERROR} when it owes a + * uniqueness claim gate and no ontology probe, and {@link + * ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR} otherwise. Decided + * HERE, from the same `newlyConstrainedAxes` fold `run` above closes over, + * so a caller names the axis this exact preflight is about instead of + * re-deriving which half applies (and risking a refusal that blames + * "ontology" for a commit that touched no disjointness, uniqueness, or + * endpoint-assignability axiom at all). + */ + capabilityError: AtomicPreflightCapabilityError; +}>; + +/** + * {@link prepareSchemaTighteningPreflight} for a store verb that commits + * without `ensureSchema`'s severity gate (`store.evolve()`, + * `store.planEvolution()`, `store.removeKinds()`): it first refuses a commit + * that would drop a stored custom-meta-edge relation, which only an explicit + * `migrateSchema()` may do, and classifies the pair once for both decisions. + * + * @throws MigrationError (`reason: "custom-relation-removal"`) from + * `assertNoRemovedCustomRelations`. + */ +export function prepareStoreVerbSchemaTighteningPreflight( + params: SchemaTighteningPreflightParams & Readonly<{ verb: string }>, +): SchemaTighteningPreflight | undefined { + const { verb, ...preflightParams } = params; + const changes = + preflightParams.changes ?? + classifyOntologyChanges(preflightParams.before, preflightParams.after); + assertNoRemovedCustomRelations({ + verb, + graphId: preflightParams.graphId, + fromVersion: preflightParams.fromVersion, + toVersion: preflightParams.toVersion, + changes, + }); + return prepareSchemaTighteningPreflight({ ...preflightParams, changes }); +} + +/** + * The data preflight a schema tightening owes, or `undefined` when the + * proposal tightens nothing on the ontology or the edge-cardinality axis and + * strands no uniqueness claim. Its `run` step executes INSIDE the + * schema-commit transaction. + * + * The uniqueness claim gate runs first: it is one emptiness probe per gated + * kind, and a commit it refuses is not legal whatever the audits below would + * find. + * + * Takes NO advisory lock. Under the PREVIOUS schema the tightening's kinds + * are not yet disjoint (or their uniqueness components have not yet merged, + * or the edge kind's endpoints have not yet shrunk, or the edge kind's + * cardinality was not yet this constrained), so a writer creating the very + * row that will violate the new axiom owes no claim and takes no lock — the + * lock cannot fence what it cannot see. The residual window this leaves is + * the one the existing kind-emptiness fence already carries: a writer that + * commits under the previous schema version between this probe and the + * version CAS is invisible to it. + * `store.verifyConstraintFences()` remains the post-hoc detector for exactly + * that window. + * + * When BOTH halves have violations, the edge-cardinality refusal wins: it is + * the more specific diagnosis, and the two reasons are mutually exclusive + * gates on the same commit (fixing one leaves the other still refusing on + * retry). The same preference governs `capabilityError`. + * + * @throws ConfigurationError if either `params.before` or `params.after` + * cannot be interpreted as a coherent ontology and `params.changes` was + * not supplied (propagates from `classifyOntologyChanges`). + */ +export function prepareSchemaTighteningPreflight( + params: SchemaTighteningPreflightParams, +): SchemaTighteningPreflight | undefined { + const changes = + params.changes ?? classifyOntologyChanges(params.before, params.after); + const probes = ontologyTighteningProbes(changes); + const newlyConstrainedAxes = newlyConstrainedEdgeAxes( + params.before, + params.after, + ); + const uniqueConstraints = declaredKindUniqueConstraints( + params.before, + params.after, + ); + const mayGateUniqueness = mayGateUniquenessClaims( + uniqueConstraints, + changes.length > 0, + ); + const kindDelta = snapshotKindDelta(params.before, params.after); + const addsEdgeKind = kindDelta.addedEdgeKinds.length > 0; + if ( + probes.length === 0 && + newlyConstrainedAxes.length === 0 && + !mayGateUniqueness && + !addsEdgeKind + ) { + return undefined; + } + const probedChanges = changes.filter( + (change) => (change.probes ?? []).length > 0, + ); + + // Built once, outside the returned closure: every commit path already ran + // `buildKindRegistry` on the target graph before reaching the preflight, so + // this cannot throw here for a reason the commit has not already surfaced. + const proposedRegistry = buildRegistryFromSerializedSchema(params.after); + + // BEFORE is read only for where its claims sit and which rows its + // populations count. + const baselineRegistry = buildSchemaBaselineRegistry(params.before); + const gates = + mayGateUniqueness ? + uniquenessClaimGates( + uniqueConstraints, + baselineRegistry, + proposedRegistry, + ) + : []; + if ( + probes.length === 0 && + newlyConstrainedAxes.length === 0 && + gates.length === 0 && + !addsEdgeKind + ) { + return undefined; + } + const addedEdgeKinds = addedEdgeKindProbes( + params.after, + kindDelta, + proposedRegistry, + ); + + const grouped = { + disjointness: probeOfKind(probes, "nodeDisjointness"), + uniqueness: probeOfKind(probes, "nodeUniqueness"), + endpoints: probeOfKind(probes, "edgeEndpointAssignability"), + acyclicity: probeOfKind(probes, "edgeAcyclicity"), + composition: probeOfKind(probes, "composition"), + compositionExistence: probeOfKind(probes, "compositionExistence"), + } as const; + const uniquenessGroups = (grouped.uniqueness?.groups ?? []).map((group) => + uniquenessAxisGroupFor(group.constraintName, group.coveredKinds), + ); + + const compositionEdgeCardinalities = compositionDeclarationsToAudit( + baselineRegistry, + proposedRegistry, + grouped.composition?.edgeKinds ?? [], + ); + + const run = async (target: SchemaCommitPreflightBackend): Promise => { + await assertUniquenessClaimGatesOpen(target, params, gates); + const populatedAddedEdgeKinds = await populatedAddedEdgeKindProbes( + target, + params.graphId, + addedEdgeKinds, + ); + const endpointAllowances = [ + ...(grouped.endpoints?.allowances ?? []), + ...populatedAddedEdgeKinds.endpointAllowances, + ]; + const acyclicEdgeKinds = [ + ...(grouped.acyclicity?.edgeKinds ?? []), + ...populatedAddedEdgeKinds.acyclicEdgeKinds, + ]; + const probedAcyclicRelations = acyclicRelationsToProbe( + params, + baselineRegistry, + proposedRegistry, + acyclicEdgeKinds, + grouped.composition !== undefined, + ); + + const claimBackedViolations = await auditConstraintFences(target, { + declarations: { + graphId: params.graphId, + // Delta-scoped, not graph-wide: the audit reads only the pairs, + // constraint names, and edge kinds THIS tightening affects, so a + // pre-existing violation elsewhere in the graph can never make this + // commit refuse. + disjointKindPairs: grouped.disjointness?.pairs ?? [], + uniqueConstraintNames: [ + ...new Set(uniquenessGroups.map((group) => group.constraintName)), + ], + edgeCardinalities: [ + ...newlyConstrainedAxes, + ...compositionEdgeCardinalities, + ], + edgeEndpointAllowances: endpointAllowances, + }, + uniquenessGroups, + registry: proposedRegistry, + }); + + // `edgeAcyclicity` does NOT go through `auditConstraintFences` / + // `readConstraintFenceViolations` (the non-recursive row-shape port every + // other family above reads through): folding the recursive predicate + // behind it would create a second implementation of "is there a cycle". + // It runs `readEdgeAcyclicityViolations` directly, over exactly the + // relations THIS commit can newly violate (`acyclicRelationsToProbe`), + // which include an added `acyclic: true` kind that already holds rows. + const acyclicityViolations = + probedAcyclicRelations.length === 0 ? + [] + : await readEdgeAcyclicityViolations( + { + graphId: params.graphId, + registry: proposedRegistry, + schema: createSqlSchema(target.tableNames), + dialect: getDialect(target.dialect), + target, + operation: "schema-commit:acyclic-tightening", + }, + probedAcyclicRelations, + ); + + // Third composition check: for every required-existence part + // kind this commit's added pair(s) name, does a LIVE part already have + // no live whole? Delta-scoped to WHICH part kinds are checked (only + // those a `compositionRequiredWhole` probe names); the check itself + // still reads the PROPOSED registry's full composition relation for + // each — an already-attached part via a pre-existing edge kind under + // the same part kind is found, not missed, matching + // `readCompositionUnattachedParts`'s own docblock. + const compositionExistenceViolations = + grouped.compositionExistence === undefined ? + [] + : await readCompositionUnattachedPartsForEdgeKinds( + proposedRegistry, + target, + params.graphId, + grouped.compositionExistence.edgeKinds, + ); + + // Fourth composition check: does a row of an edge kind that realizes a + // pair THIS commit adds already start or end later than now? Every + // composition membership decision reads a row's window as settled, which + // holds only while no composition window lies in the future — a row + // written before its edge kind realized a pair was never held to that. + const futureWindowViolations = await compositionFutureWindowViolations( + target, + params.graphId, + grouped.composition?.edgeKinds ?? [], + ); + + const violations = [ + ...claimBackedViolations, + ...acyclicityViolations, + ...compositionExistenceViolations, + ...futureWindowViolations, + ]; + if (violations.length === 0) return; + + const cardinalityViolations = violations.filter( + (violation) => violation.family === "edgeCardinality", + ); + if (cardinalityViolations.length > 0) { + throw buildEdgeCardinalityTighteningViolatedError( + params, + newlyConstrainedAxes, + cardinalityViolations, + ); + } + throw buildOntologyTighteningViolatedError( + params, + probedChanges, + violations, + ); + }; + + return { + run, + probedChanges, + newlyConstrainedAxes, + uniquenessClaimGates: gates, + capabilityError: + newlyConstrainedAxes.length > 0 ? + EDGE_CARDINALITY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR + : gates.length > 0 && probes.length === 0 && !addsEdgeKind ? + UNIQUENESS_CLAIM_GATE_ATOMIC_PREFLIGHT_CAPABILITY_ERROR + : ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR, + }; +} diff --git a/packages/typegraph/src/schema/types.ts b/packages/typegraph/src/schema/types.ts index a18705e6a..c8b4b7fc3 100644 --- a/packages/typegraph/src/schema/types.ts +++ b/packages/typegraph/src/schema/types.ts @@ -10,6 +10,11 @@ import { z } from "zod"; import { type GraphIdentityConfig } from "../core/define-graph"; +import { + CARDINALITY_VALUES, + ENDPOINT_EXISTENCE_VALUES, + TARGET_CARDINALITY_VALUES, +} from "../core/edge-integrity-options"; import { type Cardinality, type Collation, @@ -17,9 +22,15 @@ import { type EndpointExistence, type GraphAnnotations, type KindAnnotations, + type TargetCardinality, type TemporalMode, type UniquenessScope, } from "../core/types"; +import { + COLLATION_VALUES, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + UNIQUENESS_SCOPE_VALUES, +} from "../core/unique-constraint-options"; import { type GraphExtension } from "../graph-extension/extension-types"; import { validateEdgeIndexKeysPresence, @@ -30,8 +41,12 @@ import { NODE_INDEX_KEY_DIRECTIONS, NODE_SYSTEM_COLUMN_NAMES, } from "../indexes/types"; -import { type InferenceType } from "../ontology/types"; import { type JsonPointer } from "../query/json-pointer"; +import { + COMPOSITION_EXISTENCE_VALUES, + COMPOSITION_PART_SIDE_VALUES, + type CompositionRealization, +} from "../registry/composition-relation"; // ============================================================ // Enum Zod Schemas @@ -45,9 +60,14 @@ import { type JsonPointer } from "../query/json-pointer"; const deleteBehaviorZod = z.enum(["restrict", "cascade", "disconnect"]); -const cardinalityZod = z.enum(["many", "one", "unique", "oneActive"]); +// The stored-schema parser reads the same value lists `defineGraph` and the +// graph-extension validator refuse against, so a value one layer accepts is +// never one another rejects. +const cardinalityZod = z.enum(CARDINALITY_VALUES); + +const targetCardinalityZod = z.enum(TARGET_CARDINALITY_VALUES); -const endpointExistenceZod = z.enum(["notDeleted", "currentlyValid", "ever"]); +const endpointExistenceZod = z.enum(ENDPOINT_EXISTENCE_VALUES); const temporalModeZod = z.enum([ "current", @@ -56,19 +76,9 @@ const temporalModeZod = z.enum([ "includeTombstones", ]); -const uniquenessScopeZod = z.enum(["kind", "kindWithSubClasses"]); +const uniquenessScopeZod = z.enum(UNIQUENESS_SCOPE_VALUES); -const collationZod = z.enum(["binary", "caseInsensitive"]); - -const inferenceTypeZod = z.enum([ - "subsumption", - "hierarchy", - "substitution", - "constraint", - "composition", - "association", - "none", -]); +const collationZod = z.enum(COLLATION_VALUES); const indexScopeZod = z.enum(["graphAndKind", "graph", "none"]); @@ -338,11 +348,18 @@ const runtimeEdgeDocumentZod = z }) .loose(); -const runtimeOntologyRelationZod = z +const compositionPartSideZod = z.enum(COMPOSITION_PART_SIDE_VALUES); +const compositionExistenceZod = z.enum(COMPOSITION_EXISTENCE_VALUES); + +/** One ontology relation, as both the stored ontology and a graph extension persist it. */ +const ontologyRelationZod = z .object({ metaEdge: z.string(), from: z.string(), to: z.string(), + via: z.string().optional(), + partSide: compositionPartSideZod.optional(), + existence: compositionExistenceZod.optional(), }) .loose(); @@ -374,7 +391,7 @@ const graphExtensionZod = z annotations: annotationsZod.optional(), nodes: z.record(z.string(), runtimeNodeDocumentZod).optional(), edges: z.record(z.string(), runtimeEdgeDocumentZod).optional(), - ontology: z.array(runtimeOntologyRelationZod).optional(), + ontology: z.array(ontologyRelationZod).optional(), indexes: z.array(runtimeIndexDocumentZod).optional(), }) .loose() as unknown as z.ZodType; @@ -393,8 +410,13 @@ export type JsonSchema = Readonly<{ type?: string | readonly string[]; properties?: Record; required?: readonly string[]; - items?: JsonSchema; + /** `false` closes a tuple: no element past `prefixItems`. */ + items?: JsonSchema | boolean; + prefixItems?: readonly JsonSchema[]; + minItems?: number; + maxItems?: number; additionalProperties?: boolean | JsonSchema; + propertyNames?: JsonSchema; enum?: readonly unknown[]; const?: unknown; anyOf?: readonly JsonSchema[]; @@ -405,10 +427,19 @@ export type JsonSchema = Readonly<{ default?: unknown; minimum?: number; maximum?: number; + multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; format?: string; + // `exclusiveMinimum`, `exclusiveMaximum`, and `contentEncoding` are + // deliberately NOT named members: every reader of them + // (`structural-subtype.ts`'s `readNumber` and `unmodeledConstruct`) reaches + // them through the index signature below with a dynamic key, so naming them + // here would only widen this public type's surface with no code that needs + // the narrower access `noPropertyAccessFromIndexSignature` requires for the + // members that ARE named (`prefixItems`, `minItems`, `maxItems`, + // `propertyNames`, `multipleOf`). [key: string]: unknown; }>; @@ -418,14 +449,15 @@ export type JsonSchema = Readonly<{ /** * Serialized representation of a meta-edge. + * + * `transitive`/`symmetric`/`reflexive`/`inverse`/`inference` were removed + * along with the sameAs/differentFrom factories — see `MetaEdgeProperties`'s docblock. The parsing zod schema + * (`serializedSchemaZod`, below) stays `.loose()` on this record, so an + * older document that still carries those keys still parses; they are + * simply never read. */ export type SerializedMetaEdge = Readonly<{ name: string; - transitive: boolean; - symmetric: boolean; - reflexive: boolean; - inverse: string | undefined; - inference: InferenceType; description: string | undefined; }>; @@ -440,7 +472,8 @@ export type SerializedOntologyRelation = Readonly<{ metaEdge: string; // Meta-edge name from: string; // Node kind name or external IRI to: string; // Node kind name or external IRI -}>; +}> & + CompositionRealization; // ============================================================ // Serialized Closures @@ -472,6 +505,16 @@ export type SerializedClosures = Readonly<{ * Complete serialized ontology section. */ export type SerializedOntology = Readonly<{ + /** + * Derived state, computed 1:1 from `relations` by the serializer: one + * entry per meta-edge name any relation below currently uses. Carried for + * introspection (readers that want to list the meta-edges a schema + * touches without scanning every relation) — never an independent + * classification input. `classifyOntologyChanges` + * (`src/schema/ontology-change.ts`) diffs `relations` only; see that + * module's docblock for why a meta-edge name appearing or disappearing + * here is always a consequence of a relation change, not a distinct event. + */ metaEdges: Record; relations: readonly SerializedOntologyRelation[]; closures: SerializedClosures; @@ -522,11 +565,23 @@ export type SerializedEdgeDef = Readonly<{ targetKindsBySource?: Readonly>; properties: JsonSchema; cardinality: Cardinality; + // Optional, unlike `cardinality`: omitted from the serialized document + // (see `serializeEdgeDef`) when the registration leaves it undeclared or + // equal to the default `"many"`, so a graph that never uses this option + // keeps its pre-existing document shape and schema hash. + targetCardinality?: TargetCardinality; endpointExistence: EndpointExistence; matchIdentity?: Readonly<{ name: string; fields: readonly string[]; }>; + /** + * Present, and `true`, only when the edge kind declares `acyclic: true`. + * Absent (never `false`) so a graph with no acyclic edge kind serializes + * byte-identically to a older document and `computeSchemaHash` does not + * move for it — see `serializeEdgeDef`. + */ + acyclic?: boolean; description: string | undefined; annotations?: KindAnnotations; }>; @@ -590,8 +645,12 @@ export const serializedSchemaZod = z name: z.string(), fields: z.array(z.string()), where: z.string().optional(), - scope: uniquenessScopeZod.default("kind"), - collation: collationZod.default("binary"), + scope: uniquenessScopeZod.default( + NODE_UNIQUE_CONSTRAINT_DEFAULTS.scope, + ), + collation: collationZod.default( + NODE_UNIQUE_CONSTRAINT_DEFAULTS.collation, + ), }) .loose(), ), @@ -615,6 +674,12 @@ export const serializedSchemaZod = z .optional(), properties: z.record(z.string(), z.unknown()), cardinality: cardinalityZod, + // `.default("many")` is what makes a document stored before this + // option existed load as unconstrained on the target side: the + // record is `.loose()`, so an absent key parses to nothing, and + // without the default a persisted declaration on a NEWER document + // read by this schema would be silently dropped instead of kept. + targetCardinality: targetCardinalityZod.default("many"), endpointExistence: endpointExistenceZod, matchIdentity: z .object({ @@ -623,6 +688,7 @@ export const serializedSchemaZod = z }) .loose() .optional(), + acyclic: z.boolean().optional(), description: z.string().optional(), annotations: annotationsZod.optional(), }) @@ -637,27 +703,18 @@ export const serializedSchemaZod = z z .object({ name: z.string(), - transitive: z.boolean(), - symmetric: z.boolean(), - reflexive: z.boolean(), - inference: inferenceTypeZod, - inverse: z.string().optional(), description: z.string().optional(), }) + // `.loose()`: an older document may still carry `transitive`/ + // `symmetric`/`reflexive`/`inverse`/`inference` (removed + // with the sameAs/differentFrom factories) — they parse through unread rather than + // rejecting the document. .loose(), ) .superRefine( checkRecordKeyMatchesField("name", "ontology.metaEdges"), ), - relations: z.array( - z - .object({ - metaEdge: z.string(), - from: z.string(), - to: z.string(), - }) - .loose(), - ), + relations: z.array(ontologyRelationZod), closures: z .object({ subClassAncestors: z.record(z.string(), z.array(z.string())), @@ -790,6 +847,13 @@ export type SerializedSchema = Readonly<{ */ export type SchemaHash = string; +/** The graph and the versions a schema commit moves its active pointer between. */ +export type SchemaCommitTransition = Readonly<{ + graphId: string; + fromVersion: number; + toVersion: number; +}>; + /** The version and content hash of one stored schema snapshot. */ export type SchemaIdentity = Readonly<{ version: number; diff --git a/packages/typegraph/src/schema/unique-constraint-change.ts b/packages/typegraph/src/schema/unique-constraint-change.ts new file mode 100644 index 000000000..704690221 --- /dev/null +++ b/packages/typegraph/src/schema/unique-constraint-change.ts @@ -0,0 +1,67 @@ +/** + * Whether a unique-constraint declaration changes what existing rows owe. + * + * A uniqueness constraint is enforced by claim rows written alongside each + * node write. A constraint that is new, or whose shape differs from the one + * its name carried before, is a constraint no existing row has a claim for — + * so it can only be declared on a kind that holds no rows. Both the + * graph-extension classifier (`Store.evolve` / `planEvolution`) and the + * schema-commit preflight ask this question, about two different serialized + * shapes of the same declaration; this is the one place it is answered. + */ +import { type Collation, type UniquenessScope } from "../core/types"; +import { + type UniqueConstraintDefaults, + withUniqueConstraintDefaults, +} from "../core/unique-constraint-options"; +import { canonicalEqual } from "./canonical"; + +/** How a declaration differs from the one its name carried before. */ +export type UniqueConstraintEnforcementChange = "added" | "reshaped"; + +type UniqueConstraintDeclaration = Readonly<{ + name: string; + scope?: UniquenessScope | undefined; + collation?: Collation | undefined; +}>; + +function enforcedShape( + constraint: UniqueConstraintDeclaration, + defaults: UniqueConstraintDefaults, +): Readonly> { + const { name: _name, ...shape } = withUniqueConstraintDefaults( + constraint, + defaults, + ); + return shape; +} + +/** + * `"added"` when `existing` declares no constraint named like `next`, + * `"reshaped"` when it declares one whose remaining members differ, and + * `undefined` when the declaration is unchanged. + * + * Shapes are compared with `canonicalEqual` in their enforced form — an + * omitted `scope` or `collation` read as `defaults`, the ones of the document + * shape both sides come from — so key order, an absent member versus one + * explicitly `undefined`, and an implied default versus the same value + * written out never read as a reshape. + */ +export function uniqueConstraintEnforcementChange< + C extends UniqueConstraintDeclaration, +>( + existing: readonly C[], + next: C, + defaults: UniqueConstraintDefaults, +): UniqueConstraintEnforcementChange | undefined { + const previous = existing.find((constraint) => constraint.name === next.name); + if (previous === undefined) return "added"; + return ( + canonicalEqual( + enforcedShape(previous, defaults), + enforcedShape(next, defaults), + ) + ) ? + undefined + : "reshaped"; +} diff --git a/packages/typegraph/src/schema/uniqueness-claim-gate.ts b/packages/typegraph/src/schema/uniqueness-claim-gate.ts new file mode 100644 index 000000000..c20aab07b --- /dev/null +++ b/packages/typegraph/src/schema/uniqueness-claim-gate.ts @@ -0,0 +1,221 @@ +/** + * The schema transitions that would leave a uniqueness constraint unenforced + * over rows that already exist, and the refusal a commit owes for them. + * + * A uniqueness constraint is enforced by claim rows: each node write reserves + * `(axis, constraint, key)` and probes the axes its scope covers. A schema + * commit rewrites no claim rows, so two kinds of transition strand existing + * rows outside the fence: + * + * - A constraint that is added or reshaped has no claim row for any existing + * node, so pre-existing duplicates stay and a new node duplicating an old + * value is accepted. This holds for a kind the commit adds as well: rows a + * dropped kind left behind (`discardDroppedKindRows`) become live again under + * a re-added kind of the same name, with no claim. + * - A `kindWithSubClasses` constraint writes at the code-point minimum of the + * kind's subclass component and probes that component. Claims of one kind + * can sit at any axis of any component it belonged to while populated: a + * merge moves where NEW claims are written without moving old ones. So a + * component that loses a member sorting before the kind — a removed + * `subClassOf` / `equivalentTo`, a removed kind — may leave claims at an axis + * the kind no longer probes, whichever member was the minimum when the + * previous schema was written. A lost member sorting after the kind cannot + * hold its claims: the kind is always in its own component, so every axis it + * was ever written at is at or before the kind. + * + * Neither can be repaired inside a schema commit, so both are refused while + * the kind holds rows. A kind with no rows has no claims to strand and is + * unaffected. + */ +import { populatedSchemaKinds } from "../backend/schema-kind-emptiness"; +import { type SchemaCommitPreflightBackend } from "../backend/types"; +import { + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + uniquenessScopeSpansSubclasses, +} from "../core/unique-constraint-options"; +import { MigrationError } from "../errors"; +import { type KindRegistry } from "../registry/kind-registry"; +import { + compareUniquenessAxisKinds, + uniquenessClaimAxis, +} from "../store/claims/axis"; +import { compareStrings } from "../utils/compare"; +import { hasOwnKey } from "../utils/object"; +import { type OntologySnapshot } from "./ontology-change"; +import { type SchemaCommitTransition } from "./types"; +import { type SerializedUniqueConstraint } from "./types"; +import { + type UniqueConstraintEnforcementChange, + uniqueConstraintEnforcementChange, +} from "./unique-constraint-change"; + +/** + * One uniqueness constraint a schema transition can only publish while the + * kind declaring it holds no rows. + * + * `axis` is the claim axis the proposed schema writes the constraint at. + * `lostKinds`, on a moved axis, are the members of the kind's subclass + * component under the previous schema that sort before the kind and are no + * longer in it: claims of existing rows may sit at any of their axes, which the + * kind no longer probes. + */ +export type UniquenessClaimGate = + | Readonly<{ + cause: "constraint-added" | "constraint-reshaped"; + kind: string; + constraintName: string; + axis: string; + }> + | Readonly<{ + cause: "claim-axis-moved"; + kind: string; + constraintName: string; + axis: string; + lostKinds: readonly string[]; + }>; + +/** A unique constraint a node kind declares in `after`. */ +type DeclaredKindConstraint = Readonly<{ + kind: string; + constraint: SerializedUniqueConstraint; + change: UniqueConstraintEnforcementChange | undefined; +}>; + +/** + * Every unique constraint `after` declares on a node kind, with how its + * declaration changed. A kind `before` lacks has no declaration to compare, so + * each of its constraints reads as added: the kind may still own rows a + * previous removal left in the table. + */ +export function declaredKindUniqueConstraints( + before: OntologySnapshot, + after: OntologySnapshot, +): readonly DeclaredKindConstraint[] { + return Object.entries(after.nodes) + .toSorted(([left], [right]) => compareStrings(left, right)) + .flatMap(([kind, afterDef]) => { + const existing = + hasOwnKey(before.nodes, kind) ? + (before.nodes[kind]?.uniqueConstraints ?? []) + : []; + return afterDef.uniqueConstraints.map((constraint) => ({ + kind, + constraint, + change: uniqueConstraintEnforcementChange( + existing, + constraint, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + ), + })); + }); +} + +/** + * Whether a transition can gate anything at all, decided without building a + * registry: a declaration changed, or the ontology changed under an + * unchanged `kindWithSubClasses` constraint (the only scope whose axis the + * ontology decides). + */ +export function mayGateUniquenessClaims( + constraints: readonly DeclaredKindConstraint[], + ontologyChanged: boolean, +): boolean { + return constraints.some( + ({ change, constraint }) => + change !== undefined || + (ontologyChanged && uniquenessScopeSpansSubclasses(constraint.scope)), + ); +} + +/** + * The gates a transition owes, in `(kind, constraint)` order. + * + * An unchanged constraint is gated exactly when the proposed component lost a + * member that sorts before the kind. A claim sits at the code-point minimum of + * the component it was written under, which contains the kind itself, so it + * sits at the kind or at a member before it. A component that only GAINS + * members, or loses only later ones, keeps every such axis inside it: a merge + * moves where new claims are written without hiding the old ones. Once an + * earlier member is lost, the axis of any earlier claim may be the lost one, + * and the previous schema's own axis does not tell which — an earlier merge + * leaves claims at more than one. + */ +export function uniquenessClaimGates( + constraints: readonly DeclaredKindConstraint[], + beforeRegistry: KindRegistry, + afterRegistry: KindRegistry, +): readonly UniquenessClaimGate[] { + return constraints.flatMap( + ({ kind, constraint, change }): readonly UniquenessClaimGate[] => { + const axis = uniquenessClaimAxis(kind, constraint.scope, afterRegistry); + const named = { kind, constraintName: constraint.name, axis }; + if (change !== undefined) { + return [ + { + ...named, + cause: + change === "added" ? "constraint-added" : "constraint-reshaped", + }, + ]; + } + if (!uniquenessScopeSpansSubclasses(constraint.scope)) return []; + const proposedComponent = afterRegistry.getSubClassComponent(kind); + const lostKinds = beforeRegistry + .getSubClassComponent(kind) + .filter( + (member) => + compareUniquenessAxisKinds(member, kind) < 0 && + !proposedComponent.includes(member), + ); + if (lostKinds.length === 0) return []; + return [{ ...named, cause: "claim-axis-moved", lostKinds }]; + }, + ); +} + +function describeGate(gate: UniquenessClaimGate): string { + const subject = `"${gate.constraintName}" on "${gate.kind}"`; + if (gate.cause === "claim-axis-moved") { + return `${subject} (no longer shares a subclass component with ${gate.lostKinds.map((lost) => `"${lost}"`).join(", ")}; claim axis "${gate.axis}")`; + } + return `${subject} (${gate.cause === "constraint-added" ? "added" : "reshaped"}, claim axis "${gate.axis}")`; +} + +/** + * Refuses when any gated kind holds rows. Asks the same question over the + * same row population `Store.evolve` requires to be empty before it accepts a + * unique constraint on a graph-extension kind, through the same probe. + * + * @throws MigrationError (`reason: "unique-constraint-requires-empty-kind"`) + * naming every gate whose kind is populated. + */ +export async function assertUniquenessClaimGatesOpen( + target: SchemaCommitPreflightBackend, + transition: SchemaCommitTransition, + gates: readonly UniquenessClaimGate[], +): Promise { + const populatedProbes = await populatedSchemaKinds( + target, + transition.graphId, + [...new Set(gates.map((gate) => gate.kind))].map( + (kind) => ({ entity: "node", kind, rows: "nonDeleted" }) as const, + ), + ); + const populatedKinds = new Set(populatedProbes.map((probe) => probe.kind)); + const closed = gates.filter((gate) => populatedKinds.has(gate.kind)); + if (closed.length === 0) return; + throw new MigrationError( + `Schema change refused: it would leave a uniqueness constraint unenforced over existing rows of ${[...populatedKinds].map((kind) => `"${kind}"`).join(", ")}. ` + + `${closed.map((gate) => describeGate(gate)).join("; ")}. ` + + "A schema commit does not validate existing rows against a unique constraint or rebuild its claims. " + + "Declare the constraint, and the subclass hierarchy it spans, before the kind holds rows; " + + "otherwise delete the kind's rows (for a re-added kind, run store.materializeRemovals() to reclaim rows its removal left), commit the schema, then write them again.", + { + graphId: transition.graphId, + fromVersion: transition.fromVersion, + toVersion: transition.toVersion, + reason: "unique-constraint-requires-empty-kind", + constraints: closed, + }, + ); +} diff --git a/packages/typegraph/src/store/acyclicity.ts b/packages/typegraph/src/store/acyclicity.ts new file mode 100644 index 000000000..e0dee082d --- /dev/null +++ b/packages/typegraph/src/store/acyclicity.ts @@ -0,0 +1,1068 @@ +/** + * Acyclicity of an edge relation: `acyclic: true` on an edge registration, + * and the composition relation. + * + * THE acyclicity predicate: "does `from` lie in the reflexive-transitive + * closure of `to`, over one acyclic relation's live edges". Every write path + * that can put an edge into a declared-acyclic relation calls + * {@link assertEdgeRelationsAcyclic} (rows already visible to the frame, or + * one row) or {@link assertUnwrittenEdgeRelationsAcyclic} (a batch probed + * before its insert) and no other function; the audit and the + * schema-tightening preflight call {@link readEdgeAcyclicityViolations}, + * which shares the same SQL builder (`buildEdgeAcyclicityProbe`, + * `src/store/recursive-cte.ts`) so a live-graph audit and a write-path probe + * can never disagree about what counts as a cycle. + * + * The check is exhaustive: a set-semantics (`UNION`, never `UNION ALL`) + * recursive reachability with no depth bound. `MAX_EXPLICIT_RECURSIVE_DEPTH` + * does not apply here — see `buildEdgeAcyclicityProbe`'s docblock. An engine + * that cuts the search short (statement timeout, resource exhaustion) is + * reported as indeterminate, never as "no cycle". + * + * Population: every non-deleted edge of a standalone `acyclic: true` + * relation counts, regardless of its validity window. A cycle is a property + * of the edge relation, not of an instant, so honoring `validTo` would let a + * future-dated edge close a cycle no write ever probed. The composition + * relation takes its population from composition instead — see + * {@link compositionAcyclicRelation}. Either way a member's rule is a + * {@link RowMembership}, and {@link acyclicRelationCountsRow} is the one + * reader that applies it to a row a write proposes, resurrects, reopens or + * retires; the walk over stored rows renders the same rule in SQL + * (`compileEdgeMembershipFilters`, `src/query/compiler/temporal.ts`). + */ +import { resolveRecursiveTraversal } from "../backend/capabilities/recursive-traversal"; +import { type GraphBackend } from "../backend/types"; +import { type GraphDef } from "../core/define-graph"; +import { ConfigurationError, EdgeAcyclicityError } from "../errors"; +import { EdgeAcyclicityIndeterminateError } from "../errors"; +import { type SqlSchema } from "../query/compiler/schema"; +import { getDialect } from "../query/dialect"; +import { type DialectAdapter } from "../query/dialect/types"; +import { countSqlParameters, type SqlFragment } from "../query/sql-fragment"; +import { asCompiledRowsSql } from "../query/sql-intent"; +import { compositionEdgeKindMembers } from "../registry/composition-relation"; +import { type KindRegistry } from "../registry/kind-registry"; +import { type RowMembership } from "../registry/row-membership"; +import { chunk } from "../utils/array"; +import { compareStrings } from "../utils/compare"; +import { requireDefined } from "../utils/presence"; +import { isStatementCutShortError } from "../utils/sql-errors"; +import { + COMPOSITION_RELATION_NAME, + displayAcyclicRelationName, +} from "./claims/axis"; +import { membershipCountsRow } from "./claims/edge-claims"; +import { + fenceLockOnlyDecision, + type GraphWriteLock, +} from "./recorded-capture/clock"; +import { + ACYCLICITY_SEED_ROW_PARAM_COUNT, + ACYCLICITY_TARGET_ROW_PARAM_COUNT, + type AcyclicityProbeSeed, + buildEdgeAcyclicityProbe, +} from "./recursive-cte"; + +/** + * One member of an acyclic relation: an edge kind, and the orientation its + * rows must be read in to walk the relation part->whole. A standalone + * `acyclic: true` registration is always `reversed: false` — that relation IS + * the edge kind, read in its stored direction. The composition relation mixes + * `reversed: false` (part -> whole realizing edges) and `reversed: true` + * (whole -> part realizing edges) and is checked as ONE directed relation; a + * plain edge-kind list cannot express that. + */ +export type AcyclicRelationMember = Readonly<{ + edgeKind: string; + reversed: boolean; + /** + * Which of this kind's non-deleted rows are in the relation: + * `"allRows"` for every standalone `acyclic: true` kind, `"openRows"` for + * a composition kind whose ended rows are history rather than memberships. + */ + membership: RowMembership; +}>; + +/** One acyclic relation: a name, and the oriented edge kinds that form it. */ +export type AcyclicEdgeRelation = Readonly<{ + /** The edge kind for a standalone relation; the composition relation's + * reserved name for the composition union. */ + name: string; + members: readonly AcyclicRelationMember[]; +}>; + +/** + * A standalone `acyclic: true` registration's own relation: the edge + * kind IS the relation, named after itself, with one `reversed: false` + * member. The one constructor for this shape, reached only through + * {@link declaredAcyclicEdgeRelations}, so a caller that reasons over a + * proposed edge kind rather than a runtime {@link GraphDef} — the + * schema-tightening preflight, judging a tightening's newly-declared edge + * kinds — builds the identical shape {@link acyclicEdgeRelations} does, + * rather than hand-spelling the member literal a second time. + */ +function standaloneAcyclicRelation(edgeKind: string): AcyclicEdgeRelation { + return { + name: edgeKind, + members: [{ edgeKind, reversed: false, membership: "allRows" }], + }; +} + +/** + * The composition relation as ONE acyclic relation, oriented part -> whole. `undefined` when the graph declares no composition pair — + * the caller drops it from the relation list rather than probing an empty + * one. + * + * `reversed` is defined relative to this relation's canonical walk direction, + * part -> whole: a `partSide: "to"` realizing edge (a `has_*`-shaped kind, + * whole `from` / part `to`) is stored whole -> part, so it must be walked in + * reverse to read part -> whole like every other member; a `partSide: "from"` + * kind already IS part -> whole in its stored direction. This is what lets + * `A partOf B via chapterOf` and `B partOf A via includedIn` — two edge + * kinds, opposite orientations — form ONE directed relation the recursive + * probe walks uniformly, so a cycle spanning both is caught even though + * neither edge kind is acyclic alone. + * + * Membership follows the composition relation's own population rule + * (`compositionEdgeKindMembers`), not the standalone one: an ended row + * of a `oneActive` realizing edge is the history a reparent leaves and is not + * in the relation, exactly as the composition claim, the cascade's closure + * and the attachment reader treat it. Counting it would make every move a + * permanent ancestor edge, so a former ancestor could never be placed under a + * former descendant. + * + * `standaloneAcyclicEdgeKinds` are the realizing edge kinds that are ALSO + * declared `acyclic: true`. Such a kind keeps the standalone rule, every + * non-deleted row counting, inside this relation too: a write is probed + * against this relation alone ({@link acyclicRelationForEdgeKind}) while the + * audit reads both, so a narrower population here would accept a cycle through + * an ended row that the kind's own declaration forbids, and the audit would + * then report it. + * + * Named after {@link COMPOSITION_RELATION_NAME} (the same reserved axis the + * composition CLAIM is written at, `src/store/claims/axis.ts`) — the + * acyclicity relation and the claim relation are two independent invariants + * that happen to share one reserved string because both are graph-wide, not + * per-edge-kind. The reserved separator is what keeps this + * name from ever colliding with a standalone `acyclic: true` relation (which + * is named after its own edge kind, and no edge kind may spell the + * separator — `assertClaimAxisSafe`); every place this name reaches a public + * error or audit field reads it through {@link displayAcyclicRelationName} + * first, so the separator itself is never something a caller sees. + */ +function compositionAcyclicRelation( + registry: KindRegistry, + standaloneAcyclicEdgeKinds: ReadonlySet = new Set(), +): AcyclicEdgeRelation | undefined { + const edgeKinds = registry.compositionEdgeKinds(); + if (edgeKinds.length === 0) return undefined; + return { + name: COMPOSITION_RELATION_NAME, + members: compositionEdgeKindMembers(registry, edgeKinds).map( + ({ edgeKind, membership }) => ({ + edgeKind, + reversed: + requireDefined(registry.compositionPartSide(edgeKind)) === "to", + membership: + standaloneAcyclicEdgeKinds.has(edgeKind) ? "allRows" : membership, + }), + ), + }; +} + +type PerGraphAndRegistryCache = WeakMap>; + +/** Reads `cache` for `(graph, registry)`, building and storing the value on a miss. */ +function memoizedPerGraphAndRegistry( + cache: PerGraphAndRegistryCache, + graph: GraphDef, + registry: KindRegistry, + build: () => T, +): T { + const byRegistry = cache.get(graph) ?? new WeakMap(); + const cached = byRegistry.get(registry); + if (cached !== undefined) return cached; + const value = build(); + byRegistry.set(registry, value); + cache.set(graph, byRegistry); + return value; +} + +const acyclicEdgeRelationsCache: PerGraphAndRegistryCache< + readonly AcyclicEdgeRelation[] +> = new WeakMap(); + +/** + * Every acyclic relation this graph declares, in code-point order by name: + * one standalone relation per `acyclic: true` edge kind, plus the + * composition relation ({@link compositionAcyclicRelation}) when the + * registry declares any `partOf`/`hasPart` pair. + * + * Memoized per `(GraphDef, KindRegistry)` object identity: + * {@link assertEdgeRelationsAcyclic} calls {@link acyclicRelationForEdgeKind} + * (which reads this) once per proposed edge, and neither a `GraphDef` nor a + * built `KindRegistry` changes after construction, so rebuilding and + * re-sorting this list per row of a large batch would be pure waste. Nested + * rather than a single map keyed on a composite: the schema-tightening + * preflight calls this with a PROPOSED registry built fresh per + * probe, so caching must never let a stale registry's relation answer for a + * different one built from the same `GraphDef`. + */ +export function acyclicEdgeRelations( + graph: GraphDef, + registry: KindRegistry, +): readonly AcyclicEdgeRelation[] { + return memoizedPerGraphAndRegistry( + acyclicEdgeRelationsCache, + graph, + registry, + () => + declaredAcyclicEdgeRelations( + registry, + Object.entries(graph.edges) + .filter(([, registration]) => registration.acyclic === true) + .map(([edgeKind]) => edgeKind), + ), + ); +} + +/** The relation an edge kind's writes are probed against, and its rule there. */ +type AcyclicEdgeKindPlacement = Readonly<{ + relation: AcyclicEdgeRelation; + membership: RowMembership; +}>; + +const acyclicEdgeKindPlacementsCache: PerGraphAndRegistryCache< + ReadonlyMap +> = new WeakMap(); + +/** + * Every acyclic edge kind's {@link AcyclicEdgeKindPlacement}, memoized like + * {@link acyclicEdgeRelations} so the per-row readers below are one lookup. + * The composition-wins rule for a kind in two relations lives here; see + * {@link acyclicRelationForEdgeKind}. + */ +function acyclicEdgeKindPlacements( + graph: GraphDef, + registry: KindRegistry, +): ReadonlyMap { + return memoizedPerGraphAndRegistry( + acyclicEdgeKindPlacementsCache, + graph, + registry, + () => { + const placements = new Map(); + for (const relation of acyclicEdgeRelations(graph, registry)) { + for (const member of relation.members) { + const placed = placements.get(member.edgeKind); + if ( + placed !== undefined && + (placed.relation.name === COMPOSITION_RELATION_NAME || + relation.name !== COMPOSITION_RELATION_NAME) + ) { + continue; + } + placements.set(member.edgeKind, { + relation, + membership: member.membership, + }); + } + } + return placements; + }, + ); +} + +/** + * {@link acyclicEdgeRelations} from its two inputs rather than a runtime + * {@link GraphDef}: the registry the composition relation is read from, and + * the edge kinds declared `acyclic: true`. The one constructor of the + * relation list, so a caller that holds a SERIALIZED schema — the + * schema-tightening preflight, judging a proposed version — builds exactly + * the relations the write path will enforce once that version is live. + */ +export function declaredAcyclicEdgeRelations( + registry: KindRegistry, + standaloneAcyclicEdgeKinds: readonly string[], +): readonly AcyclicEdgeRelation[] { + const standalone = standaloneAcyclicEdgeKinds.map((edgeKind) => + standaloneAcyclicRelation(edgeKind), + ); + const composition = compositionAcyclicRelation( + registry, + new Set(standaloneAcyclicEdgeKinds), + ); + return [ + ...standalone, + ...(composition === undefined ? [] : [composition]), + ].toSorted((left, right) => compareStrings(left.name, right.name)); +} + +/** + * The relation an edge of this kind belongs to, or `undefined`. + * + * Ordinarily an edge kind matches at most one relation. It can match TWO when + * a composition-realizing edge kind is also independently declared + * `acyclic: true` on its own registration — a standalone singleton + * (`standaloneAcyclicRelation`) named after the kind itself, alongside the + * composition union it already participates in. When that happens the + * COMPOSITION relation wins, EXPLICITLY (matched by + * {@link COMPOSITION_RELATION_NAME}), rather than as an artifact of + * {@link acyclicEdgeRelations}' name sort — `COMPOSITION_RELATION_NAME`'s + * reserved U+001E prefix happens to sort before every printable kind name, + * but that is an accident of code-point order, not a decision this function + * should depend on. Preferring composition is safe for cycle detection: its + * membership is a strict superset of the standalone singleton's one member, + * so any cycle the singleton alone could have caught is still caught — the + * refusal simply names the (correct, wider) `"composition"` relation instead + * of the kind's own name. + */ +export function acyclicRelationForEdgeKind( + graph: GraphDef, + registry: KindRegistry, + edgeKind: string, +): AcyclicEdgeRelation | undefined { + return acyclicEdgeKindPlacements(graph, registry).get(edgeKind)?.relation; +} + +/** + * The membership rule an edge of this kind is judged under in the relation + * its writes are probed against ({@link acyclicRelationForEdgeKind}), or + * `undefined` when the kind is in no acyclic relation. + * + * A static fact of the kind: a window reopen re-admits one of its rows to the + * relation exactly when this is `"openRows"`, which is what a write that + * clears `validTo` reads to decide whether it owes the acyclicity probe and + * fence before it has read any row. + */ +export function acyclicRelationMembership( + graph: GraphDef, + registry: KindRegistry, + edgeKind: string, +): RowMembership | undefined { + return acyclicEdgeKindPlacements(graph, registry).get(edgeKind)?.membership; +} + +/** + * THE acyclicity-relation membership of one row: whether a non-deleted row of + * `edgeKind` whose window ends at `validTo` is in the relation its kind's + * writes are probed against. `false` for a kind in no acyclic relation. + * + * {@link membershipCountsRow} under the relation member's own rule, so a + * composition kind's ended row is outside the composition union while the + * same row of a kind also declared `acyclic: true` stays in it. Every write + * that hands the probe a row it has not stored in its final state calls + * this: the rows a create, resurrection or reopen proposes (dropped from the + * probe when they are not members, {@link groupProposedByAcyclicRelation}), + * and the rows a move retires (`bulkReparent`, which excludes an incumbent + * only when its retirement takes it out of the relation). + */ +export function acyclicRelationCountsRow( + graph: GraphDef, + registry: KindRegistry, + row: Readonly<{ edgeKind: string; validTo: string | undefined }>, +): boolean { + const membership = acyclicRelationMembership(graph, registry, row.edgeKind); + return membership !== undefined && membershipCountsRow(membership, row); +} + +/** + * Every edge kind that participates in ANY acyclic relation, in + * {@link acyclicEdgeRelations}' relation order: the composition relation's + * members first, then the standalone kinds in code-point order. A kind both + * composition-realizing and declared `acyclic: true` appears once per + * relation. A projection of {@link acyclicEdgeRelations} for callers that need + * the flat kind list (a trusted-import capability refusal, an import-time + * batching decision over more than one candidate edge) rather than the + * relation structure itself — reading it through this function rather than + * re-filtering `graph.edges` keeps them from drifting once a relation can + * have more than one member. + */ +export function acyclicEdgeKinds( + graph: GraphDef, + registry: KindRegistry, +): readonly string[] { + return acyclicEdgeRelations(graph, registry).flatMap((relation) => + relation.members.map((member) => member.edgeKind), + ); +} + +/** + * Whether this edge kind participates in ANY acyclic relation. THE predicate + * every write-eligibility, fused-command, or import-batching decision must + * consult instead of re-reading `registration.acyclic === true` directly + * (AGENTS.md "one predicate, one owner": a second inline spelling of an + * existing decision drifts even while the copies still agree). This is wider + * than `registration.acyclic === true`: the composition relation is an + * oriented union, so a composition-realizing edge kind answers `true` here + * even though its OWN registration carries no `acyclic` field. + */ +export function edgeKindIsInAcyclicRelation( + graph: GraphDef, + registry: KindRegistry, + edgeKind: string, +): boolean { + return acyclicEdgeKindPlacements(graph, registry).has(edgeKind); +} + +/** + * An edge a writer proposes to have in the relation when the frame commits, + * in the state it will have then. `validTo` is the row's end after the write + * (`undefined` for an open window): a row the relation does not count in that + * state ({@link acyclicRelationCountsRow}) is no member and is not probed. + */ +export type ProposedRelationEdge = Readonly<{ + edgeId: string; + edgeKind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + validTo: string | undefined; +}>; + +/** + * A proposed edge whose endpoints are the same node: a cycle of length one, + * answerable without a round trip because the reflexive seed would return it. + */ +function proposedEdgeIsSelfLoop(edge: ProposedRelationEdge): boolean { + return edge.fromKind === edge.toKind && edge.fromId === edge.toId; +} + +/** One acyclic relation's share of a proposed edge set. */ +type ProposedAcyclicGroup = Readonly<{ + relation: AcyclicEdgeRelation; + /** The edges that still need the recursive probe. */ + edges: readonly ProposedRelationEdge[]; + /** Edges already decided by {@link proposedEdgeIsSelfLoop}. */ + selfLoopEdgeIds: readonly string[]; +}>; + +/** + * The classification that decides WHAT gets probed, owned once: proposed + * edges the relation of their kind does not count + * ({@link acyclicRelationCountsRow} — a kind in no acyclic relation, or a + * composition row that will be ended) are dropped, the rest are bucketed by + * relation in code-point order by name, and the self-loops are separated + * out. A dropped row is never a self-loop refusal either: an ended history + * row closes no cycle. `firstSelfLoop` is the earliest self-loop in `proposed` + * order, so the write path can refuse exactly the row its caller listed first + * while the plan-time preview collects every relation's self-loops. + */ +function groupProposedByAcyclicRelation( + graph: GraphDef, + registry: KindRegistry, + proposed: readonly ProposedRelationEdge[], +): Readonly<{ + groups: readonly ProposedAcyclicGroup[]; + firstSelfLoop: + | Readonly<{ relation: AcyclicEdgeRelation; edge: ProposedRelationEdge }> + | undefined; +}> { + const byRelationName = new Map< + string, + { + relation: AcyclicEdgeRelation; + edges: ProposedRelationEdge[]; + selfLoopEdgeIds: string[]; + } + >(); + let firstSelfLoop: + | Readonly<{ relation: AcyclicEdgeRelation; edge: ProposedRelationEdge }> + | undefined; + for (const edge of proposed) { + if (!acyclicRelationCountsRow(graph, registry, edge)) continue; + const relation = requireDefined( + acyclicRelationForEdgeKind(graph, registry, edge.edgeKind), + ); + const entry = byRelationName.get(relation.name) ?? { + relation, + edges: [], + selfLoopEdgeIds: [], + }; + byRelationName.set(relation.name, entry); + if (proposedEdgeIsSelfLoop(edge)) { + entry.selfLoopEdgeIds.push(edge.edgeId); + firstSelfLoop ??= { relation, edge }; + } else { + entry.edges.push(edge); + } + } + const groups = [...byRelationName.keys()] + .toSorted((left, right) => compareStrings(left, right)) + .map((name) => requireDefined(byRelationName.get(name))); + return { groups, firstSelfLoop }; +} + +/** + * The `edgeAcyclicity` member of `ConstraintFenceViolation` + * (`src/store/claims/verify.ts`), defined here so the shape has one owner: + * the family carries no `ClaimTarget` — acyclicity reserves no claim row, + * so there is nothing shaped like one to name. + */ +export type EdgeAcyclicityViolation = Readonly<{ + family: "edgeAcyclicity"; + /** The declared relation's display name. */ + relation: string; + /** Live edges of the relation whose `to` reaches their `from`. */ + edgeIds: readonly string[]; +}>; + +export type AcyclicityProbeContext = Readonly<{ + graphId: string; + graph: GraphDef; + /** + * Needed alongside `graph`: whether an edge kind is IN an acyclic relation, + * and which one, depends on the composition relation too — a fact `graph` + * alone cannot answer. + */ + registry: KindRegistry; + schema: SqlSchema; + dialect: DialectAdapter; + /** The transaction target the frame's row work runs on. */ + target: Pick< + GraphBackend, + "capabilities" | "commands" | "dialect" | "execute" + >; + /** Compile-time evidence the per-graph fence was taken before any read. */ + lock: GraphWriteLock; + /** Echoed in every refusal's `details.operation`. */ + operation: string; +}>; + +/** + * The probe context for an acyclicity assertion run on `target`. The dialect + * is read from that target, so no caller threads a second copy of it. + */ +export function acyclicityProbeContext( + params: Omit, +): AcyclicityProbeContext { + return { ...params, dialect: getDialect(params.target.dialect) }; +} + +/** + * What the audit reader needs — `AcyclicityProbeContext` minus `lock` (no + * write to fence) and `graph` (the relations to probe are passed + * explicitly, so a caller working from a SERIALIZED schema document rather + * than a runtime `GraphDef` — the schema-tightening preflight — need not + * fabricate one just to satisfy this type). + */ +export type AcyclicityAuditContext = Omit< + AcyclicityProbeContext, + "lock" | "graph" +>; + +function acyclicityProbeStatement( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + seed: AcyclicityProbeSeed, + excludedEdgeIds: readonly string[], +): SqlFragment { + return buildEdgeAcyclicityProbe({ + graphId: ctx.graphId, + members: relation.members, + seed, + dialect: ctx.dialect, + schema: ctx.schema, + recursiveTraversal: resolveRecursiveTraversal(ctx.target.capabilities), + operation: ctx.operation, + excludedEdgeIds, + }); +} + +/** + * How many caller-supplied rows one probe statement may carry: what the + * engine's bind budget leaves once the statement's own parameters (graph id, + * kind filters, the packed exclusion list) are paid for. Measured off a + * rendered one-row statement rather than counted by hand, so it cannot fall + * behind the builder. + * + * `undefined` when the backend declares no `maxBindParameters`: there is no + * limit to slice against. + * + * @throws ConfigurationError when the budget cannot fit a single row. + */ +function acyclicityProbeRowBudget( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + sample: ProposedRelationEdge, + excludedEdgeIds: readonly string[], +): number | undefined { + const maxBindParameters = ctx.target.capabilities.maxBindParameters; + if (maxBindParameters === undefined) return undefined; + const oneRowStatement = acyclicityProbeStatement( + ctx, + relation, + { kind: "proposed", edges: [sample] }, + excludedEdgeIds, + ); + const available = + maxBindParameters - + (countSqlParameters(oneRowStatement) - ACYCLICITY_SEED_ROW_PARAM_COUNT); + if ( + available < + ACYCLICITY_SEED_ROW_PARAM_COUNT + ACYCLICITY_TARGET_ROW_PARAM_COUNT + ) { + throw new ConfigurationError( + `The acyclicity check for the "${displayAcyclicRelationName(relation.name)}" relation does not fit this backend's limit of ${maxBindParameters} bound parameters.`, + { + capability: "maxBindParameters", + maxBindParameters, + relation: displayAcyclicRelationName(relation.name), + operation: ctx.operation, + }, + ); + } + return available; +} + +/** + * The `"proposed"` form over any number of rows. Each origin is answered on + * its own (its `to` either reaches its own `from` or does not), so the rows + * are simply sent in slices that fit the bind budget. + */ +async function readProposedViolatingOriginKeys( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + edges: readonly ProposedRelationEdge[], +): Promise { + // One row is one statement whatever the budget: the common single-edge + // write pays for no sizing. + const budget = + edges.length === 1 ? + undefined + : acyclicityProbeRowBudget(ctx, relation, requireDefined(edges[0]), []); + const slices = + budget === undefined ? + [edges] + : chunk(edges, Math.floor(budget / ACYCLICITY_SEED_ROW_PARAM_COUNT)); + const originKeys: string[] = []; + for (const slice of slices) { + originKeys.push( + ...(await readViolatingOriginKeys(ctx, relation, { + kind: "proposed", + edges: slice, + })), + ); + } + return originKeys; +} + +/** + * Every reach among a set of UNWRITTEN edges: one row per pair where the + * first edge's `to` reaches the second's `from` over the relation's stored + * edges (less `excludedEdgeIds`). + * + * Origins and targets are sliced independently and every origin slice is + * probed against every target slice, so the answer is the complete pair set + * however the rows were divided. The split gives origins and targets the + * shares of the budget that minimize the number of statements; a set that + * fits one statement costs one. + */ +async function readUnwrittenEdgeReaches( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + edges: readonly ProposedRelationEdge[], + excludedEdgeIds: readonly string[], +): Promise { + const budget = acyclicityProbeRowBudget( + ctx, + relation, + requireDefined(edges[0]), + excludedEdgeIds, + ); + const fitsOneStatement = + budget === undefined || + edges.length * + (ACYCLICITY_SEED_ROW_PARAM_COUNT + ACYCLICITY_TARGET_ROW_PARAM_COUNT) <= + budget; + const [originSlices, targetSlices] = + fitsOneStatement ? + [[edges], [edges]] + : [ + chunk( + edges, + Math.max( + 1, + Math.floor(budget / (2 * ACYCLICITY_SEED_ROW_PARAM_COUNT)), + ), + ), + chunk( + edges, + Math.max( + 1, + Math.floor(budget / (2 * ACYCLICITY_TARGET_ROW_PARAM_COUNT)), + ), + ), + ]; + const reaches: ProposedEdgeReach[] = []; + for (const origins of originSlices) { + for (const targets of targetSlices) { + reaches.push( + ...(await runAcyclicityProbe( + ctx, + relation, + { kind: "unwritten", edges: origins, targets }, + excludedEdgeIds, + )), + ); + } + } + return reaches; +} + +/** + * Runs one acyclicity probe statement and returns its rows — one per origin + * the database reports as reaching its own `from` (empty when the seed's rows + * are all fine), or the `"unwritten"` form's reach pairs. Shared by the + * write-path assertions and the audit reader, so a cut-short statement is + * classified identically by all of them. + * + * @throws EdgeAcyclicityIndeterminateError when the engine cut the statement + * short. + */ +async function runAcyclicityProbe>( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + seed: AcyclicityProbeSeed, + excludedEdgeIds: readonly string[] = [], +): Promise { + const fragment = acyclicityProbeStatement( + ctx, + relation, + seed, + excludedEdgeIds, + ); + try { + return await ctx.target.execute(asCompiledRowsSql(fragment)); + } catch (error) { + if (!isStatementCutShortError(error)) throw error; + throw new EdgeAcyclicityIndeterminateError( + { + relation: displayAcyclicRelationName(relation.name), + operation: ctx.operation, + graphId: ctx.graphId, + }, + { cause: error }, + ); + } +} + +/** The origins one probe reports, for the forms that return nothing else. */ +async function readViolatingOriginKeys( + ctx: AcyclicityAuditContext, + relation: AcyclicEdgeRelation, + seed: AcyclicityProbeSeed, +): Promise { + const rows = await runAcyclicityProbe(ctx, relation, seed); + return rows.map((row) => row.origin_key); +} + +/** + * The acyclicity probe is a lock-only read: its frame advances the graph's + * fence row before it, and it runs only on a session that observes the + * deciders that held the fence before it. + * + * @throws TransactionConflictError when another decider committed after this + * session's snapshot + * @throws ConfigurationError (`EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT`) + */ +function fenceAcyclicityDecision(ctx: AcyclicityProbeContext): Promise { + return fenceLockOnlyDecision(ctx.target, ctx.graphId, ctx.lock, { + code: "EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT", + subject: "Edge-acyclicity", + }); +} + +/** + * THE acyclicity predicate for edges a frame has ALREADY made visible to its + * own transaction, or for a single proposed edge. Refuses with + * {@link EdgeAcyclicityError} when any proposed edge closes a cycle in its + * relation; with {@link EdgeAcyclicityIndeterminateError} when the engine cut + * the search short; with `ConfigurationError` + * (`RECURSIVE_TRAVERSAL_UNSUPPORTED`) when the backend declares no recursive + * traversal; with `ConfigurationError` + * (`EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT`) when the fenced session cannot + * observe writes committed while it waited for the fence. + * + * The question is "does `from` lie in the reflexive-transitive closure of + * `to`", and a single proposed edge, present or absent, is never on such a + * path unless a cycle already exists — so one row may be probed before its + * insert. MORE than one row is different: two rows of one set can close a + * cycle through each other, which this form sees only when both are already + * live. A caller holding several rows it has NOT written yet — every batch + * that must refuse before its first write — calls + * {@link assertUnwrittenEdgeRelationsAcyclic} instead. + * + * Short-circuits three ways before touching SQL: a proposed row whose kind is + * in no acyclic relation is dropped; a self-loop + * (`fromKind === toKind && fromId === toId`) is refused immediately, because + * the reflexive seed would answer the same question with a round trip; and an + * empty remaining set returns with no statement. + */ +export async function assertEdgeRelationsAcyclic( + ctx: AcyclicityProbeContext, + proposed: readonly ProposedRelationEdge[], +): Promise { + const probeable = selfLoopFreeAcyclicGroups(ctx, proposed); + if (probeable.length === 0) return; + + await fenceAcyclicityDecision(ctx); + + for (const { relation, edges } of probeable) { + const violatingOriginKeys = await readProposedViolatingOriginKeys( + ctx, + relation, + edges, + ); + if (violatingOriginKeys.length === 0) continue; + throw cycleRefusal( + relation, + edges.find((edge) => violatingOriginKeys.includes(edge.edgeId)) ?? + requireDefined(edges[0]), + ); + } +} + +/** + * The same predicate over a proposed-edge OVERLAY: the relation's live edges + * plus `proposed` itself, none of which needs to exist yet. This is what a + * batch calls BEFORE its insert, so a refusal precedes the first write and a + * caller that catches it inside an enclosing transaction commits no cycle. + * + * A cycle in the overlay alternates proposed edges with (possibly empty) live + * paths between them, so it is decided in two steps: one walk over the live + * relation answers "which proposed edges' `from` does each proposed edge's + * `to` reach" (the `"unwritten"` seed form — an index seek per hop, like the + * single-row probe), and {@link proposedEdgeOnOverlayCycle} then looks for a + * cycle among those reaches. A cycle closed entirely by rows of the one batch + * (`a→b` and `b→a` together) is seen because a row's `to` trivially reaches + * the next row's `from`. + * + * Order-insensitive: a proposed row that is already live only adds reaches, + * never removes one, so the probe means the same thing before and after the + * insert. + * + * `excludedEdgeIds` names stored edges the same frame retires before it + * writes `proposed` (a batch of moves), with the meaning + * {@link readProposedEdgeAcyclicityViolations} gives it: the overlay is the + * state the frame produces. + */ +export async function assertUnwrittenEdgeRelationsAcyclic( + ctx: AcyclicityProbeContext, + proposed: readonly ProposedRelationEdge[], + excludedEdgeIds: readonly string[] = [], +): Promise { + const probeable = selfLoopFreeAcyclicGroups(ctx, proposed); + if (probeable.length === 0) return; + + await fenceAcyclicityDecision(ctx); + + for (const { relation, edges } of probeable) { + const reaches = await readUnwrittenEdgeReaches( + ctx, + relation, + edges, + excludedEdgeIds, + ); + const [violatingEdge] = proposedEdgesOnOverlayCycle(edges, reaches, 1); + if (violatingEdge === undefined) continue; + throw cycleRefusal(relation, violatingEdge); + } +} + +/** One `"unwritten"` probe row: `origin_key`'s `to` reaches `reached_key`'s `from` over live edges. */ +type ProposedEdgeReach = Readonly<{ origin_key: string; reached_key: string }>; + +/** + * The proposed edges, in the caller's order, that lie on a cycle of the + * "reaches" digraph (an arc `i → j` per {@link ProposedEdgeReach}) — at most + * `limit` of them, and none when that digraph is acyclic. + * + * Trims every edge no arc enters, repeatedly: whatever survives has an + * entering arc from another survivor, so survivors exist exactly when a cycle + * does. The common no-cycle batch therefore costs one linear pass; the + * per-edge search below runs only to name the edges a refusal reports, and + * stops at `limit` (the write path names one, the plan preview all). + */ +function proposedEdgesOnOverlayCycle( + edges: readonly ProposedRelationEdge[], + reaches: readonly ProposedEdgeReach[], + limit = Number.POSITIVE_INFINITY, +): readonly ProposedRelationEdge[] { + const successors = new Map(); + const enteringArcs = new Map(edges.map((edge) => [edge.edgeId, 0])); + for (const { origin_key: origin, reached_key: reached } of reaches) { + const known = successors.get(origin); + if (known === undefined) successors.set(origin, [reached]); + else known.push(reached); + enteringArcs.set(reached, (enteringArcs.get(reached) ?? 0) + 1); + } + const trimmable = [...enteringArcs] + .filter(([, count]) => count === 0) + .map(([edgeId]) => edgeId); + while (trimmable.length > 0) { + const edgeId = requireDefined(trimmable.pop()); + enteringArcs.delete(edgeId); + for (const successor of successors.get(edgeId) ?? []) { + const remaining = requireDefined(enteringArcs.get(successor)) - 1; + enteringArcs.set(successor, remaining); + if (remaining === 0) trimmable.push(successor); + } + } + const onCycle: ProposedRelationEdge[] = []; + for (const edge of edges) { + if (onCycle.length >= limit) break; + if ( + enteringArcs.has(edge.edgeId) && + reachesItself(edge.edgeId, successors) + ) { + onCycle.push(edge); + } + } + return onCycle; +} + +function reachesItself( + edgeId: string, + successors: ReadonlyMap, +): boolean { + const visited = new Set(); + const frontier = [...(successors.get(edgeId) ?? [])]; + while (frontier.length > 0) { + const current = requireDefined(frontier.pop()); + if (current === edgeId) return true; + if (visited.has(current)) continue; + visited.add(current); + frontier.push(...(successors.get(current) ?? [])); + } + return false; +} + +/** + * The shared front half of both write-path assertions: refuses the caller's + * first self-loop, and returns the relation groups that still owe a probe. + */ +function selfLoopFreeAcyclicGroups( + ctx: AcyclicityProbeContext, + proposed: readonly ProposedRelationEdge[], +): readonly ProposedAcyclicGroup[] { + const { groups, firstSelfLoop } = groupProposedByAcyclicRelation( + ctx.graph, + ctx.registry, + proposed, + ); + if (firstSelfLoop !== undefined) { + throw cycleRefusal(firstSelfLoop.relation, firstSelfLoop.edge, true); + } + return groups.filter((group) => group.edges.length > 0); +} + +function cycleRefusal( + relation: AcyclicEdgeRelation, + edge: ProposedRelationEdge, + selfLoop = false, +): EdgeAcyclicityError { + return new EdgeAcyclicityError({ + relation: displayAcyclicRelationName(relation.name), + edgeKind: edge.edgeKind, + edgeId: edge.edgeId, + fromKind: edge.fromKind, + fromId: edge.fromId, + toKind: edge.toKind, + toId: edge.toId, + selfLoop, + }); +} + +/** + * The audit reader: every live edge of `relations` whose `to` endpoint + * reaches its `from` endpoint. Shared verbatim with `verifyConstraintFences` + * (`src/store/claims/verify.ts`) and with the `acyclic`-added schema-tightening + * probe (`src/schema/tightening-preflight.ts`), so there is exactly one + * implementation of "is there a cycle". + * + * Takes no `lock`: this is a read-only diagnostic, never gating a live write + * against a concurrent race, so it runs no isolation-freshness check. + */ +export async function readEdgeAcyclicityViolations( + ctx: AcyclicityAuditContext, + relations: readonly AcyclicEdgeRelation[], +): Promise { + const violations: EdgeAcyclicityViolation[] = []; + for (const relation of [...relations].toSorted((left, right) => + compareStrings(left.name, right.name), + )) { + const originKeys = await readViolatingOriginKeys(ctx, relation, { + kind: "relation", + }); + if (originKeys.length === 0) continue; + violations.push({ + family: "edgeAcyclicity", + relation: displayAcyclicRelationName(relation.name), + edgeIds: [...originKeys].toSorted(compareStrings), + }); + } + return violations; +} + +/** + * The plan-time preview: every violation a caller-proposed edge + * set would create if it were added to the relation's CURRENT live + * population. This is what lets the graph-merge planner ask "would this + * resolved plan's edge writes close a cycle" and surface a typed conflict + * for review, without ever writing anything. + * + * Read-only and lock-free like {@link readEdgeAcyclicityViolations} — this is + * a PREVIEW, not a write gate, and takes no `lock` for the same reason that + * function does not: it decides nothing on its own. It runs before any + * per-graph write lock exists (a merge plan does no write to fence), and its + * answer is inherently racy against a concurrent writer of the SAME relation + * — which is fine, because the actual write path + * ({@link assertEdgeRelationsAcyclic}) re-verifies under the per-graph write + * lock at commit/apply time regardless, and remains the sole authority. + * + * Shares the unwritten-rows probe and its in-memory cycle search with a + * batch's own pre-write check ({@link assertUnwrittenEdgeRelationsAcyclic}), + * so a write-path refusal and a plan-time preview cannot disagree about what + * counts as a cycle; this reader differs only in reporting EVERY proposed + * edge on a cycle rather than refusing at the first. A self-loop among + * `proposed` is reported directly, without a round trip. + * + * `excludedEdgeIds` names stored edges the same plan removes. They are left + * out of the walk, so the preview judges the state the plan produces — a + * reversal (`a → b` deleted, `b → a` added) is acyclic — rather than the + * proposed edges layered onto rows that will be gone. + */ +export async function readProposedEdgeAcyclicityViolations( + ctx: AcyclicityAuditContext, + graph: GraphDef, + proposed: readonly ProposedRelationEdge[], + excludedEdgeIds: readonly string[] = [], +): Promise { + const { groups } = groupProposedByAcyclicRelation( + graph, + ctx.registry, + proposed, + ); + const violations: EdgeAcyclicityViolation[] = []; + for (const { relation, edges, selfLoopEdgeIds } of groups) { + const probedIds = + edges.length === 0 ? + [] + : proposedEdgesOnOverlayCycle( + edges, + await readUnwrittenEdgeReaches(ctx, relation, edges, excludedEdgeIds), + ).map((edge) => edge.edgeId); + const edgeIds = [...new Set([...selfLoopEdgeIds, ...probedIds])].toSorted( + compareStrings, + ); + if (edgeIds.length === 0) continue; + violations.push({ + family: "edgeAcyclicity", + relation: displayAcyclicRelationName(relation.name), + edgeIds, + }); + } + return violations; +} diff --git a/packages/typegraph/src/store/algorithms/context.ts b/packages/typegraph/src/store/algorithms/context.ts index dae6b484b..eeed8506c 100644 --- a/packages/typegraph/src/store/algorithms/context.ts +++ b/packages/typegraph/src/store/algorithms/context.ts @@ -23,6 +23,10 @@ import { } from "../../query/compiler/temporal"; import { type DialectAdapter } from "../../query/dialect/types"; import { type KindRegistry } from "../../registry/kind-registry"; +import { + assertRegisteredEdgeKinds, + assertRegisteredNodeKinds, +} from "../registered-kinds"; import type { AlgorithmCyclePolicy, TraversalDirection } from "./types"; export const DEFAULT_ALGORITHM_MAX_HOPS = 10; @@ -214,13 +218,34 @@ export function assertPositiveSafeIntegerOption( } } -export function assertEdgeKinds(edges: readonly string[]): void { +/** + * @throws ConfigurationError when `edges` is empty. + * @throws KindNotFoundError when it names an edge kind the graph does not + * register, which would otherwise traverse nothing and answer as if the + * graph were simply disconnected. + */ +export function assertEdgeKinds( + ctx: Pick, + edges: readonly string[], +): void { if (edges.length === 0) { throw new ConfigurationError( `Graph algorithms require at least one edge kind in 'edges'.`, { edges }, ); } + assertRegisteredEdgeKinds(ctx.graph, edges); +} + +/** + * @throws KindNotFoundError when a stated `nodeKinds` scope names a node kind + * the graph does not register, which would otherwise seed nothing. + */ +export function assertNodeKindScope( + ctx: Pick, + nodeKinds: readonly string[] | undefined, +): void { + assertRegisteredNodeKinds(ctx.graph, nodeKinds ?? []); } export function assertGraphAnalyticsSupported( diff --git a/packages/typegraph/src/store/algorithms/degree.ts b/packages/typegraph/src/store/algorithms/degree.ts index 761f56b05..5a4faa981 100644 --- a/packages/typegraph/src/store/algorithms/degree.ts +++ b/packages/typegraph/src/store/algorithms/degree.ts @@ -1,6 +1,7 @@ import { compileKindFilter } from "../../query/compiler/predicate-utils"; import { sql, type SqlFragment } from "../../query/sql-fragment"; import { asCompiledRowsSql } from "../../query/sql-intent"; +import { assertRegisteredEdgeKinds } from "../registered-kinds"; import { type AlgorithmContext, type InternalTemporalOptions, @@ -21,6 +22,7 @@ export async function executeDegree( options: InternalDegreeOptions = {}, ): Promise { const direction = options.direction ?? "both"; + assertRegisteredEdgeKinds(ctx.graph, options.edges ?? []); const edgeKinds = options.edges ?? Object.keys(ctx.graph.edges); // A graph that declares no edge kinds has no edges to count — provably zero // without a round trip. diff --git a/packages/typegraph/src/store/algorithms/iterative-graph-operation.ts b/packages/typegraph/src/store/algorithms/iterative-graph-operation.ts index 9642dcd69..003180d6a 100644 --- a/packages/typegraph/src/store/algorithms/iterative-graph-operation.ts +++ b/packages/typegraph/src/store/algorithms/iterative-graph-operation.ts @@ -20,7 +20,11 @@ import { } from "../../query/compiler/temporal"; import { type DialectAdapter } from "../../query/dialect/types"; import { jsonPointer } from "../../query/json-pointer"; -import { sql, type SqlFragment } from "../../query/sql-fragment"; +import { + countSqlParameters, + sql, + type SqlFragment, +} from "../../query/sql-fragment"; import { asCompiledRowsSql, asCompiledTemporaryStatementSql, @@ -167,7 +171,7 @@ export async function seedWorkingTableFromNodes( const parameterLimit = context.operation.backend.capabilities.maxBindParameters ?? DEFAULT_MAX_BIND_PARAMETERS; - const fixedParameterCount = countBindParameters(fixedStatement); + const fixedParameterCount = countSqlParameters(fixedStatement); // The full-graph seed carries no kind binds and only has to fit; a scoped // seed also needs room for at least one kind bind before it can be chunked. const kindBindBudget = parameterLimit - fixedParameterCount; @@ -193,15 +197,6 @@ export async function seedWorkingTableFromNodes( } } -function countBindParameters(fragment: SqlFragment): number { - return fragment.chunks.reduce( - (count, chunk) => - count + - (chunk.kind === "parameter" || chunk.kind === "placeholder" ? 1 : 0), - 0, - ); -} - export type IterativeGraphPlan< State extends IterativeGraphState, Result, diff --git a/packages/typegraph/src/store/algorithms/label-propagation.ts b/packages/typegraph/src/store/algorithms/label-propagation.ts index 7ab78d462..df821be3f 100644 --- a/packages/typegraph/src/store/algorithms/label-propagation.ts +++ b/packages/typegraph/src/store/algorithms/label-propagation.ts @@ -6,6 +6,7 @@ import { type AlgorithmContext, assertEdgeKinds, assertGraphAnalyticsSupported, + assertNodeKindScope, type InternalTraversalOptions, pickTemporalOptions, resolveMaxIterations, @@ -63,7 +64,8 @@ export async function executeLabelPropagation( ctx: AlgorithmContext, options: InternalLabelPropagationOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); + assertNodeKindScope(ctx, options.nodeKinds); assertGraphAnalyticsSupported(ctx, "labelPropagation", { requiresWindowFunctions: true, }); diff --git a/packages/typegraph/src/store/algorithms/page-rank.ts b/packages/typegraph/src/store/algorithms/page-rank.ts index 599679093..64cca3259 100644 --- a/packages/typegraph/src/store/algorithms/page-rank.ts +++ b/packages/typegraph/src/store/algorithms/page-rank.ts @@ -6,6 +6,7 @@ import { type AlgorithmContext, assertEdgeKinds, assertGraphAnalyticsSupported, + assertNodeKindScope, assertPositiveSafeIntegerOption, type InternalTraversalOptions, pickTemporalOptions, @@ -93,7 +94,8 @@ function executePageRankOperation( ): Promise { const algorithm: PageRankAlgorithm = seeds === undefined ? "pageRank" : "personalizedPageRank"; - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); + assertNodeKindScope(ctx, options.nodeKinds); assertGraphAnalyticsSupported(ctx, algorithm); const resolved = resolvePageRankOptions(options, algorithm); const traversalOptions: InternalTraversalOptions = { diff --git a/packages/typegraph/src/store/algorithms/reachable.ts b/packages/typegraph/src/store/algorithms/reachable.ts index 4ad59afd5..d5a5158e1 100644 --- a/packages/typegraph/src/store/algorithms/reachable.ts +++ b/packages/typegraph/src/store/algorithms/reachable.ts @@ -21,7 +21,7 @@ export async function executeReachable( sourceId: string, options: InternalReachableOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); const maxHops = resolveMaxHops( options.maxHops, DEFAULT_ALGORITHM_MAX_HOPS, @@ -40,7 +40,7 @@ export async function executeCanReach( targetId: string, options: InternalTraversalOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); const maxHops = resolveMaxHops( options.maxHops, diff --git a/packages/typegraph/src/store/algorithms/shortest-path.ts b/packages/typegraph/src/store/algorithms/shortest-path.ts index f99fec95b..39f520e35 100644 --- a/packages/typegraph/src/store/algorithms/shortest-path.ts +++ b/packages/typegraph/src/store/algorithms/shortest-path.ts @@ -14,7 +14,7 @@ export async function executeShortestPath( targetId: string, options: InternalTraversalOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); const maxHops = resolveMaxHops( options.maxHops, DEFAULT_ALGORITHM_MAX_HOPS, diff --git a/packages/typegraph/src/store/algorithms/weakly-connected-components.ts b/packages/typegraph/src/store/algorithms/weakly-connected-components.ts index 5407331cf..05aa91b34 100644 --- a/packages/typegraph/src/store/algorithms/weakly-connected-components.ts +++ b/packages/typegraph/src/store/algorithms/weakly-connected-components.ts @@ -5,6 +5,7 @@ import { type AlgorithmContext, assertEdgeKinds, assertGraphAnalyticsSupported, + assertNodeKindScope, assertPositiveSafeIntegerOption, type InternalTraversalOptions, pickTemporalOptions, @@ -43,7 +44,8 @@ export async function executeWeaklyConnectedComponents( ctx: AlgorithmContext, options: InternalWeaklyConnectedComponentsOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); + assertNodeKindScope(ctx, options.nodeKinds); assertGraphAnalyticsSupported(ctx, "weaklyConnectedComponents", { requiresWindowFunctions: true, }); diff --git a/packages/typegraph/src/store/algorithms/weighted-shortest-path.ts b/packages/typegraph/src/store/algorithms/weighted-shortest-path.ts index e967d9fad..8bdbc8a31 100644 --- a/packages/typegraph/src/store/algorithms/weighted-shortest-path.ts +++ b/packages/typegraph/src/store/algorithms/weighted-shortest-path.ts @@ -135,7 +135,7 @@ export async function executeWeightedShortestPath( targetId: string, options: InternalWeightedShortestPathOptions, ): Promise { - assertEdgeKinds(options.edges); + assertEdgeKinds(ctx, options.edges); assertWeightOptions(options.weightProperty, options.defaultWeight); const maxIterations = resolveMaxIterations( options.maxIterations, @@ -395,7 +395,7 @@ async function findWeightedShortestPathInWorkingTable( return state.frontierCount === 0; }, async extractResult(context) { - // Selects rather than refuses (§5.1.3 of the design): the working-table + // Selects rather than refuses: the working-table // path is gated on temp tables/RETURNING, not recursive traversal, so // an engine that can run every relaxation round but lacks the // recursive CTE still gets a working extraction instead of a refusal @@ -702,7 +702,7 @@ export async function collectPredecessorChain( * primary-key point read per hop instead of one recursive statement. Costs * `path length + 1` statements rather than 1, which is the applied-not- * refused answer for an engine that can run every relaxation round but - * cannot run the final recursive read (§5.1.3 of the design). + * cannot run the final recursive read. */ async function extractPathByPredecessorWalk( context: IterativeGraphRunContext, diff --git a/packages/typegraph/src/store/claims/axis.ts b/packages/typegraph/src/store/claims/axis.ts index 754be90f1..395ce95b9 100644 --- a/packages/typegraph/src/store/claims/axis.ts +++ b/packages/typegraph/src/store/claims/axis.ts @@ -21,10 +21,15 @@ */ import { subClassComponent } from "../../constraints"; import { type UniquenessScope } from "../../core/types"; +import { uniquenessScopeSpansSubclasses } from "../../core/unique-constraint-options"; import { ConfigurationError } from "../../errors"; import { type KindRegistry } from "../../registry/kind-registry"; -import { compareStrings } from "../../utils/compare"; +import { compareCodePoints, compareStrings } from "../../utils/compare"; import { encodeTupleKey } from "../../utils/tuple-key"; +// Type-only: `edge-claims.ts` value-imports `edgeCardinalityAxis` from this +// file, but a type-only import is erased at runtime, so this back-edge +// creates no value cycle. +import { type EdgeCardinalityAxisRef } from "./edge-claims"; /** * The one code point an axis component may not contain, written as an escape @@ -52,6 +57,48 @@ const DISJOINT_AXIS_PREFIX = `${AXIS_SEPARATOR}disjoint${AXIS_SEPARATOR}`; */ export const DISJOINT_CONSTRAINT_NAME = `${AXIS_SEPARATOR}disjointWith`; +/** + * THE axis every composition claim is written at, and THE name of the + * composition acyclic relation ({@link file://../acyclicity.ts + * compositionAcyclicRelation}). + * + * ONE string for the whole graph, not one per edge kind and not one per + * population: the single-whole invariant is relation-wide, so two composition edges out + * of one part must collide on one row regardless of which `partOf`/`hasPart` + * pair or which realizing edge kind wrote it. Prefixed with the reserved + * separator so no kind name can spell it (enforced the same way + * {@link DISJOINT_CONSTRAINT_NAME} is, through {@link assertClaimAxisSafe}). + */ +export const COMPOSITION_RELATION_NAME = `${AXIS_SEPARATOR}composition`; + +/** + * THE printable form of an acyclic relation's name, for the one place such a + * name crosses into a public field — `EdgeAcyclicityErrorDetails.relation`, + * `EdgeAcyclicityIndeterminateErrorDetails.relation`, and + * `EdgeAcyclicityViolation.relation` (which `ConstraintFenceViolation` + * carries verbatim). A standalone `acyclic: true` relation is named after its + * own edge kind (`src/store/acyclicity.ts`'s `standaloneAcyclicRelation`) and + * is already printable — `assertClaimAxisSafe` refuses any kind name + * containing {@link AXIS_SEPARATOR} — so this only ever has work to do for + * {@link COMPOSITION_RELATION_NAME} itself. + * + * The reserved prefix stays on the relation's INTERNAL name + * (`AcyclicEdgeRelation.name`, used for grouping and as this graph's one + * composition relation's identity) precisely because no real kind name can + * spell it — swapping in a printable internal name would reopen the + * collision {@link assertClaimAxisSafe} exists to close, this time against a + * user's own `acyclic: true` edge kind. Stripping it here, at display time + * only, is what keeps the raw U+001E out of a thrown message or a + * `details`/`ConstraintFenceViolation` field a caller might log, serialize, + * or compare — the same treatment {@link DISJOINT_CONSTRAINT_NAME} gets by + * never being surfaced at all (`DisjointError` names the two kinds instead). + */ +export function displayAcyclicRelationName(name: string): string { + return name.startsWith(AXIS_SEPARATOR) ? + name.slice(AXIS_SEPARATOR.length) + : name; +} + /** * THE axis a disjointness claim is written at: the registry's own canonical * pair label, prefixed so it cannot collide with a kind. @@ -123,7 +170,41 @@ function uniquenessClaimKinds( scope: UniquenessScope, registry: KindRegistry, ): readonly string[] { - return scope === "kind" ? [kind] : subClassComponent(kind, registry); + return uniquenessScopeSpansSubclasses(scope) ? + subClassComponent(kind, registry) + : [kind]; +} + +/** + * THE code-point minimum of a set of kinds — what a uniqueness axis folds + * a covered set onto. One spelling, so the live-graph claim target + * ({@link uniquenessClaimTarget}) and the ontology-tightening probe (which + * folds a proposed schema's merged subclass component onto the same axis) + * cannot compute two different minima for the same covered set. + * + * `undefined` only for an empty input, which no real caller produces: both + * `uniquenessClaimKinds` and a merged subclass component always carry at + * least the kind that anchors them. + */ +export function uniquenessAxisOfKinds( + kinds: readonly string[], +): string | undefined { + return kinds.toSorted((left, right) => + compareUniquenessAxisKinds(left, right), + )[0]; +} + +/** + * THE order a uniqueness axis is the minimum of: code-point order. The schema + * gate's "does a lost member sort before the kind?" asks this same order, so + * where claims were written and which lost members could hold them cannot + * disagree for kind names with astral characters. + */ +export function compareUniquenessAxisKinds( + left: string, + right: string, +): number { + return compareCodePoints(left, right); } /** THE decision above — the one owner of both readings. */ @@ -133,7 +214,10 @@ export function uniquenessClaimTarget( registry: KindRegistry, ): UniquenessClaimTarget { const kinds = uniquenessClaimKinds(kind, scope, registry); - return { axis: kinds[0] ?? kind, crossKind: kinds.length > 1 }; + return { + axis: uniquenessAxisOfKinds(kinds) ?? kind, + crossKind: kinds.length > 1, + }; } /** @@ -179,25 +263,42 @@ export function uniquenessProbeKinds( const coveredKinds = uniquenessClaimKinds(kind, scope, registry); const rest = coveredKinds .filter((candidate) => candidate !== axis) - .toSorted((left, right) => compareStrings(left, right)); + .toSorted((left, right) => compareUniquenessAxisKinds(left, right)); return [axis, ...rest]; } /** - * THE axis an edge cardinality claim is written at: the declared cardinality - * and the edge kind, which together name the population the constraint counts. + * The prefix marking an axis as a TARGET-side population. + * + * The reserved U+001E device the disjointness axis already uses (`:41-42`): + * a source axis is `":"` and can never begin with the + * separator, so the two namespaces cannot collide however an edge kind is + * named. Source axis strings are unchanged byte for byte — existing + * `typegraph_edge_claims` rows keep fencing what they fence today, with no + * migration. + */ +const TARGET_AXIS_PREFIX = `${AXIS_SEPARATOR}to${AXIS_SEPARATOR}`; + +/** + * THE axis an edge cardinality claim is written at: the direction, the + * declared cardinality and the edge kind, which together name the population + * the constraint counts. * - * `one` and `oneActive` on one kind are DIFFERENT axes on purpose — they count - * different populations (every live edge from a source vs every active one) — - * so a kind whose declaration changed cannot inherit rows the old declaration - * wrote. The cardinality tokens contain no `:`, so the pair is injective over - * arbitrary edge kind names. + * `one` and `oneActive` on one kind (in either direction) are DIFFERENT axes + * on purpose — they count different populations (every live edge vs every + * active one) — so a kind whose declaration changed cannot inherit rows the + * old declaration wrote. The cardinality tokens contain no `:`, so the pair is + * injective over arbitrary edge kind names. The signature takes the direction + * explicitly (rather than a bare cardinality) so every caller must say which + * endpoint's population it means; a caller that cannot say is a caller that + * was guessing. */ export function edgeCardinalityAxis( - cardinality: string, + ref: EdgeCardinalityAxisRef, edgeKind: string, ): string { - return `${cardinality}:${edgeKind}`; + const base = `${ref.cardinality}:${edgeKind}`; + return ref.direction === "source" ? base : `${TARGET_AXIS_PREFIX}${base}`; } /** The relation a claim row lives in. */ @@ -247,6 +348,25 @@ export function compareClaimTargets( ); } +/** + * A claim target keyed as ONE map entry — the identity a grouping or a + * duplicate refusal keys on, so every consumer groups the same rows together. + * {@link encodeTupleKey} rather than a delimiter join, for the reason that + * module states: a delimiter is also a legal value character. + * + * `graphId` is deliberately absent: every caller groups within one graph, and + * including it would make the key say nothing the caller does not already + * know. + */ +export function targetIdentity(target: ClaimTarget): string { + return encodeTupleKey([ + target.relation, + target.axis, + target.constraintName ?? "", + target.key, + ]); +} + /** * WHO holds a claim. A node, not an id: ids are unique only per kind, so * `(concrete_kind, node_id)` is the smallest thing that identifies a claimant. diff --git a/packages/typegraph/src/store/claims/backing.ts b/packages/typegraph/src/store/claims/backing.ts index 1c740f070..03052ad29 100644 --- a/packages/typegraph/src/store/claims/backing.ts +++ b/packages/typegraph/src/store/claims/backing.ts @@ -32,7 +32,9 @@ import { type GraphBackend } from "../../backend/types"; * that map's own keys and would be trivially true. */ export const CONSTRAINT_FENCE_REASONS = [ + "edgeAcyclicity", "edgeCardinality", + "edgeComposition", "edgeMatchKeyConvergence", "nodeDisjointness", "nodeUniquenessClaim", @@ -72,16 +74,55 @@ type ConstraintFenceBacking = "uniques" | "edgeClaims" | "lockOnly"; * declaration actually spans and the one the edges primary key `(graph_id, id)` * cannot fence. The classes still marked `lockOnly` are fenced by the per-graph * write lock alone, which is why they are also the classes a non-transactional - * backend refuses. + * backend refuses. `edgeAcyclicity` is `lockOnly` because the axis a cycle + * spans is a whole reachable subgraph, not a tuple — there is no key that + * could refuse a second claimant. */ export const CONSTRAINT_FENCE_BACKING = { + edgeAcyclicity: "lockOnly", edgeCardinality: "edgeClaims", + // The composition claim is an ordinary `typegraph_edge_claims` row + // at the reserved relation-wide axis (`COMPOSITION_RELATION_NAME`) — same + // relation, same key shape, as `edgeCardinality`. It is its own reason + // rather than folding into `edgeCardinality` so a backend that cannot hold + // the fence gets advice naming the `partOf`/`hasPart` declaration. + edgeComposition: "edgeClaims", edgeMatchKeyConvergence: "lockOnly", nodeDisjointness: "uniques", nodeUniquenessClaim: "uniques", nodeUniquenessScope: "uniques", } as const satisfies Record; +/** + * Whether a write constrained for this reason makes a decision NOTHING BUT the + * per-graph write fence protects — a read with no claim row behind it, whose + * verdict a concurrent writer of the same class can invalidate. The + * `satisfies` forces totality. + * + * Not the same question as {@link CONSTRAINT_FENCE_BACKING}: a composition + * write claims its relation-wide axis in `typegraph_edge_claims` AND reads + * what no claim records — the acyclicity of the oriented composition + * relation, and a deleted whole's parts closure. + * + * It is a statement about the reason a write NAMES, and a write names one + * reason: an edge kind that is both cardinality-constrained and acyclic names + * `edgeCardinality`, and a batch names its first constrained member's. So for + * acyclicity and composition this decides only whether a write takes the + * fence in its deciding form up front; their decision sites + * (`fenceLockOnlyDecision`) are what guarantee a decider advanced the fence + * row, whatever reason its frame named. A match-key convergence has no other + * site: it is always its frame's own named reason. + */ +export const CONSTRAINT_FENCE_DECIDES_UNDER_LOCK_ONLY = { + edgeAcyclicity: true, + edgeCardinality: false, + edgeComposition: true, + edgeMatchKeyConvergence: true, + nodeDisjointness: false, + nodeUniquenessClaim: false, + nodeUniquenessScope: false, +} as const satisfies Record; + /** The decision {@link claimSupport} returns — never a flag a caller re-derives. */ export type ClaimSupport = /** Capability declared AND every claim member present: the members, bound off the port. */ diff --git a/packages/typegraph/src/store/claims/composition-claims.ts b/packages/typegraph/src/store/claims/composition-claims.ts new file mode 100644 index 000000000..7ff68fc43 --- /dev/null +++ b/packages/typegraph/src/store/claims/composition-claims.ts @@ -0,0 +1,217 @@ +/** + * Composition claims — what a `partOf`/`hasPart` realizing edge write + * reserves, on top of whatever ordinary cardinality axis it already + * reserves. + * + * "A part holds exactly one whole" is one invariant, relation-wide: across + * EVERY declared composition pair, not one whole per realizing edge kind. It + * therefore cannot be an ordinary per-edge-kind cardinality claim — two + * different edge kinds attaching the same part must collide on one row. This + * module is the ONE owner of that extra reservation: `compositionClaim` + * decides whether one edge kind owes it, {@link edgeInsertClaims} folds it + * into the claim SET every edge insert (or re-entry) issues, and + * `compositionClaimRefusal` (`./edge-claims`, its physical home — see that + * file's docblock) is the one typed error a lost claim raises. + */ +import { + type ClaimEdgeCardinalityParams, + type CompositionClaimScope, + type EdgeCardinalityDeclaration, +} from "../../backend/types"; +import { type CompositionPartSide } from "../../registry/composition-relation"; +import { type KindRegistry } from "../../registry/kind-registry"; +import { requireDefined } from "../../utils/presence"; +import { + axisCountsRow, + axisRowMembership, + type EdgeCardinalityAxisRef, + edgeCardinalityAxisReferences, + edgeCardinalityClaims, + type EdgeCardinalityDeclarations, + type EdgeClaimSubject, + sortedByClaimTarget, +} from "./edge-claims"; + +type CompositionHolders = readonly Readonly<{ + edgeKind: string; + partSide: CompositionPartSide; +}>[]; + +const compositionHoldersCache = new WeakMap(); + +/** + * THE oriented holder list every composition claim on this graph carries as + * its `scope.holders` — one entry per realizing edge kind, tagged with which + * endpoint of that kind is the part. Memoized per registry: a registry is + * immutable once built, and every composition claim reads the same list. + */ +function compositionHolders(registry: KindRegistry): CompositionHolders { + const cached = compositionHoldersCache.get(registry); + if (cached !== undefined) return cached; + const holders = registry.compositionEdgeKinds().map((edgeKind) => ({ + edgeKind, + partSide: requireDefined( + registry.compositionPartSide(edgeKind), + `"${edgeKind}" is in compositionEdgeKinds() but has no recorded partSide`, + ), + })); + compositionHoldersCache.set(registry, holders); + return holders; +} + +/** + * THE translation of composition orientation into the vocabulary an ordinary + * edge cardinality claim already understands: the part's side is the + * DIRECTION (`"source"` when the part is `from`, `"target"` when it is + * `to`), and the whole-side population is the CARDINALITY. This is what lets + * a composition claim reuse {@link edgeCardinalitySpec}'s `keyShape` and + * `holderLiveness` with no new table: `edgeCardinalitySpec` already answers + * both for every `(direction, cardinality)` pair, composition or not. + * + * Its `holderLiveness` is the composition rule `compositionPopulationMembership` + * (`src/registry/composition-relation.ts`) states for every other reader; + * `tests/composition-row-membership.test.ts` pins the two together. + */ +function compositionAxisRef( + partSide: CompositionPartSide, + population: "one" | "oneActive", +): EdgeCardinalityAxisRef { + return partSide === "from" ? + { direction: "source", cardinality: population } + : { direction: "target", cardinality: population }; +} + +/** + * THE composition claim an edge insert owes, or `undefined` when it owes + * none (an ordinary, non-composition edge kind). + * + * Not exported beyond this module: {@link edgeInsertClaims} and + * {@link compositionReentryClaim} are the only two callers, both here. + */ +function compositionClaim( + registry: KindRegistry, + subject: EdgeClaimSubject, +): ClaimEdgeCardinalityParams | undefined { + const partSide = registry.compositionPartSide(subject.kind); + if (partSide === undefined) return undefined; + const population = requireDefined( + registry.compositionEdgePopulation(subject.kind), + `"${subject.kind}" is a composition edge kind but has no recorded composition population`, + ); + return { + ...compositionAxisRef(partSide, population), + graphId: subject.graphId, + edgeKind: subject.kind, + edgeId: subject.id, + fromKind: subject.fromKind, + fromId: subject.fromId, + toKind: subject.toKind, + toId: subject.toId, + scope: { kind: "composition", holders: compositionHolders(registry) }, + }; +} + +/** + * THE claims one edge insert owes: every declared cardinality axis, plus the + * composition claim when the edge kind realizes one, in claim order + * ({@link compareClaimTargets}) — not declaration order and not insertion + * order, so a peer taking the same two rows can never take them in the + * opposite order and deadlock. + * + * The ONE owner every insert path calls: the store's `edgeInsertWork` + * (`src/store/operations/edge-operations.ts`) and import's + * `importEdgeInsertWork` (`src/interchange/import.ts`), which before this + * both called {@link edgeCardinalityClaims} directly for the same reason. + */ +export function edgeInsertClaims( + registry: KindRegistry, + declarations: EdgeCardinalityDeclarations, + subject: EdgeClaimSubject, +): readonly ClaimEdgeCardinalityParams[] { + const ordinary = edgeCardinalityClaims( + edgeCardinalityAxisReferences(declarations), + subject, + ); + const composition = compositionClaim(registry, subject); + if (composition === undefined || !axisCountsRow(composition, subject)) { + return ordinary; + } + return sortedByClaimTarget([...ordinary, composition]); +} + +/** + * Whether an edge of this kind takes ANY claim row — the release side's + * question, answered by the same module that decides what an insert + * acquires, so a hard delete can never stop purging a row kind + * {@link edgeInsertClaims} still takes. + */ +export function edgeKindOwesAnyClaim( + registry: KindRegistry, + declarations: EdgeCardinalityDeclarations, + edgeKind: string, +): boolean { + return ( + edgeCardinalityAxisReferences(declarations).length > 0 || + registry.isCompositionEdge(edgeKind) + ); +} + +/** + * THE composition claim a RE-ENTRY (a resurrect, or a bare active-only + * window reopen) owes, mirroring the same gate the caller already applies to + * its own ordinary axes (`reentryAxisReferences` in + * `src/store/operations/edge-operations.ts`): a full resurrect re-takes + * every reservation the soft delete vacated, composition included; a window + * reopen with no delete transition never vacated a `claimsWhenBornEnded: + * true` (`one`) axis in the first place and must not re-probe it, so it + * re-takes composition only when composition's OWN population is + * `oneActive`. + * + * Either way the row must be one composition counts ({@link axisCountsRow}): + * resurrecting an ENDED `oneActive` history row attaches nothing, so it owes + * no claim and is not refused because the part holds another whole — the + * same exemption {@link edgeInsertClaims} gives the identical row born ended. + */ +export function compositionReentryClaim( + registry: KindRegistry, + subject: EdgeClaimSubject, + reentersLivePopulation: boolean, +): ClaimEdgeCardinalityParams | undefined { + const claim = compositionClaim(registry, subject); + if (claim === undefined || !axisCountsRow(claim, subject)) return undefined; + if (reentersLivePopulation) return claim; + return axisRowMembership(claim) === "openRows" ? claim : undefined; +} + +/** + * The per-edge-kind composition declaration the constraint-fence audit reads + * (`src/store/claims/verify.ts`'s `fenceDeclarations`): one entry per + * realizing edge kind, carrying the same oriented `scope` every write-path + * claim for that kind carries, so the audit and the fence read the identical + * axis and holder set. + * + * One entry per edge kind, not per declared pair: `CompositionPair.population` + * is a property of the realizing edge kind's own declaration (its + * `cardinality`/`targetCardinality`), so every pair sharing one `viaEdgeKind` + * already agrees on it — `buildCompositionRelation` refuses the graph + * otherwise (`ONTOLOGY_COMPOSITION_VIA_MIXED`). + */ +export function compositionEdgeCardinalityDeclarations( + registry: KindRegistry, +): readonly EdgeCardinalityDeclaration[] { + const holders = compositionHolders(registry); + if (holders.length === 0) return []; + const scope: CompositionClaimScope = { kind: "composition", holders }; + const seenEdgeKinds = new Set(); + const declarations: EdgeCardinalityDeclaration[] = []; + for (const pair of registry.compositionRelation().pairs) { + if (seenEdgeKinds.has(pair.viaEdgeKind)) continue; + seenEdgeKinds.add(pair.viaEdgeKind); + declarations.push({ + ...compositionAxisRef(pair.partSide, pair.population), + edgeKind: pair.viaEdgeKind, + scope, + }); + } + return declarations; +} diff --git a/packages/typegraph/src/store/claims/edge-claims.ts b/packages/typegraph/src/store/claims/edge-claims.ts index a2e12262e..d6b3c5856 100644 --- a/packages/typegraph/src/store/claims/edge-claims.ts +++ b/packages/typegraph/src/store/claims/edge-claims.ts @@ -21,14 +21,20 @@ import { type GraphBackend, type InsertEdgeParams, } from "../../backend/types"; -import { checkCardinality, checkUniqueEdge } from "../../constraints"; -import { type Cardinality } from "../../core/types"; -import { CardinalityError, ConfigurationError } from "../../errors"; +import { type Cardinality, type TargetCardinality } from "../../core/types"; +import { + CardinalityError, + CompositionError, + ConfigurationError, +} from "../../errors"; +import { type RowMembership } from "../../registry/row-membership"; +import { requireDefined } from "../../utils/presence"; import { isMissingTableError } from "../../utils/sql-errors"; import { encodeTupleKey } from "../../utils/tuple-key"; import { type ClaimTarget, compareClaimTargets, + COMPOSITION_RELATION_NAME, edgeCardinalityAxis, } from "./axis"; import { claimSupport } from "./backing"; @@ -36,14 +42,59 @@ import { claimSupport } from "./backing"; /** A cardinality that declares something — `many` declares nothing. */ export type ConstrainedCardinality = Exclude; +/** A target-side cardinality that declares something. */ +type ConstrainedTargetCardinality = Exclude; + +/** Which endpoint's population a declared cardinality bounds. */ +export type EdgeCardinalityDirection = "source" | "target"; + +/** + * ONE declared population: which endpoint it bounds and how. + * + * A discriminated pair rather than two independent fields, because + * `{direction: "target", cardinality: "unique"}` is not a population this + * library has — `unique` is a property of the endpoint PAIR, declared once + * from the source side. Making it unspellable here is what keeps every + * downstream `switch` total without a defensive arm for a state that cannot + * exist. + */ +export type EdgeCardinalityAxisRef = + | Readonly<{ direction: "source"; cardinality: ConstrainedCardinality }> + | Readonly<{ + direction: "target"; + cardinality: ConstrainedTargetCardinality; + }>; + +/** The composite key of the spec table. */ +export type EdgeCardinalityAxisName = + `source:${ConstrainedCardinality}` | `target:${ConstrainedTargetCardinality}`; + +export function edgeCardinalityAxisName( + ref: EdgeCardinalityAxisRef, +): EdgeCardinalityAxisName { + // A switch on the discriminant, not a bare template literal over + // `ref.cardinality`: read off the union, that field's type is the union of + // BOTH branches' cardinality (`unique` included), which would let the + // template literal type widen to a `"target:unique"` string this module + // must never produce. + switch (ref.direction) { + case "source": { + return `source:${ref.cardinality}`; + } + case "target": { + return `target:${ref.cardinality}`; + } + } +} + /** * What one declared cardinality means to every layer that has to agree about * it: which endpoints its axis key covers, whether an edge born already ended * claims at all, and what a holder must still BE for its claim to stand. */ export type EdgeCardinalitySpec = Readonly<{ - /** Which endpoints the axis key covers. */ - keyShape: "from" | "fromAndTo"; + /** Which endpoint columns the axis key and the liveness predicate read. */ + keyShape: "from" | "to" | "fromAndTo"; /** Whether a new edge that is born ENDED makes a claim at all. */ claimsWhenBornEnded: boolean; /** What a holder must still be for its claim to stand. */ @@ -51,36 +102,89 @@ export type EdgeCardinalitySpec = Readonly<{ }>; /** - * THE table both renderers of the cardinality predicate read: the TypeScript - * probe ({@link file://../constraints.ts checkCardinalityConstraint}) and the - * SQL takeover statement - * ({@link file://../../backend/drizzle/operations/edge-claims.ts}). + * THE table every renderer of the cardinality predicate reads: the TypeScript + * probe ({@link file://../constraints.ts checkEdgeCardinalityConstraints}), + * the SQL takeover statement + * ({@link file://../../backend/drizzle/operations/edge-claims.ts}), and the + * fence audit + * ({@link file://../../backend/drizzle/operations/constraint-fence-audit.ts}). * - * Two renderers, one table, exhaustive by type: a new cardinality cannot be - * added without stating all three facts, and the probe and the fence cannot - * disagree about any of them — a disagreement is exactly the shape where the - * probe accepts a write the fence then refuses forever (or the reverse). + * Three renderers, one table, exhaustive by type: a new cardinality (on + * either side) cannot be added without stating all three facts, and no + * renderer can disagree about any of them — a disagreement is exactly the + * shape where the probe accepts a write the fence then refuses forever (or + * the reverse). */ -export const EDGE_CARDINALITY_SPECS = { - one: { keyShape: "from", claimsWhenBornEnded: true, holderLiveness: "live" }, - unique: { +const EDGE_CARDINALITY_SPECS = { + "source:one": { + keyShape: "from", + claimsWhenBornEnded: true, + holderLiveness: "live", + }, + "source:unique": { keyShape: "fromAndTo", claimsWhenBornEnded: true, holderLiveness: "live", }, - oneActive: { + "source:oneActive": { keyShape: "from", claimsWhenBornEnded: false, holderLiveness: "liveAndActive", }, -} as const satisfies Record; + "target:one": { + keyShape: "to", + claimsWhenBornEnded: true, + holderLiveness: "live", + }, + "target:oneActive": { + keyShape: "to", + claimsWhenBornEnded: false, + holderLiveness: "liveAndActive", + }, +} as const satisfies Record; -/** Every axis an edge kind's claims can sit on, for housekeeping reaps. */ +export function edgeCardinalitySpec( + ref: EdgeCardinalityAxisRef, +): EdgeCardinalitySpec { + return EDGE_CARDINALITY_SPECS[edgeCardinalityAxisName(ref)]; +} + +/** + * Every ordinary cardinality axis this library has, as the refs themselves — + * the same fixed set {@link EDGE_CARDINALITY_SPECS} is keyed by, so a caller + * that needs the axes rather than their specs never parses a spec key back + * into the ref it was built from. + */ +const EDGE_CARDINALITY_AXIS_REFS = [ + { direction: "source", cardinality: "one" }, + { direction: "source", cardinality: "unique" }, + { direction: "source", cardinality: "oneActive" }, + { direction: "target", cardinality: "one" }, + { direction: "target", cardinality: "oneActive" }, +] as const satisfies readonly EdgeCardinalityAxisRef[]; + +/** + * Every axis an edge kind's claims can sit on, for housekeeping reaps. + * + * Deliberately NOT extended to the reserved composition axis + * ({@link COMPOSITION_RELATION_NAME}): unlike the five ordinary axes here, + * that one is relation-wide rather than per-edge-kind, so sweeping it on + * every removed edge kind would reap OTHER, still-live composition edge + * kinds' rows too — over-broad in a way this function's callers (kind + * removal) do not expect and this module's own zero-behavior-change golden + * snapshot (`tests/claim-owner-sql-golden.test.ts`) pins against. Safe + * either way — the composition claim's fence is a live ENTITY-relation + * check (`competingLiveEdgePredicate`), never the claim row's mere + * existence — but reaping a removed COMPOSITION edge kind's own rows + * specifically is left as a known gap rather than widening this shared + * function; `purgeEdgeClaims` (edge-id-keyed, axis-agnostic) already reaps + * them correctly whenever the holding edges themselves are hard-deleted. + */ export function edgeCardinalityAxesForKind( edgeKind: string, ): readonly string[] { - return Object.keys(EDGE_CARDINALITY_SPECS).map((cardinality) => - edgeCardinalityAxis(cardinality, edgeKind), + return EDGE_CARDINALITY_AXIS_REFS.map((ref) => + edgeCardinalityAxis(ref, edgeKind), ); } @@ -92,18 +196,40 @@ export function edgeCardinalityAxesForKind( * arbitrary caller data (only kind names pass `assertClaimAxisSafe`), and a * delimiter that a value may contain makes two different endpoint tuples * collapse onto one key — which would refuse a write no constraint forbids. + * + * **Reservation scope, as the issue specifies it**: `axis` carries the edge + * kind and the direction, `key` carries the constrained endpoint's identity + * only. A target claim's key never mentions the source, so two permitted + * source kinds contend for one target allowance by construction, and a target + * claim's axis never mentions the opposite endpoint's kind, so an edge kind's + * source and target populations cannot collide even when a node id repeats + * under different kinds. + * + * **Composition (`params.scope`)**: the axis is the one reserved, + * relation-wide string instead of `:` — one whole per + * part is a single invariant over every declared `partOf`/`hasPart` pair, so two different + * realizing edge kinds must collide on one row. The key is unaffected: it is + * still whichever endpoint {@link edgeCardinalitySpec}'s `keyShape` names, + * which for a composition claim is always the PART endpoint (`direction: + * "source"` when the part is `from`, `"target"` when it is `to`), so a + * `from`-side pair and a `to`-side pair attaching the same part still compute + * the same key. */ export function edgeCardinalityClaimTarget( params: ClaimEdgeCardinalityParams, ): ClaimTarget { - const spec = EDGE_CARDINALITY_SPECS[params.cardinality]; + const spec = edgeCardinalitySpec(params); return { relation: "edgeClaims", graphId: params.graphId, - axis: edgeCardinalityAxis(params.cardinality, params.edgeKind), + axis: + params.scope === undefined ? + edgeCardinalityAxis(params, params.edgeKind) + : COMPOSITION_RELATION_NAME, key: spec.keyShape === "from" ? encodeTupleKey([params.fromKind, params.fromId]) + : spec.keyShape === "to" ? encodeTupleKey([params.toKind, params.toId]) : encodeTupleKey([ params.fromKind, params.fromId, @@ -126,86 +252,340 @@ export type EdgeClaimSubject = Pick< > & Readonly<{ validTo?: string }>; +/** The two options a registration may declare, narrowed to what decides. */ +export type EdgeCardinalityDeclarations = Readonly<{ + cardinality?: Cardinality; + targetCardinality?: TargetCardinality; +}>; + +/** + * THE axes one edge kind's declaration constrains, in probe order. + * + * Every layer that asks "is this write constrained, and by what?" folds + * through here: the write-time probe, the claim set, the pending-batch + * accounting, the write-fence predicate, the fast-path eligibility gates, the + * fence-audit declarations and the schema-tightening probe. A layer that + * re-derived the answer from `cardinality !== "many"` would be blind to a + * target-only declaration — which is the exact silent bypass this fold exists + * to make impossible. + * + * Source before target: when a write violates both, the refusal a caller sees + * names the source axis, which is the refusal it sees today. + */ +export function edgeCardinalityAxisReferences( + declarations: EdgeCardinalityDeclarations, +): readonly EdgeCardinalityAxisRef[] { + const references: EdgeCardinalityAxisRef[] = []; + const cardinality = declarations.cardinality ?? "many"; + if (cardinality !== "many") { + references.push({ direction: "source", cardinality }); + } + const targetCardinality = declarations.targetCardinality ?? "many"; + if (targetCardinality !== "many") { + references.push({ direction: "target", cardinality: targetCardinality }); + } + return references; +} + /** - * THE claim an edge insert owes, or `undefined` when it owes none. + * THE axes, of {@link edgeCardinalityAxisReferences}, that count only the + * ACTIVE population — an edge born already ended never joins one of these + * (`claimsWhenBornEnded` is false), and a live row leaves one the moment it + * stops being active (deleted, or its `validTo` set), with no delete + * transition required. + * + * The one owner of "does this declaration carry an active-only axis?" for + * every reader that gates a reentry decision on it: the update path's + * resurrection/window-reopen probe and claim set, and the write-fence + * eligibility gate that decides whether clearing `validTo` needs a per-graph + * lock at all. A second inline spelling of this filter is exactly the drift + * Contract Discipline's "one predicate, one owner" rule exists to prevent — + * one copy narrowing the trigger or the other widening the fence would + * silently split what must stay one decision. + */ +export function activeOnlyAxisReferences( + declarations: EdgeCardinalityDeclarations, +): readonly EdgeCardinalityAxisRef[] { + return edgeCardinalityAxisReferences(declarations).filter( + (ref) => axisRowMembership(ref) === "openRows", + ); +} + +/** + * Claim targets in {@link compareClaimTargets} order — the canonical claim + * order, and the reason claim order has one owner: claim order IS lock order, + * so a caller that assembles its own claim set sorts it through this function + * rather than re-spelling the map/sort/map fold inline. + */ +export function sortedByClaimTarget( + claims: readonly ClaimEdgeCardinalityParams[], +): readonly ClaimEdgeCardinalityParams[] { + return claims + .map((claim) => ({ claim, target: edgeCardinalityClaimTarget(claim) })) + .toSorted((left, right) => compareClaimTargets(left.target, right.target)) + .map((entry) => entry.claim); +} + +/** + * The membership rule of one cardinality axis, read off + * {@link edgeCardinalitySpec}'s `holderLiveness`: an active-only axis + * (`oneActive`) counts open rows, every other axis counts a row for its + * whole life. The claim takeover SQL and the fence audit render the same + * `holderLiveness` fact. + */ +export function axisRowMembership(ref: EdgeCardinalityAxisRef): RowMembership { + return edgeCardinalitySpec(ref).holderLiveness === "liveAndActive" ? + "openRows" + : "allRows"; +} + +/** + * THE membership predicate: whether a non-deleted row with this `validTo` is + * a member of a population whose rule is `membership`. + * + * Every consumer that decides membership in JavaScript calls this with the + * rule of the population it asks about: the claim set + * ({@link axisCountsRow}), the composition cascade's closure + * (`compositionEdgeCounts`), the acyclicity relations' proposed and retired + * rows (`acyclicRelationCountsRow`, `src/store/acyclicity.ts`). It has two + * SQL renderings: `compileEdgeMembershipFilters` + * (`src/query/compiler/temporal.ts`), for a relation of several edge kinds + * each under its own rule at a read coordinate — the acyclicity walk, the + * composition closure and `parts()`/`wholes()` — and `holderLivenessTerm` + * (`src/backend/drizzle/operations/edge-claims.ts`), for one cardinality + * axis's holders in the claim and fence-audit statements. + */ +export function membershipCountsRow( + membership: RowMembership, + row: Readonly<{ validTo?: string | undefined }>, +): boolean { + return membership === "allRows" || row.validTo === undefined; +} + +/** + * Whether moving a population from rule `prior` to rule `next` admits rows + * the prior rule left out: ended rows that start counting when an + * open-rows population becomes an all-rows one. A schema commit that widens + * a population this way can turn rows that were legal under `prior` into a + * violation, so it owes a data check over the population. + */ +export function rowMembershipWidens( + prior: RowMembership, + next: RowMembership, +): boolean { + return prior === "openRows" && next === "allRows"; +} + +/** + * Whether a row with this validity window joins the population `ref` counts. + * + * A row born already ended joins no ACTIVE population, so an active-only axis + * (`oneActive`, `claimsWhenBornEnded: false`) neither probes nor claims it; + * every other axis counts a row for its whole life. THE exemption every + * claim-issuing path reads — ordinary axes, the composition axis, a create + * and a re-entry alike — so "probed but unclaimed" (a silent hole) and + * "claimed but unprobed" (a refusal with no matching state) cannot arise from + * two paths spelling it differently. {@link membershipCountsRow} applied to + * the axis's own rule. + */ +export function axisCountsRow( + ref: EdgeCardinalityAxisRef, + row: Readonly<{ validTo?: string | undefined }>, +): boolean { + return membershipCountsRow(axisRowMembership(ref), row); +} + +/** + * THE claims an edge write owes, in CLAIM order. + * + * Takes the AXIS LIST rather than the raw {@link EdgeCardinalityDeclarations} — a caller + * that re-admits a row to only PART of its declaration (a window reopen with + * no delete transition re-admits just the active-only axes; see + * {@link file://../operations/edge-operations.ts}'s reentry branch) claims + * exactly what it probed, never a superset. + * + * Claim order is {@link compareClaimTargets}, not probe order: two writers + * that take the same two axis rows must take them in the same order or they + * deadlock against each other, and the source and target axes of one edge + * kind are two rows in one relation. Probe order (source first) is a + * diagnostic choice and lives in {@link edgeCardinalityAxisReferences}; lock order + * is a correctness one and lives here. The two are deliberately different and + * that difference is the reason both are named. * * `many` declares no constraint, and an `oneActive` edge born already ended - * joins no active population — `claimsWhenBornEnded` is the one place that - * second exemption is written down, and the probe reads the same field, so the - * two cannot drift into "probed but unclaimed" (a silent hole) or "claimed but - * unprobed" (a refusal with no matching error). + * joins no active population — {@link axisCountsRow} owns that second + * exemption, per AXIS. */ -export function edgeCardinalityClaim( - cardinality: Cardinality, +export function edgeCardinalityClaims( + axisReferences: readonly EdgeCardinalityAxisRef[], subject: EdgeClaimSubject, -): ClaimEdgeCardinalityParams | undefined { - if (cardinality === "many") return undefined; - if ( - !EDGE_CARDINALITY_SPECS[cardinality].claimsWhenBornEnded && - subject.validTo !== undefined - ) { - return undefined; - } - return { - graphId: subject.graphId, - cardinality, - edgeKind: subject.kind, - edgeId: subject.id, +): readonly ClaimEdgeCardinalityParams[] { + const claims = axisReferences + .filter((ref) => axisCountsRow(ref, subject)) + .map((ref): ClaimEdgeCardinalityParams => ({ + ...ref, + graphId: subject.graphId, + edgeKind: subject.kind, + edgeId: subject.id, + fromKind: subject.fromKind, + fromId: subject.fromId, + toKind: subject.toKind, + toId: subject.toId, + })); + return sortedByClaimTarget(claims); +} + +/** + * THE cardinality refusal. Every probe and every fence refusal is this + * function, so the fence's error is `instanceof` the same class and carries + * the same payload as the probe's for the same violation. + * + * `existingCount` is already the population the axis counts (the from-count, + * the to-count, or the 0/1 pair-existence read) — every constrained + * cardinality refuses on exactly "the population is non-empty", so one + * comparison serves `one`, `oneActive` and `unique` alike; the difference + * between them lives entirely in how the caller computed `existingCount`. + */ +export function edgeCardinalityViolation( + ref: EdgeCardinalityAxisRef, + subject: EdgeCardinalityViolationSubject, + existingCount: number, +): CardinalityError | undefined { + if (existingCount <= 0) return undefined; + return new CardinalityError({ + edgeKind: subject.edgeKind, + direction: ref.direction, fromKind: subject.fromKind, fromId: subject.fromId, toKind: subject.toKind, toId: subject.toId, - }; + cardinality: ref.cardinality, + existingCount, + }); } +/** The endpoint identity {@link edgeCardinalityViolation} names in its refusal. */ +export type EdgeCardinalityViolationSubject = Readonly<{ + edgeKind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}>; + /** - * The refusal a lost claim raises — built by the SAME owners the probe calls, - * so the fence's error is `instanceof` the same class and carries the same - * payload as the probe's for the same violation (I3). - */ -/** - * Builds the public cardinality refusal for a claim decision. + * Builds the public cardinality refusal for a lost claim. * * Both the ordinary claim path and the fused claim-plus-edge path call this - * owner. Keeping the translation here prevents a backend result discriminator - * from growing a second spelling of the same typed error. + * owner (through {@link claimRefusalFor}, the only caller). Keeping the + * translation here prevents a backend result discriminator from growing a + * second spelling of the same typed error. */ -export function edgeCardinalityClaimRefusal( +function edgeCardinalityClaimRefusal( params: ClaimEdgeCardinalityParams, ): CardinalityError { - const error = - params.cardinality === "unique" ? - checkUniqueEdge( - params.edgeKind, - params.fromKind, - params.fromId, - params.toKind, - params.toId, - 1, - ) - : checkCardinality( - params.edgeKind, - params.fromKind, - params.fromId, - params.cardinality, - 1, - true, - ); - // Both owners refuse for an incumbent count of one on every constrained - // cardinality; the fallback keeps the return type honest without inventing a - // second spelling of the message. - return ( - error ?? - new CardinalityError({ - edgeKind: params.edgeKind, - fromKind: params.fromKind, - fromId: params.fromId, - cardinality: params.cardinality, - existingCount: 1, - }) + return requireDefined( + edgeCardinalityViolation(params, params, 1), + "An incumbent count of one violates every constrained cardinality.", ); } +/** + * Builds the public composition refusal for a lost composition claim. + * + * Physically beside {@link edgeCardinalityClaimRefusal} rather than in + * `composition-claims.ts` (its documented home): both read the same + * {@link edgeCardinalitySpec} table to name the PART side, and + * `composition-claims.ts` already imports from this module — a back-import + * here would be a value-level cycle, the exact shape `axis.ts`'s docblock + * records avoiding for its own type-only back-edge onto this module. Not + * exported: {@link claimRefusalFor}, in this same module, is the only + * caller. + * + * `incumbentEdgeId` is the one fact a caller can actually have in hand: the + * claim statement's own `holder_edge_id` (or, for an in-batch collision, the + * earlier claim in the same batch). It is never the incumbent WHOLE's + * identity — reading that would be a second query this refusal path does not + * make — so `CompositionErrorDetails` names the edge, not the whole. + */ +function compositionClaimRefusal( + params: ClaimEdgeCardinalityParams, + incumbentEdgeId?: string, +): CompositionError { + const { part, whole } = compositionClaimEndpoints(params); + return new CompositionError({ + situation: "occupied", + partKind: part.kind, + partId: part.id, + wholeKind: whole.kind, + wholeId: whole.id, + edgeKind: params.edgeKind, + ...(incumbentEdgeId === undefined ? {} : { incumbentEdgeId }), + }); +} + +/** + * Which endpoint of a composition claim's edge is the part and which the + * whole. A composition claim keys on its PART endpoint + * ({@link edgeCardinalityClaimTarget}), so the claim's own `keyShape` is the + * orientation: the refusal names both sides from it, and the portable probe + * (`checkEdgeCardinalityConstraints`, `../constraints.ts`) counts the part's + * attachments from it. + */ +export function compositionClaimEndpoints( + params: ClaimEdgeCardinalityParams, +): Readonly<{ + part: Readonly<{ kind: string; id: string }>; + whole: Readonly<{ kind: string; id: string }>; +}> { + const from = { kind: params.fromKind, id: params.fromId }; + const to = { kind: params.toKind, id: params.toId }; + return edgeCardinalitySpec(params).keyShape === "from" ? + { part: from, whole: to } + : { part: to, whole: from }; +} + +/** + * Claims in PROBE order: the composition claim first, then source before + * target. The diagnostic counterpart of {@link sortedByClaimTarget}'s lock + * order — when a write violates several axes, the refusal a caller sees names + * the widest invariant first ("this part already has a whole") and then the + * source axis, whichever backend path decided it. + */ +export function claimsInProbeOrder( + claims: readonly ClaimEdgeCardinalityParams[], +): readonly ClaimEdgeCardinalityParams[] { + const rank = (claim: ClaimEdgeCardinalityParams): number => + claim.scope === undefined ? + claim.direction === "source" ? + 1 + : 2 + : 0; + return claims.toSorted((left, right) => rank(left) - rank(right)); +} + +/** + * THE claim refusal for a lost claim, of either shape. Every claim-issuing + * call site raises through here rather than choosing between + * {@link edgeCardinalityClaimRefusal} and {@link compositionClaimRefusal} + * itself, so a caller cannot forget the composition arm the way a second + * inline `claim.scope !== undefined` spelling could. + * + * `incumbentEdgeId` (when the caller has one — a claim statement's returned + * holder, or an in-batch collision's earlier claim) is forwarded only to the + * composition arm: `CardinalityError` names no such field, and inventing one + * here would be exactly the fabricated-value-a-reader-has-to-check-is-unread + * shape this module's own `EdgeClaimSubject` docblock warns against. + */ +export function claimRefusalFor( + params: ClaimEdgeCardinalityParams, + incumbentEdgeId?: string, +): CardinalityError | CompositionError { + return params.scope === undefined ? + edgeCardinalityClaimRefusal(params) + : compositionClaimRefusal(params, incumbentEdgeId); +} + /** * Converts the engine's "relation does not exist" into a typed precondition * error naming the relation and the way to create it. @@ -224,7 +604,10 @@ export function edgeClaimRelationMissing( "Enforcing a declared edge cardinality needs the edge claim relation " + "(typegraph_edge_claims), and this database does not have it. " + "Databases initialized before this relation existed were never sent " + - "its CREATE TABLE, because the bootstrap DDL runs only on first boot.", + "its CREATE TABLE, because the bootstrap DDL runs only on first boot. " + + "A composition edge (`partOf`/`hasPart`) rides the same relation: a " + + "deployment initialized before it existed must be migrated under " + + "owner credentials before declaring `partOf`.", { code: "EDGE_CLAIM_RELATION_MISSING", graphId }, { cause, @@ -232,7 +615,10 @@ export function edgeClaimRelationMissing( "Run the generated migration SQL (generatePostgresMigrationSQL / " + "generateSqliteMigrationSQL) against this database, or declare the " + 'edge kind `cardinality: "many"` and enforce the limit in ' + - "application code.", + "application code — or, for a composition edge kind, drop the " + + "`partOf`/`hasPart` declaration it realizes, since a composition " + + 'edge cannot take the `cardinality: "many"` escape (it always ' + + "declares a constrained whole-side cardinality).", }, ); } @@ -296,7 +682,7 @@ export function edgeCardinalityClaimMode( * declared gap with a parity-matrix row, never a silent unfencing and never a * new refusal. */ -export async function claimEdgeCardinality( +async function claimEdgeCardinality( backend: ClaimTargetMembers, verdict: BundleVerdictOf, claim: ClaimEdgeCardinalityParams, @@ -307,7 +693,7 @@ export async function claimEdgeCardinality( mode.claim(claim), ); if (outcome.status === "refused") { - throw edgeCardinalityClaimRefusal(claim); + throw claimRefusalFor(claim, outcome.holderEdgeId); } return; } @@ -316,7 +702,28 @@ export async function claimEdgeCardinality( const outcome = await withEdgeClaimRelationPrecondition(claim.graphId, () => support.claims.claimEdgeCardinality(claim), ); - if (outcome.status === "refused") throw edgeCardinalityClaimRefusal(claim); + if (outcome.status === "refused") { + throw claimRefusalFor(claim, outcome.holderEdgeId); + } +} + +/** + * Issues every claim ONE edge write owes, in the order {@link + * edgeCardinalityClaims} already sorted them (claim order, not probe order). + * + * Every single-write call site issues its claim set through here rather than + * calling {@link claimEdgeCardinality} directly — the fold is what makes a + * two-axis declaration (source AND target) reserve both rows instead of + * whichever one a call site happened to spell. + */ +export async function claimEdgeCardinalities( + backend: ClaimTargetMembers, + verdict: BundleVerdictOf, + claims: readonly ClaimEdgeCardinalityParams[], +): Promise { + for (const claim of claims) { + await claimEdgeCardinality(backend, verdict, claim); + } } /** @@ -337,19 +744,15 @@ export async function claimEdgeCardinalityBatch( if (claims.length === 0) return; const support = claimSupport(backend, verdict); if (!support.supported) return; - const ordered = claims - .map((claim) => ({ claim, target: edgeCardinalityClaimTarget(claim) })) - .toSorted((left, right) => compareClaimTargets(left.target, right.target)); - const graphId = ordered[0]?.claim.graphId ?? ""; + const ordered = sortedByClaimTarget(claims); + const graphId = ordered[0]?.graphId ?? ""; const outcomes = await withEdgeClaimRelationPrecondition(graphId, () => - support.claims.claimEdgeCardinalityBatch( - ordered.map((entry) => entry.claim), - ), + support.claims.claimEdgeCardinalityBatch(ordered), ); for (const [index, outcome] of outcomes.entries()) { - const entry = ordered[index]; - if (entry !== undefined && outcome.status === "refused") { - throw edgeCardinalityClaimRefusal(entry.claim); + const claim = ordered[index]; + if (claim !== undefined && outcome.status === "refused") { + throw claimRefusalFor(claim, outcome.holderEdgeId); } } } diff --git a/packages/typegraph/src/store/claims/node-claims.ts b/packages/typegraph/src/store/claims/node-claims.ts index 1353b1dc4..153ed6a87 100644 --- a/packages/typegraph/src/store/claims/node-claims.ts +++ b/packages/typegraph/src/store/claims/node-claims.ts @@ -1054,8 +1054,8 @@ export type UniquenessUpdatePlan = readonly PendingUniqueMutation[]; * * @throws UniquenessError if an updated value is already held by another node */ -export async function planNodeClaimUpdate( - ctx: UniquenessContext, +async function planNodeClaimUpdate( + ctx: UniquenessProbeContext, kind: string, id: string, oldProps: Record, @@ -1138,7 +1138,7 @@ export async function planNodeClaimUpdate( * itself reports) */ export async function planNodeClaimReinsert( - ctx: UniquenessContext, + ctx: UniquenessProbeContext, kind: string, id: string, props: Record, @@ -1167,6 +1167,122 @@ export async function planNodeClaimReinsert( return pending; } +/** + * One existing node row's claim transition: the props its live reservations + * were taken for, and the props it is about to hold. + * + * `heldProps` is `undefined` for a TOMBSTONE, which holds no reservation — the + * soft delete released them — and so cannot be diffed. + */ +export type NodeClaimTransition = Readonly<{ + kind: string; + id: string; + heldProps: Record | undefined; + props: Record; + constraints: readonly UniqueConstraint[]; +}>; + +/** + * Decides one existing row's claim transition, writing nothing. + * + * Two shapes behind one seam. A live row diffs its old and new keys + * ({@link planNodeClaimUpdate}); a restored tombstone cannot, because the diff + * would skip an unchanged key and leave the revived node holding NO + * reservation, so it re-reserves every applying key + * ({@link planNodeClaimReinsert}). Both refuse a conflict before returning, + * and both hand the caller a plan only {@link withNodeClaimTransition} may + * carry out. + */ +export function planNodeClaimTransition( + ctx: UniquenessProbeContext, + transition: NodeClaimTransition, +): Promise { + const { kind, id, heldProps, props, constraints } = transition; + return heldProps === undefined ? + planNodeClaimReinsert(ctx, kind, id, props, constraints) + : planNodeClaimUpdate(ctx, kind, id, heldProps, props, constraints); +} + +/** + * The READ half of a SET of row updates' claim transitions: every uniqueness + * refusal any of them can reach, with nothing written — so a frame that + * updates several rows refuses before its first statement. + * + * The set is judged in order, as the state it produces rather than one row at + * a time against the stored claims alone: a later row's probe does not see a + * key an earlier row of the set gives up, and does see a key an earlier row + * takes. Each row's verdict is {@link planNodeClaimTransition}'s own, reached + * through a probe that reads the stored claims with the earlier rows' + * decided transitions laid over them. + * + * @throws UniquenessError when a row would hold a key that a node outside the + * set, or an earlier row of it, holds + */ +export async function assertNodeClaimTransitionsAvailable( + ctx: UniquenessProbeContext, + transitions: readonly NodeClaimTransition[], +): Promise { + const released = new Set(); + const claimed = new Map(); + const pendingContext: UniquenessProbeContext = { + ...ctx, + backend: { + checkUnique: async (params) => { + const pending = claimed.get( + encodeTupleKey([params.nodeKind, params.constraintName, params.key]), + ); + if (pending !== undefined) return pending; + const stored = await ctx.backend.checkUnique(params); + if (stored === undefined) return; + // A release gives up every reservation its owner holds for the key, + // whatever axis the row sits on (see `releaseOwnedUniqueKeys`). + return ( + released.has( + encodeTupleKey([ + stored.constraint_name, + stored.key, + stored.concrete_kind, + stored.node_id, + ]), + ) + ) ? + undefined + : stored; + }, + }, + }; + + for (const transition of transitions) { + const { kind, id } = transition; + for (const mutation of await planNodeClaimTransition( + pendingContext, + transition, + )) { + if (mutation.release !== undefined) { + released.add( + encodeTupleKey([mutation.constraintName, mutation.release, kind, id]), + ); + } + // A disjointness claim's key is this row's own id at its pair's axis: + // registered like any other, it can only ever answer this row. + const claim = mutation.claim; + if (claim === undefined) continue; + claimed.set( + encodeTupleKey([claim.axis, claim.constraintName, claim.key]), + { + graph_id: ctx.graphId, + node_kind: claim.axis, + constraint_name: claim.constraintName, + key: claim.key, + node_id: id, + concrete_kind: kind, + deleted_at: undefined, + }, + ); + } + } +} + /** One reservation a release names. */ type ReleasableKey = Readonly<{ constraintName: string; key: string }>; diff --git a/packages/typegraph/src/store/claims/resolved-node-claims.ts b/packages/typegraph/src/store/claims/resolved-node-claims.ts index 7ce1a1625..ab72630a3 100644 --- a/packages/typegraph/src/store/claims/resolved-node-claims.ts +++ b/packages/typegraph/src/store/claims/resolved-node-claims.ts @@ -434,7 +434,7 @@ async function prepareResolvedNodeClaims( * It goes through {@link withNodeCreateClaimsBatch} — the claim writer every * create-shaped write uses — and not through a uniqueness-only insert, for a * reason the preparation step makes load-bearing: `hardDeleteUniquesByNodeIds` - * clears every claim the affected nodes OWN, and after WS2 that includes their + * clears every claim the affected nodes OWN, and that includes their * `disjointWith` reservations. A rebuild that restored only the uniqueness * family would leave each merged node unfenced against a disjoint namesake for * the rest of the graph's life. The claim writer restores what diff --git a/packages/typegraph/src/store/claims/verify.ts b/packages/typegraph/src/store/claims/verify.ts index ece165f06..41d4e50bc 100644 --- a/packages/typegraph/src/store/claims/verify.ts +++ b/packages/typegraph/src/store/claims/verify.ts @@ -17,21 +17,50 @@ * {@link edgeCardinalityClaimTarget} — so a report row names the row a writer * would actually contend for. A second spelling here would produce a report * about axes the fence does not use. + * + * `auditConstraintFences` is the reader: declarations in, violations out. It + * is the ONE implementation of every violation predicate, shared by + * `verifyConstraintFences` (the graph-wide audit below) and by the + * schema-tightening commit preflight (`src/schema/tightening-preflight.ts`), + * so a live-graph audit and a proposed-schema probe can never disagree about + * what counts as a violation. */ +import type { BATCH_POINT_READ } from "../../backend/capabilities/bundle-registry"; +import { type BundleVerdictOf } from "../../backend/capabilities/resolve"; import type { ConstraintFenceViolationRows, ContendedEdgeRow, ContendedUniqueRow, DisjointOverlapRow, EdgeCardinalityDeclaration, + EdgeEndpointAllowance, GraphBackend, + GraphReadBackend, + MisassignedEdgeEndpointRow, ReadConstraintFenceViolationsParams, } from "../../backend/types"; import { subClassComponent } from "../../constraints"; import { type GraphDef } from "../../core/define-graph"; +import { uniquenessScopeSpansSubclasses } from "../../core/unique-constraint-options"; import { ConfigurationError } from "../../errors"; +import { createSqlSchema } from "../../query/compiler/schema"; +import { getDialect } from "../../query/dialect"; +import { buildGraphEdgeKindFacts } from "../../registry/builders"; +import { expandEdgeEndpointAllowance } from "../../registry/edge-endpoint-allowance"; import { type KindRegistry } from "../../registry/kind-registry"; +import { groupBy } from "../../utils/array"; import { compareStrings } from "../../utils/compare"; +import { nowIso } from "../../utils/date"; +import { + acyclicEdgeRelations, + type EdgeAcyclicityViolation, + readEdgeAcyclicityViolations, +} from "../acyclicity"; +import { + readCompositionFutureWindowEdges, + readCompositionUnattachedParts, + requiredCompositionPartKinds, +} from "../operations/composition-create"; import { type ClaimOwner, type ClaimTarget, @@ -39,18 +68,28 @@ import { DISJOINT_CONSTRAINT_NAME, disjointnessClaimAxis, isSameClaimOwner, + targetIdentity, + uniquenessAxisOfKinds, uniquenessClaimTarget, } from "./axis"; -import { edgeCardinalityClaimTarget } from "./edge-claims"; +import { compositionEdgeCardinalityDeclarations } from "./composition-claims"; +import { + edgeCardinalityAxisReferences, + edgeCardinalityClaimTarget, +} from "./edge-claims"; /** - * One claim axis more than one live claimant holds. + * One claim axis more than one live claimant holds, OR one edge kind whose + * live edges sit outside every declared endpoint pair. * - * Discriminated on the family because the two claim relations record their + * Discriminated on the family because the claim relations record their * holders differently: a `uniques` axis is held by an OWNER PAIR (ids are - * unique only per kind), an edge-claim axis by an edge id. `target` is the - * claim row itself, so a reader can go straight to the row a writer contends - * for rather than reconstructing it from the family's own vocabulary. + * unique only per kind), an edge-claim axis by an edge id, and endpoint + * assignability by nothing claim-shaped at all — there is no claim row for + * "this edge's endpoints are still admitted", so this member carries no + * `target`. `target` on the other members is the claim row itself, so a + * reader can go straight to the row a writer contends for rather than + * reconstructing it from the family's own vocabulary. */ export type ConstraintFenceViolation = | Readonly<{ @@ -62,7 +101,57 @@ export type ConstraintFenceViolation = family: "edgeCardinality"; target: ClaimTarget; edgeIds: readonly string[]; - }>; + }> + | Readonly<{ + /** + * Two or more live edges — of any realizing kind, in either + * orientation — hold the SAME part's reserved composition axis. Its + * own family rather than folding into `edgeCardinality`, even though + * the row shape is identical, because the axis it names is the + * relation-wide one, not a per-edge-kind one, and a caller branching on + * `family` should not have to inspect `target.axis` to tell them apart. + */ + family: "composition"; + target: ClaimTarget; + edgeIds: readonly string[]; + }> + | Readonly<{ + /** + * One or more LIVE nodes of a required-existence composition part kind + * currently have no live whole. Its own family — the same reason + * `composition` splits off `edgeCardinality`: a caller + * branching on `family` should not have to inspect the target to tell + * "two wholes" (`composition`) from "no whole" (this). There is no + * `ClaimTarget` and no `edgeIds`: the violation is the ABSENCE of an + * edge row, not a contended one. + */ + family: "compositionExistence"; + partKind: string; + parts: readonly Readonly<{ kind: string; id: string }>[]; + }> + | Readonly<{ + /** + * Non-deleted rows of a composition realizing edge kind whose window + * starts or ends later than the audit's clock — a window no write may + * state (`COMPOSITION_VALIDITY_FUTURE_START` / `_END`). Composition + * membership is decided from the row alone on the premise that no such + * window exists, so each of these rows would join or leave a part's + * whole at an instant no claim, cascade or audit observes. Untargeted + * like `compositionExistence`: no claim row names the window. + */ + family: "compositionFutureWindow"; + edgeKind: string; + edgeIds: readonly string[]; + }> + | Readonly<{ + family: "edgeEndpointAssignability"; + edgeKind: string; + /** The concrete endpoint pairs the declaration still admits. */ + allowedPairs: readonly (readonly [string, string])[]; + /** Live edges sitting outside all of them. */ + edges: readonly MisassignedEdgeEndpointRow[]; + }> + | EdgeAcyclicityViolation; /** What the audit needs to know: the graph, its registry, and where to read. */ export type VerifyConstraintFencesContext = Readonly<{ @@ -70,6 +159,16 @@ export type VerifyConstraintFencesContext = Readonly<{ registry: KindRegistry; graphId: string; backend: GraphBackend; + /** + * The store's own `batchPointRead` verdict, minted once at construction and + * threaded here rather than re-resolved: the `compositionExistence` family + * reads each page's whole rows through it. It is BOUND against the backend + * this audit actually reads from (`bindExtraIfReachable`, inside + * `readLiveCompositionWholes`), so a target that implements less than the + * object the verdict was resolved against falls back to the per-id read the + * bundle declares instead of reaching a member it lacks. + */ + batchPointRead: BundleVerdictOf; }>; /** @@ -81,46 +180,82 @@ export type VerifyConstraintFencesContext = Readonly<{ * version writes at. Without it the report would group a pre-upgrade duplicate * into two groups of one and find nothing. */ -type UniquenessAxisGroup = Readonly<{ +export type UniquenessAxisGroup = Readonly<{ constraintName: string; axis: string; - coveredKinds: ReadonlySet; + coveredKinds: readonly string[]; }>; +/** + * THE fold from "a constraint name plus the kinds its scope covers" onto a + * claim axis. The graph-side {@link uniquenessAxisGroups} and the + * serialized-schema-side ontology-tightening probe both build a + * `UniquenessAxisGroup` through this one constructor, so they cannot disagree + * about which axis a covered set folds onto. + */ +export function uniquenessAxisGroupFor( + constraintName: string, + coveredKinds: readonly string[], +): UniquenessAxisGroup { + return { + constraintName, + axis: uniquenessAxisOfKinds(coveredKinds) ?? constraintName, + coveredKinds, + }; +} + /** The uniqueness axes the graph's own declarations produce. */ function uniquenessAxisGroups( graph: GraphDef, registry: KindRegistry, ): readonly UniquenessAxisGroup[] { - const groups = new Map(); + const coveredByIdentity = new Map< + string, + Readonly<{ constraintName: string; coveredKinds: Set }> + >(); for (const [kind, registration] of Object.entries(graph.nodes)) { for (const constraint of registration.unique ?? []) { const target = uniquenessClaimTarget(kind, constraint.scope, registry); const coveredKinds = - constraint.scope === "kind" ? - [kind] - : subClassComponent(kind, registry); + uniquenessScopeSpansSubclasses(constraint.scope) ? + subClassComponent(kind, registry) + : [kind]; const identity = `${constraint.name}\u0000${target.axis}`; - const existing = groups.get(identity); - groups.set(identity, { + const existing = + coveredByIdentity.get(identity)?.coveredKinds ?? new Set(); + for (const coveredKind of coveredKinds) existing.add(coveredKind); + coveredByIdentity.set(identity, { constraintName: constraint.name, - axis: target.axis, - coveredKinds: new Set([ - ...(existing?.coveredKinds ?? []), - ...coveredKinds, - ]), + coveredKinds: existing, }); } } - return [...groups.values()]; + return [...coveredByIdentity.values()].map((entry) => + uniquenessAxisGroupFor( + entry.constraintName, + [...entry.coveredKinds].toSorted((left, right) => + compareStrings(left, right), + ), + ), + ); } /** * WHICH axis a live `uniques` row is read at. * - * A row whose `node_kind` no declared group covers is left at its own - * `node_kind`: the relation's primary key already makes it the only row there, - * so it can contend with nothing and is reported by no group. + * Decided by the row's OWNER (`concrete_kind`), not by where the row sits + * (`node_kind`): the constraint a claim answers to is the one its owner's + * kind is covered by today. The two agree for every row written under the + * hierarchy the groups describe — a claim sits at its component's axis or, + * from before the axis moved, at its owner's own kind, and both are members + * of that component. They part ways exactly when a schema change re-pairs + * kinds: a claim written at `min({A, P})` belongs to no group once `A`'s + * component is `{A, B}` and `P` is outside it, while its owner still does. + * Reading the row at its stored axis would leave two contending owners in + * two groups of one. A row whose owner no group covers falls back to the + * stored axis, and one neither covers is left at its own `node_kind`: the + * relation's primary key already makes it the only row there, so it can + * contend with nothing and is reported by no group. * * A row covered by more than one group — possible only when one constraint name * is declared at two different scopes over one hierarchy — folds onto the @@ -132,17 +267,20 @@ function uniquenessAxisFor( row: ContendedUniqueRow, groups: readonly UniquenessAxisGroup[], ): string { - const covering = groups - .filter( - (group) => - group.constraintName === row.constraintName && - group.coveredKinds.has(row.nodeKind), - ) - .toSorted( - (left, right) => - right.coveredKinds.size - left.coveredKinds.size || - compareStrings(left.axis, right.axis), - ); + const named = groups.filter( + (group) => group.constraintName === row.constraintName, + ); + const coveringKind = (kind: string): readonly UniquenessAxisGroup[] => + named.filter((group) => group.coveredKinds.includes(kind)); + const byOwner = coveringKind(row.concreteKind); + const covering = ( + byOwner.length > 0 ? + byOwner + : coveringKind(row.nodeKind)).toSorted( + (left, right) => + right.coveredKinds.length - left.coveredKinds.length || + compareStrings(left.axis, right.axis), + ); return covering[0]?.axis ?? row.nodeKind; } @@ -155,16 +293,6 @@ function sortedOwners(owners: readonly ClaimOwner[]): readonly ClaimOwner[] { ); } -/** A claim target keyed as one map entry, for grouping rows onto axes. */ -function targetIdentity(target: ClaimTarget): string { - return [ - target.relation, - target.axis, - target.constraintName ?? "", - target.key, - ].join("\u0000"); -} - /** * Live `uniques` rows folded onto their axes, reported where an axis carries * more than one DISTINCT owner. @@ -238,6 +366,17 @@ function disjointnessViolations( /** * Live edges folded onto the cardinality axis each one would claim, reported * where an axis carries more than one holder. + * + * A row's `scope` — set by the backend loop that queried it, against either + * the ordinary per-edge-kind axis or the reserved, relation-wide composition + * axis — is what decides the family and the target, never a re-derivation + * from the row's own endpoints. Re-deriving it here (via `compositionClaim`) + * used to fold EVERY row of a composition-realizing edge kind onto the + * composition axis regardless of which query produced it — collapsing that + * kind's ordinary-axis violations into the composition group — and to throw + * on dirty data whose part-side endpoint kind was not a declared part kind. + * The row already knows which query found it; asking the registry again is a + * second, disagreeing spelling of the same decision. */ function edgeCardinalityViolations( rows: readonly ContendedEdgeRow[], @@ -245,26 +384,136 @@ function edgeCardinalityViolations( ): readonly ConstraintFenceViolation[] { const byAxis = new Map< string, - Readonly<{ target: ClaimTarget; edgeIds: string[] }> + Readonly<{ + target: ClaimTarget; + family: "edgeCardinality" | "composition"; + edgeIds: Set; + }> >(); for (const row of rows) { - const target = edgeCardinalityClaimTarget({ ...row, graphId }); + // `scope` is split out and re-added only when defined: `row.scope` is a + // required-but-nullable field, so a bare `...row` would spell + // `scope: undefined` explicitly into the object literal below, which + // `exactOptionalPropertyTypes` refuses for `ClaimEdgeCardinalityParams`' + // OPTIONAL `scope`. + const { scope, ...rest } = row; + const target = edgeCardinalityClaimTarget({ + ...rest, + graphId, + ...(scope === undefined ? {} : { scope }), + }); + const family = row.scope === undefined ? "edgeCardinality" : "composition"; const identity = targetIdentity(target); - const entry = byAxis.get(identity) ?? { target, edgeIds: [] }; - entry.edgeIds.push(row.edgeId); + const entry = byAxis.get(identity) ?? { + target, + family, + edgeIds: new Set(), + }; + entry.edgeIds.add(row.edgeId); byAxis.set(identity, entry); } return [...byAxis.values()] - .filter((entry) => entry.edgeIds.length > 1) + .filter((entry) => entry.edgeIds.size > 1) .map((entry) => ({ - family: "edgeCardinality" as const, + family: entry.family, target: entry.target, - edgeIds: entry.edgeIds.toSorted((left, right) => + edgeIds: [...entry.edgeIds].toSorted((left, right) => compareStrings(left, right), ), })); } +/** Live edges grouped per edge kind, reported where the kind carries any. */ +function edgeEndpointViolations( + rows: readonly MisassignedEdgeEndpointRow[], + allowances: readonly EdgeEndpointAllowance[], +): readonly ConstraintFenceViolation[] { + const allowedPairsByKind = new Map( + allowances.map((allowance) => [allowance.edgeKind, allowance.allowedPairs]), + ); + const rowsByKind = groupBy(rows, (row) => row.edgeKind); + return [...rowsByKind.entries()] + .toSorted(([left], [right]) => compareStrings(left, right)) + .map(([edgeKind, edgeRows]) => ({ + family: "edgeEndpointAssignability" as const, + edgeKind, + allowedPairs: allowedPairsByKind.get(edgeKind) ?? [], + edges: edgeRows.toSorted((left, right) => + compareStrings(left.edgeId, right.edgeId), + ), + })); +} + +/** + * The family order for the members with no `target`: after every + * claim-backed family, in this fixed order between themselves. + */ +const UNTARGETED_FAMILY_ORDER = [ + "edgeEndpointAssignability", + "edgeAcyclicity", + "compositionExistence", + "compositionFutureWindow", +] as const; + +type ClaimTargetedViolation = Extract< + ConstraintFenceViolation, + { target: ClaimTarget } +>; + +type UntargetedViolation = Exclude< + ConstraintFenceViolation, + ClaimTargetedViolation +>; + +/** Narrows to the two claim-backed families, both of which carry `target`. */ +function hasClaimTarget( + violation: ConstraintFenceViolation, +): violation is ClaimTargetedViolation { + return "target" in violation; +} + +/** The key an untargeted violation is ordered by within its own family. */ +function untargetedOrderKey(violation: UntargetedViolation): string { + switch (violation.family) { + case "edgeEndpointAssignability": + case "compositionFutureWindow": { + return violation.edgeKind; + } + case "compositionExistence": { + return violation.partKind; + } + case "edgeAcyclicity": { + return violation.relation; + } + } +} + +/** + * THE canonical order violations are reported in: claim families first + * (their existing {@link compareClaimTargets} order), then the untargeted + * families in {@link UNTARGETED_FAMILY_ORDER}, each ordered by its own key + * (`edgeEndpointAssignability` and `compositionFutureWindow` by edge kind, + * `compositionExistence` by part kind, `edgeAcyclicity` by relation). + */ +function compareConstraintFenceViolations( + left: ConstraintFenceViolation, + right: ConstraintFenceViolation, +): number { + if (hasClaimTarget(left)) { + return hasClaimTarget(right) ? + compareClaimTargets(left.target, right.target) + : -1; + } + if (hasClaimTarget(right)) return 1; + + const leftUntargetedRank = UNTARGETED_FAMILY_ORDER.indexOf(left.family); + const rightUntargetedRank = UNTARGETED_FAMILY_ORDER.indexOf(right.family); + if (leftUntargetedRank !== rightUntargetedRank) { + return leftUntargetedRank - rightUntargetedRank; + } + return compareStrings(untargetedOrderKey(left), untargetedOrderKey(right)); +} + /** The declarations the audit reads, one list per family. */ function fenceDeclarations( graph: GraphDef, @@ -276,33 +525,68 @@ function fenceDeclarations( (registration.unique ?? []).map((constraint) => constraint.name), ), ); - const edgeCardinalities = Object.entries(graph.edges).flatMap( - ([edgeKind, registration]): readonly EdgeCardinalityDeclaration[] => { - const cardinality = registration.cardinality ?? "many"; - return cardinality === "many" ? [] : [{ edgeKind, cardinality }]; - }, - ); + const edgeCardinalities: readonly EdgeCardinalityDeclaration[] = [ + ...Object.entries(graph.edges).flatMap( + ([edgeKind, registration]): readonly EdgeCardinalityDeclaration[] => + edgeCardinalityAxisReferences(registration).map((ref) => ({ + ...ref, + edgeKind, + })), + ), + ...compositionEdgeCardinalityDeclarations(registry), + ]; + const edgeEndpointKinds = buildGraphEdgeKindFacts(graph.edges); + const edgeEndpointAllowances = [...edgeEndpointKinds.entries()] + .map(([edgeKind, endpoints]) => + expandEdgeEndpointAllowance(edgeKind, endpoints, registry), + ) + .toSorted((left, right) => compareStrings(left.edgeKind, right.edgeKind)); return { graphId, uniqueConstraintNames: [...uniqueConstraintNames], disjointKindPairs: registry.disjointKindPairs(), edgeCardinalities, + edgeEndpointAllowances, }; } /** - * THE fence audit. Reads only; reports every claim axis whose population - * already carries more than one live claimant. + * What the audit reads and how it folds the rows. + * + * No top-level `graphId`: `declarations.graphId` is the only spelling of + * which graph this plan reads, so a caller cannot author two graph ids that + * silently drift out of agreement — the failure mode a second `graphId` + * field invites the moment a caller sets one and not the other. + */ +export type ConstraintFenceAuditPlan = Readonly<{ + declarations: ReadConstraintFenceViolationsParams; + uniquenessGroups: readonly UniquenessAxisGroup[]; + /** The registry the verdict is computed against — the PROPOSED one for a probe. */ + registry: KindRegistry; +}>; + +/** The narrow backend surface `auditConstraintFences` needs. */ +export type ConstraintFenceAuditBackend = Readonly<{ + readConstraintFenceViolations?: GraphBackend["readConstraintFenceViolations"]; +}>; + +/** + * THE reader. Declarations in, violations out. One implementation of every + * violation predicate, shared by `verifyConstraintFences` and by the + * ontology-tightening commit preflight. * * @throws ConfigurationError (`CONSTRAINT_FENCE_AUDIT_UNSUPPORTED`) when the - * backend cannot run the audit. Returning an empty report would be - * indistinguishable from a clean database, which is the one answer a - * diagnostic must never fabricate. + * backend cannot run the audit at all, and + * (`CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED`) when it ran the audit but + * the family was asked for and it answered nothing — an empty report there + * would be indistinguishable from a clean database, which is the one + * answer a diagnostic must never fabricate. */ -export async function verifyConstraintFences( - context: VerifyConstraintFencesContext, +export async function auditConstraintFences( + backend: ConstraintFenceAuditBackend, + plan: ConstraintFenceAuditPlan, ): Promise { - const audit = context.backend.readConstraintFenceViolations; + const audit = backend.readConstraintFenceViolations; if (audit === undefined) { throw new ConfigurationError( "This backend cannot audit constraint fences: it does not implement " + @@ -315,20 +599,181 @@ export async function verifyConstraintFences( }, ); } - const declarations = fenceDeclarations( - context.graph, - context.registry, - context.graphId, - ); - const rows: ConstraintFenceViolationRows = await audit(declarations); - const groups = uniquenessAxisGroups(context.graph, context.registry); + const rows: ConstraintFenceViolationRows = await audit(plan.declarations); + + if ( + (plan.declarations.edgeEndpointAllowances ?? []).length > 0 && + rows.misassignedEdgeEndpointRows === undefined + ) { + throw new ConfigurationError( + "This backend's constraint-fence audit does not answer the edge " + + "endpoint assignability family.", + { + code: "CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED", + family: "edgeEndpointAssignability", + }, + { + suggestion: + "Implement `misassignedEdgeEndpointRows` in `readConstraintFenceViolations`, " + + "or drop the edgeEndpointAllowances declaration if the family is not needed.", + }, + ); + } + return [ - ...uniquenessViolations(rows.contendedUniqueRows, groups, context.graphId), + ...uniquenessViolations( + rows.contendedUniqueRows, + plan.uniquenessGroups, + plan.declarations.graphId, + ), ...disjointnessViolations( rows.disjointOverlaps, + plan.registry, + plan.declarations.graphId, + ), + ...edgeCardinalityViolations( + rows.contendedEdgeRows, + plan.declarations.graphId, + ), + ...edgeEndpointViolations( + rows.misassignedEdgeEndpointRows ?? [], + plan.declarations.edgeEndpointAllowances ?? [], + ), + ].toSorted(compareConstraintFenceViolations); +} + +/** + * THE fence audit. Reads only; reports every claim axis whose population + * already carries more than one live claimant, every edge kind whose live + * rows sit outside every declared endpoint pair, every acyclic relation + * already carrying a cycle, every live required part with no live whole, and + * every composition row whose window starts or ends later than now. + * + * The `edgeAcyclicity` family does NOT go through + * `readConstraintFenceViolations` (the backend port every other family + * reads through): that port is a row-shape port for non-recursive families + * — "one statement with a correlated EXISTS" per family — and putting the + * recursive predicate behind it would create a second implementation of "is + * there a cycle", the write path's and the backend's. It instead runs + * `readEdgeAcyclicityViolations` directly through `context.backend.execute`, + * which every `GraphBackend` has. + */ +export async function verifyConstraintFences( + context: VerifyConstraintFencesContext, +): Promise { + const claimBacked = await auditConstraintFences(context.backend, { + declarations: fenceDeclarations( + context.graph, context.registry, context.graphId, ), - ...edgeCardinalityViolations(rows.contendedEdgeRows, context.graphId), - ].toSorted((left, right) => compareClaimTargets(left.target, right.target)); + uniquenessGroups: uniquenessAxisGroups(context.graph, context.registry), + registry: context.registry, + }); + + const acyclicRelations = acyclicEdgeRelations( + context.graph, + context.registry, + ); + const acyclicity = + acyclicRelations.length === 0 ? + [] + : await readEdgeAcyclicityViolations( + { + graphId: context.graphId, + registry: context.registry, + schema: createSqlSchema(context.backend.tableNames), + dialect: getDialect(context.backend.dialect), + target: context.backend, + operation: "verifyConstraintFences", + }, + acyclicRelations, + ); + + // Required-existence parts, graph-wide (not delta-scoped: this is a + // live-graph audit, not a commit preflight). + const compositionExistence = await compositionExistenceViolations( + context.registry, + context.backend, + context.graphId, + requiredCompositionPartKinds(context.registry), + context.batchPointRead, + ); + + // Graph-wide over every realizing edge kind, for the same reason. + const compositionFutureWindow = await compositionFutureWindowViolations( + context.backend, + context.graphId, + context.registry.compositionEdgeKinds(), + ); + + return [ + ...claimBacked, + ...acyclicity, + ...compositionExistence, + ...compositionFutureWindow, + ].toSorted(compareConstraintFenceViolations); +} + +/** + * The `compositionFutureWindow` family over these realizing edge kinds: one + * violation per edge kind holding a row whose window starts or ends later + * than now. "Now" is one read of the clock every composition edge write + * judges its own window against (`nowIso`), so a row the write path would + * have admitted at its write is never reported later. Shared by this audit + * and the schema-commit preflight (`src/schema/tightening-preflight.ts`). + */ +export async function compositionFutureWindowViolations( + backend: GraphReadBackend, + graphId: string, + edgeKinds: readonly string[], +): Promise { + if (edgeKinds.length === 0) return []; + const scheduled = await readCompositionFutureWindowEdges( + backend, + graphId, + edgeKinds, + nowIso(), + ); + const byEdgeKind = groupBy(scheduled, (edge) => edge.kind); + return [...byEdgeKind.entries()] + .map(([edgeKind, edges]) => ({ + family: "compositionFutureWindow" as const, + edgeKind, + edgeIds: edges + .map((edge) => edge.id) + .toSorted((left, right) => compareStrings(left, right)), + })) + .toSorted((left, right) => compareStrings(left.edgeKind, right.edgeKind)); +} + +/** + * Groups {@link readCompositionUnattachedParts}' flat result into one + * `compositionExistence` violation per part kind — mirroring + * `disjointnessViolations`'/`edgeCardinalityViolations`' own per-axis + * grouping below, so this family folds through the same shape. + */ +async function compositionExistenceViolations( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + partKinds: readonly string[], + batchPointRead: BundleVerdictOf, +): Promise { + if (partKinds.length === 0) return []; + const unattached = await readCompositionUnattachedParts( + registry, + backend, + graphId, + partKinds, + batchPointRead, + ); + const byPartKind = groupBy(unattached, (part) => part.kind); + return [...byPartKind.entries()] + .map(([partKind, parts]) => ({ + family: "compositionExistence" as const, + partKind, + parts, + })) + .toSorted((left, right) => compareStrings(left.partKind, right.partKind)); } diff --git a/packages/typegraph/src/store/clear-graph-command.ts b/packages/typegraph/src/store/clear-graph-command.ts new file mode 100644 index 000000000..8a907c7be --- /dev/null +++ b/packages/typegraph/src/store/clear-graph-command.ts @@ -0,0 +1,35 @@ +/** Which backend member one `Store.clear()` deletes its graph through. */ +import type { GraphBackend, TransactionBackend } from "../backend/types"; +import { UnsupportedBackendCapabilityError } from "../errors"; + +type ClearGraphCommand = GraphBackend["clearGraph"]; + +const CLEAR_OPERATION = + "store.clear({ preserveContributionMaterializations: true })"; +const PRESERVING_CLEAR_MEMBER = + "clearGraphPreservingContributionMaterializations"; + +/** + * - `preserve: false` clears through `clearGraph`, markers included. + * - `preserve: true` is a stated requirement: a backend without the + * preserving member cannot honor it and is refused, never silently cleared + * through `clearGraph`. + * - An omitted option prefers the preserving member and otherwise keeps a + * custom backend's own `clearGraph` behavior. + */ +export function resolveClearGraphCommand( + target: GraphBackend | TransactionBackend, + preserve: boolean | undefined, +): ClearGraphCommand { + if (preserve === false) return target.clearGraph; + const preservingClear = + target.clearGraphPreservingContributionMaterializations; + if (preservingClear !== undefined) return preservingClear; + if (preserve === undefined) return target.clearGraph; + throw new UnsupportedBackendCapabilityError( + CLEAR_OPERATION, + PRESERVING_CLEAR_MEMBER, + {}, + `Use a backend that implements ${PRESERVING_CLEAR_MEMBER}, or omit the option to clear through the backend's own clearGraph.`, + ); +} diff --git a/packages/typegraph/src/store/collection-factory.ts b/packages/typegraph/src/store/collection-factory.ts index fcfa6e06c..2dc05cca8 100644 --- a/packages/typegraph/src/store/collection-factory.ts +++ b/packages/typegraph/src/store/collection-factory.ts @@ -18,8 +18,12 @@ import type { CompiledSelectSql } from "../query/sql-intent"; import { type KindRegistry } from "../registry/kind-registry"; import { createEdgeCollection, createNodeCollection } from "./collections"; import { type UpsertDirtyCheckFunction } from "./collections/coalesce"; -import { type EdgeUpsertUpdateBatchEntry } from "./collections/edge-collection"; import { + type EdgeCreateBatchOptions, + type EdgeUpsertUpdateBatchEntry, +} from "./collections/edge-collection"; +import { + type NodeCreateBatchOptions, type NodeSetUpdateRequest, type NodeUpsertUpdateBatchEntry, type UpsertUpdateNodeInput, @@ -36,7 +40,10 @@ import { type IfExistsMode, type Node, type NodeBulkFindByIndexOptions, + type NodeDeletePlan, type NodeGetOrCreateByConstraintOptions, + type NodeReparentOptions, + type NodeReparentResult, type QueryOptions, } from "./types"; @@ -60,7 +67,7 @@ export type NodeOperations = Readonly<{ executeCreateBatch: ( inputs: readonly CreateNodeInput[], backend: GraphBackend | TransactionBackend, - options?: Readonly<{ propsPreValidated?: true }>, + options?: NodeCreateBatchOptions, ) => Promise; executeCreateNoReturnBatch: ( inputs: readonly CreateNodeInput[], @@ -112,10 +119,22 @@ export type NodeOperations = Readonly<{ * the other preconditions. */ upsertDirtyCheck?: UpsertDirtyCheckFunction; + executeReparent: ( + kind: string, + id: string, + options: NodeReparentOptions, + backend: GraphBackend | TransactionBackend, + ) => Promise; + executeReparentBatch: ( + kind: string, + items: readonly Readonly<{ id: string; options: NodeReparentOptions }>[], + backend: GraphBackend | TransactionBackend, + ) => Promise; executeDelete: ( kind: string, id: string, backend: GraphBackend | TransactionBackend, + expect: NodeDeletePlan | undefined, ) => Promise; executeDeleteBatch: ( kind: string, @@ -127,6 +146,11 @@ export type NodeOperations = Readonly<{ id: string, backend: GraphBackend | TransactionBackend, ) => Promise; + executeDeletePlan: ( + kind: string, + id: string, + backend: GraphBackend | TransactionBackend, + ) => Promise; temporalRowMatcher: (options?: QueryOptions) => (row: NodeRow) => boolean; createQuery?: () => QueryBuilder; executeGetOrCreateByConstraint: ( @@ -181,6 +205,7 @@ export type EdgeOperations = Readonly<{ executeCreateBatch: ( inputs: readonly CreateEdgeInput[], backend: GraphBackend | TransactionBackend, + options?: EdgeCreateBatchOptions, ) => Promise; executeCreateNoReturnBatch: ( inputs: readonly CreateEdgeInput[], diff --git a/packages/typegraph/src/store/collection-surface.ts b/packages/typegraph/src/store/collection-surface.ts index e79f0bff8..312d1b757 100644 --- a/packages/typegraph/src/store/collection-surface.ts +++ b/packages/typegraph/src/store/collection-surface.ts @@ -33,11 +33,12 @@ export const NODE_TEMPORAL_READ_NAMES = [ "count", ] as const satisfies readonly (keyof NodeCollection)[]; -/** Current-state-only node reads (constraint / index lookups). */ +/** Current-state-only node reads (constraint / index lookups, delete plans). */ export const CURRENT_ONLY_READ_NAMES = [ "findByConstraint", "bulkFindByConstraint", "bulkFindByIndex", + "planDelete", ] as const satisfies readonly (keyof NodeCollection)[]; /** Node write method names: never available on a read-only StoreView. */ @@ -45,6 +46,8 @@ export const NODE_WRITE_NAMES = [ "create", "createFromRecord", "update", + "reparent", + "bulkReparent", "compareAndSet", "updateWhere", "delete", @@ -118,6 +121,18 @@ export const IDENTITY_READ_NAMES = [ "explainSame", ] as const satisfies readonly (keyof IdentityFacade)[]; +/** + * Identity facade methods that answer across every recorded coordinate + * (transition history), never available on a coordinate-pinned StoreView — + * and not receipt-tracked writes either, so they are neither a "read" (the + * `IdentityReadFacade` split) nor a "write" (the receipt counters) in the + * senses those two buckets mean. + */ +export const IDENTITY_HISTORY_NAMES = [ + "transitionsOf", + "replay", +] as const satisfies readonly (keyof IdentityFacade)[]; + /** Identity facade write method names: never available on a read-only view. */ export const IDENTITY_WRITE_NAMES = [ "assertSame", diff --git a/packages/typegraph/src/store/collections/edge-collection.ts b/packages/typegraph/src/store/collections/edge-collection.ts index 8fb2fe8ed..0682f53b1 100644 --- a/packages/typegraph/src/store/collections/edge-collection.ts +++ b/packages/typegraph/src/store/collections/edge-collection.ts @@ -17,7 +17,6 @@ import { type FindEdgesByKindParams, type GraphBackend, rowPropsToObject, - runOptionallyInTransaction, type TransactionBackend, } from "../../backend/types"; import { type GraphDef } from "../../core/define-graph"; @@ -39,6 +38,7 @@ import { type EdgeIdentityExpectation, edgeIdentityFromRow, } from "../operations/edge-identity"; +import { runOwnedMutationSetTransaction } from "../operations/write-transaction"; import { type ResolvedMutationSetAttempt, runResolvedMutationSetConverging, @@ -118,6 +118,7 @@ export type EdgeCollectionConfig = Readonly<{ executeCreateBatch: ( inputs: readonly CreateEdgeInput[], backend: GraphBackend | TransactionBackend, + options?: EdgeCreateBatchOptions, ) => Promise; executeUpdate: ( input: { @@ -282,6 +283,22 @@ export type UpsertUpdateEdgeInput = EdgeUpdateInput & onImmutableLowerBound?: "preserve" | "refuse"; }>; +/** + * What a batch create states beyond its own rows. + */ +export type EdgeCreateBatchOptions = Readonly<{ + /** + * The updates the caller writes right after this batch, inside the same + * transaction. Every one of them is prepared before this batch's first + * insert, against the state the inserts leave — re-entry cardinality and + * composition claims counting this batch's rows, merged props, the window + * verdict — and the ones that resurrect join this batch's one acyclicity + * probe. A refusal any of them reaches then precedes every write of the + * combined set, instead of surfacing after this batch's rows are stored. + */ + pairedUpdates?: readonly EdgeUpsertUpdateBatchEntry[]; +}>; + export type EdgeUpsertUpdateBatchEntry = Readonly<{ input: UpsertUpdateEdgeInput; clearDeleted: boolean; @@ -1078,7 +1095,9 @@ export function createEdgeCollection< ); if (mutationAttempt.outcome === "unsupported") { if (toCreate.length > 0) { - const created = await executeEdgeCreateBatch(createInputs, target); + const created = await executeEdgeCreateBatch(createInputs, target, { + pairedUpdates: updateEntries, + }); for (const [index, entry] of toCreate.entries()) { results[entry.index] = narrowEdge( requireDefined(created[index]), @@ -1126,8 +1145,9 @@ export function createEdgeCollection< // written it, never on an autocommit read taken before it (the defect // `upsertById` had on the node side). // - // `runOptionallyInTransaction` is the one owner of "open a transaction if - // this backend has one": already inside `store.transaction(...)` the + // `runOwnedMutationSetTransaction` opens a transaction if this backend + // has one (and replays it when the per-graph write fence refuses its + // snapshot): already inside `store.transaction(...)` the // target IS the caller's transaction and no nested one opens, and on a // backend without interactive transactions there is nothing to open, so // the decision is exactly as fenced as that backend's writes are — which @@ -1136,7 +1156,9 @@ export function createEdgeCollection< "edge", backend, () => - runOptionallyInTransaction(backend, (target) => upsertAll(target)), + runOwnedMutationSetTransaction(backend, (target) => + upsertAll(target), + ), ); // Match bulkCreate/bulkInsert: refresh planner statistics after a large // autocommit bulk write. Coalesced items wrote nothing, so only real diff --git a/packages/typegraph/src/store/collections/node-collection.ts b/packages/typegraph/src/store/collections/node-collection.ts index 4c17e28fb..1f574d9f6 100644 --- a/packages/typegraph/src/store/collections/node-collection.ts +++ b/packages/typegraph/src/store/collections/node-collection.ts @@ -36,9 +36,15 @@ import { asCompiledSelectSql, type CompiledSelectSql, } from "../../query/sql-intent"; +import type { CompositionViaRef } from "../../registry/composition-relation"; import { nowIso } from "../../utils/date"; import { requireDefined } from "../../utils/presence"; import { getNodeRowsByIds } from "../node-fetch"; +import { + readNodeDeleteOptions, + refuseNodeDeleteArguments, +} from "../operations/node-delete-expectation"; +import { runOwnedMutationSetTransaction } from "../operations/write-transaction"; import { type ResolvedMutationSetAttempt, runResolvedMutationSetConverging, @@ -50,8 +56,13 @@ import { type Node, type NodeBulkFindByIndexOptions, type NodeCollection, + type NodeCreateOptions, + type NodeDeleteOptions, + type NodeDeletePlan, type NodeGetOrCreateByConstraintOptions, type NodeGetOrCreateByConstraintResult, + type NodeReparentOptions, + type NodeReparentResult, type QueryOptions, type UpdateNodeInput, type ValidityEndMutation, @@ -201,6 +212,24 @@ export type NodeUpsertUpdateBatchEntry = Readonly<{ replacementProps?: Record; }>; +/** + * What a batch create states beyond its own rows. + */ +export type NodeCreateBatchOptions = Readonly<{ + /** Every input's `props` is already the output of its kind's schema. */ + propsPreValidated?: true; + /** + * The updates the caller writes right after this batch, inside the same + * transaction. Every one of them is decided before this batch's first + * write, against the state the creates leave — merged props, the window + * verdict, uniqueness counting this batch's keys, the composition and + * identity verdicts of a restore — so a refusal any of them reaches + * precedes every write of the combined set, instead of surfacing after + * this batch's rows are stored. + */ + pairedUpdates?: readonly NodeUpsertUpdateBatchEntry[]; +}>; + /** * Config for creating a NodeCollection. */ @@ -226,7 +255,7 @@ export type NodeCollectionConfig = Readonly<{ executeCreateBatch: ( inputs: readonly CreateNodeInput[], backend: GraphBackend | TransactionBackend, - options?: Readonly<{ propsPreValidated?: true }>, + options?: NodeCreateBatchOptions, ) => Promise; executeUpdate: ( input: UpsertUpdateNodeInput, @@ -268,10 +297,22 @@ export type NodeCollectionConfig = Readonly<{ ) => Promise>; /** See NodeOperations.upsertDirtyCheck. */ upsertDirtyCheck?: UpsertDirtyCheckFunction; + executeReparent: ( + kind: string, + id: string, + options: NodeReparentOptions, + backend: GraphBackend | TransactionBackend, + ) => Promise; + executeReparentBatch: ( + kind: string, + items: readonly Readonly<{ id: string; options: NodeReparentOptions }>[], + backend: GraphBackend | TransactionBackend, + ) => Promise; executeDelete: ( kind: string, id: string, backend: GraphBackend | TransactionBackend, + expect: NodeDeletePlan | undefined, ) => Promise; executeDeleteBatch: ( kind: string, @@ -283,6 +324,11 @@ export type NodeCollectionConfig = Readonly<{ id: string, backend: GraphBackend | TransactionBackend, ) => Promise; + executeDeletePlan: ( + kind: string, + id: string, + backend: GraphBackend | TransactionBackend, + ) => Promise; temporalRowMatcher: (options?: QueryOptions) => (row: NodeRow) => boolean; createQuery?: () => QueryBuilder; executeGetOrCreateByConstraint: ( @@ -323,23 +369,16 @@ export type NodeCollectionConfig = Readonly<{ function buildCreateInput( kind: string, props: Record, - options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>, + options?: NodeCreateOptions, ): CreateNodeInput { - const input: { - kind: string; - id?: string; - props: Record; - validFrom?: string | null; - validTo?: string; - } = { kind, props }; - if (options?.id !== undefined) input.id = options.id; - if (options?.validFrom !== undefined) input.validFrom = options.validFrom; - if (options?.validTo !== undefined) input.validTo = options.validTo; - return input; + return { + kind, + props, + ...(options?.id !== undefined && { id: options.id }), + ...(options?.validFrom !== undefined && { validFrom: options.validFrom }), + ...(options?.validTo !== undefined && { validTo: options.validTo }), + ...(options?.partOf !== undefined && { partOf: options.partOf }), + }; } function buildUpdateInput( @@ -392,12 +431,8 @@ function buildUpsertUpdateInput( function mapBulkNodeInputs( kind: string, - items: readonly Readonly<{ - props: Record; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[], + items: readonly (Readonly<{ props: Record }> & + NodeCreateOptions)[], ): CreateNodeInput[] { return items.map((item) => buildCreateInput(kind, item.props, item)); } @@ -427,9 +462,12 @@ export function createNodeCollection< executeResolvedMutationSet: executeNodeResolvedMutationSet, prepareReplacement, executeReplacementBatch: executeNodeReplacementBatch, + executeReparent: executeNodeReparent, + executeReparentBatch: executeNodeReparentBatch, executeDelete: executeNodeDelete, executeDeleteBatch: executeNodeDeleteBatch, executeHardDelete: executeNodeHardDelete, + executeDeletePlan: executeNodeDeletePlan, temporalRowMatcher, createQuery, executeGetOrCreateByConstraint, @@ -442,22 +480,14 @@ export function createNodeCollection< return { async create( props: z.input, - options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>, + options?: NodeCreateOptions, ): Promise> { return this.createFromRecord(props, options); }, async createFromRecord( data: Record, - options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>, + options?: NodeCreateOptions, ): Promise> { const result = await executeNodeCreate( buildCreateInput(kind, data, options), @@ -520,8 +550,11 @@ export function createNodeCollection< } const rootAlias = "compare_and_set_candidate"; const candidateIdColumn = `${rootAlias}_id`; + // Root pins to exact kind (here and in updateWhere) are redundant with + // `executeNodeSetUpdate`'s `WHERE nodes.kind = ` filter, which + // re-filters whatever these candidate subqueries widen to. const candidateIds = createQuery() - .fromDynamic(kind, rootAlias) + .fromDynamic(kind, rootAlias, { expansion: "exact" }) .whereNode(rootAlias, (accessor) => accessor.id.eq(id)) .select((ctx: Record) => ctx[rootAlias]?.id) .compile(); @@ -614,7 +647,7 @@ export function createNodeCollection< } let base = createQuery() - .fromDynamic(kind, rootAlias) + .fromDynamic(kind, rootAlias, { expansion: "exact" }) .temporal("asOf", readInstant); const where = params.where; if (where !== undefined) { @@ -626,7 +659,7 @@ export function createNodeCollection< const edgeAlias = `update_edge_${index}`; const relatedAlias = `update_related_${index}`; const relationRoot = createQuery() - .fromDynamic(kind, rootAlias) + .fromDynamic(kind, rootAlias, { expansion: "exact" }) .temporal("asOf", readInstant); let traversal = relationRoot.traverseDynamic( relation.edgeKind, @@ -639,7 +672,15 @@ export function createNodeCollection< if (relation.whereEdge !== undefined) { traversal = traversal.whereEdge(edgeAlias, relation.whereEdge); } - let related = traversal.toDynamic(relation.relatedKind, relatedAlias); + // Pinned exact-kind, load-bearing (unlike the root pins): this alias's kind gates whether the `exists` predicate is + // satisfied at all, and only `rootAlias`'s id is projected — the + // outer `WHERE nodes.kind = ` fence never sees `relatedAlias`, + // so widening it here would let a subclass-only related row + // satisfy an `exists` check the caller declared against the exact + // parent kind. + let related = traversal.toDynamic(relation.relatedKind, relatedAlias, { + expansion: "exact", + }); if (relation.whereRelated !== undefined) { related = related.whereNode(relatedAlias, relation.whereRelated); } @@ -696,14 +737,49 @@ export function createNodeCollection< return result; }, - async delete(id: NodeId): Promise { - await executeNodeDelete(kind, id, backend); + async reparent< + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( + id: NodeId, + options: NodeReparentOptions, + ): Promise> { + return executeNodeReparent(kind, id, options, backend) as Promise< + NodeReparentResult + >; }, - async hardDelete(id: NodeId): Promise { + async bulkReparent< + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( + items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[], + ): Promise[]> { + return executeNodeReparentBatch(kind, items, backend) as Promise< + readonly NodeReparentResult[] + >; + }, + + async delete(id: NodeId, options?: NodeDeleteOptions): Promise { + const expect = readNodeDeleteOptions(options, { kind, id }); + await executeNodeDelete(kind, id, backend, expect); + }, + + async hardDelete( + id: NodeId, + ...extraArguments: readonly unknown[] + ): Promise { + refuseNodeDeleteArguments(extraArguments, { kind, id }, "hardDelete"); await executeNodeHardDelete(kind, id, backend); }, + async planDelete(id: NodeId): Promise { + return executeNodeDeletePlan(kind, id, backend); + }, + async find( filter?: Readonly<{ where?: (accessor: never) => unknown; @@ -731,8 +807,12 @@ export function createNodeCollection< temporal, defaultTemporalMode, ); + // Pinned exact-kind: the no-`where` branch just below goes straight + // to the backend find path, which is exact-kind by construction — + // this branch must return the identical row set (see the comment + // there), not a polymorphic-by-default one. let query = createQuery() - .from(kind, "_n") + .from(kind, "_n", { expansion: "exact" }) .temporal(asOf === undefined ? temporalMode : "asOf", asOf) .whereNode("_n", filter.where as never) .select((ctx: Record) => ctx["_n"]); @@ -879,12 +959,8 @@ export function createNodeCollection< }, async bulkCreate( - items: readonly Readonly<{ - props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[], + items: readonly (Readonly<{ props: z.input }> & + NodeCreateOptions)[], ): Promise[]> { const batchInputs = mapBulkNodeInputs(kind, items); const results = await executeNodeCreateBatch(batchInputs, backend); @@ -932,7 +1008,7 @@ export function createNodeCollection< "node", backend, () => - runOptionallyInTransaction(backend, async (target) => { + runOwnedMutationSetTransaction(backend, async (target) => { const rows = await getNodeRowsByIds( target, batchPointRead, @@ -942,29 +1018,31 @@ export function createNodeCollection< ); const results = new Map>(); const creates = prepared.filter((item) => !rows.has(item.id)); - const updates = prepared.filter((item) => rows.has(item.id)); + const updateEntries = prepared + .filter((item) => rows.has(item.id)) + .map((item) => { + const existing = requireDefined(rows.get(item.id)); + return { + input: buildUpsertUpdateInput(kind, item.id, item.props), + clearDeleted: existing.deleted_at !== undefined, + existing, + replacementProps: item.props, + }; + }); if (creates.length > 0) { const created = await executeNodeCreateBatch( creates.map((item) => buildCreateInput(kind, item.props, { id: item.id }), ), target, - { propsPreValidated: true }, + { propsPreValidated: true, pairedUpdates: updateEntries }, ); for (const row of created) results.set(row.id, narrowNode(row)); } - if (updates.length > 0) { + if (updateEntries.length > 0) { const updated = await executeNodeUpsertUpdateBatch( - updates.map((item) => { - const existing = requireDefined(rows.get(item.id)); - return { - input: buildUpsertUpdateInput(kind, item.id, item.props), - clearDeleted: existing.deleted_at !== undefined, - existing, - replacementProps: item.props, - }; - }), + updateEntries, target, ); for (const row of updated) @@ -1214,7 +1292,9 @@ export function createNodeCollection< ); if (mutationAttempt.outcome === "unsupported") { if (toCreate.length > 0) { - const created = await executeNodeCreateBatch(createInputs, target); + const created = await executeNodeCreateBatch(createInputs, target, { + pairedUpdates: updateEntries, + }); for (const [index, entry] of toCreate.entries()) { results[entry.index] = narrowNode( requireDefined(created[index]), @@ -1260,7 +1340,9 @@ export function createNodeCollection< "node", backend, () => - runOptionallyInTransaction(backend, (target) => upsertAll(target)), + runOwnedMutationSetTransaction(backend, (target) => + upsertAll(target), + ), ); // Match bulkCreate/bulkInsert: refresh planner statistics after a large // autocommit bulk write. Coalesced items wrote nothing, so only real @@ -1284,7 +1366,11 @@ export function createNodeCollection< await config.maybeRefreshStatisticsAfterBulk?.(batchInputs.length); }, - async bulkDelete(ids: readonly NodeId[]): Promise { + async bulkDelete( + ids: readonly NodeId[], + ...extraArguments: readonly unknown[] + ): Promise { + refuseNodeDeleteArguments(extraArguments, { kind }, "bulkDelete"); if (ids.length === 0) return; await executeNodeDeleteBatch(kind, ids, backend); }, @@ -1381,28 +1467,16 @@ export function createNodeCollection< props: item.props, })); - const getOrCreateAll = async ( - target: GraphBackend | TransactionBackend, - ): Promise[]> => { - const results = await executeBulkGetOrCreateByConstraint( - kind, - constraintName, - mappedItems, - target, - options, - ); - return results as NodeGetOrCreateByConstraintResult[]; - }; - - if ( - backend.capabilities.execution.interactiveTransactions && - "transaction" in backend - ) { - return backend.transaction(async (txBackend) => - getOrCreateAll(txBackend), - ); - } - return getOrCreateAll(backend); + // The operation owns its transaction boundary, which its convergence + // retry reads: see `executeNodeBulkGetOrCreateByConstraint`. + const results = await executeBulkGetOrCreateByConstraint( + kind, + constraintName, + mappedItems, + backend, + options, + ); + return results as NodeGetOrCreateByConstraintResult[]; }, }; } diff --git a/packages/typegraph/src/store/constraints.ts b/packages/typegraph/src/store/constraints.ts index e8ac5ccb2..f6bf59817 100644 --- a/packages/typegraph/src/store/constraints.ts +++ b/packages/typegraph/src/store/constraints.ts @@ -34,18 +34,39 @@ * are what the write paths ask so they take that same per-graph lock whenever * one of these probes is in play, and only then. */ -import { type GraphEntityReadBackend, isLiveNodeRow } from "../backend/types"; import { - checkCardinality, - checkDisjointness, - checkUniqueEdge, -} from "../constraints"; + type ClaimEdgeCardinalityParams, + type CompositionClaimScope, + type GraphEntityReadBackend, + isLiveNodeRowPresent, +} from "../backend/types"; +import { checkDisjointness } from "../constraints"; import { type GraphDef } from "../core/define-graph"; -import { type Cardinality, type UniqueConstraint } from "../core/types"; +import { type UniqueConstraint } from "../core/types"; +import { type CardinalityError, type CompositionError } from "../errors"; import { type KindRegistry } from "../registry/kind-registry"; +import { hasOwnKey } from "../utils/object"; +import { + acyclicEdgeRelations, + edgeKindIsInAcyclicRelation, +} from "./acyclicity"; import { type ConstraintFenceReason } from "./claims/backing"; -import { EDGE_CARDINALITY_SPECS } from "./claims/edge-claims"; +import { + claimRefusalFor, + claimsInProbeOrder, + compositionClaimEndpoints, + edgeCardinalityAxisName, + type EdgeCardinalityAxisRef, + edgeCardinalityAxisReferences, + type EdgeCardinalityDeclarations, + edgeCardinalitySpec, + edgeCardinalityViolation, +} from "./claims/edge-claims"; import { nodeClaimSites } from "./claims/sites"; +import { + compositionEdgeHasRequiredExistencePart, + findLiveCompositionAttachment, +} from "./operations/composition-create"; export { type ConstraintFenceReason } from "./claims/backing"; @@ -65,22 +86,90 @@ export type ConstraintContext = Readonly<{ }>; /** - * The constraint that makes an edge write of this cardinality constrained, or + * The constraint that makes an edge write of this declaration constrained, or * `undefined` when it is not. * - * `many` declares no constraint, so its create runs no cardinality probe and - * must NOT pay for the lock — the fence is for writes that check something, not - * for writes in general. Every other cardinality counts or existence-tests - * sibling edges before inserting, and nothing in the schema repeats that test. + * A declaration with no constrained cardinality axis (both `cardinality` and + * `targetCardinality` absent or `many`) and no `acyclic: true` runs no probe + * and must NOT pay for the lock — the fence is for writes that check + * something, not for writes in general. Every constrained axis counts or + * existence-tests sibling edges before inserting, and an `acyclic: true` edge + * kind (`cardinality: "many", acyclic: true` is the common case) runs the + * reachability probe before inserting; nothing in the schema repeats either + * test. * - * The one owner of this classification: `checkCardinalityConstraint`'s `many` - * arm and this predicate are the same decision seen from two sides, and a - * second inline `!== "many"` at a write path would be the copy that drifts. + * Composition is reported first of all: a composition edge kind ALWAYS + * declares a constrained whole-side cardinality, so + * it always also qualifies as `"edgeCardinality"` — reporting the narrower + * reason first is what lets a backend that cannot hold the fence give advice + * that names the `partOf`/`hasPart` declaration rather than a generic + * cardinality one. Cardinality is reported next when both it and acyclicity + * apply, so every refusal payload that existed before `acyclic` shipped stays + * byte-identical — the fence itself is the same per-graph lock regardless of + * which reason names it, so the choice affects only what a refusal on an + * unfenceable backend calls the constraint. + * + * The one owner of this classification: it folds through + * {@link edgeCardinalityAxisReferences}, the same fold the claim set + * `checkEdgeCardinalityConstraints` probes is built from, so a second inline `!== "many"` at a write path — blind to a + * target-only declaration — can never drift from it. {@link graphOwesClaims} + * routes through this function too, and accepts only its + * `"edgeComposition"` / `"edgeCardinality"` answers: acyclicity has no claim + * row to substitute for the per-graph lock import skips (see + * {@link graphOwesLockOnlyFence}), so an `"edgeAcyclicity"` answer from here + * is filtered out at that call site rather than re-derived by a second, + * narrower fold. */ export function edgeWriteNeedsConstraintFence( - cardinality: Cardinality, + declarations: EdgeCardinalityDeclarations & + Readonly<{ acyclic?: boolean; composition?: boolean }>, +): ConstraintFenceReason | undefined { + if (declarations.composition === true) { + return "edgeComposition"; + } + if (edgeCardinalityAxisReferences(declarations).length > 0) { + return "edgeCardinality"; + } + return declarations.acyclic === true ? "edgeAcyclicity" : undefined; +} + +/** + * The cardinality declarations an edge write of `kind` must honor, read from + * the graph def. A kind this graph does not define answers the empty + * declaration: choosing the fence must not become the thing that REPORTS an + * unknown kind — the write path raises `KindNotFoundError` from inside the + * hooked transaction, where a caller's `onError` hook observes it. + */ +export function edgeKindCardinalityDeclarations( + graph: GraphDef, + kind: string, +): EdgeCardinalityDeclarations { + return (hasOwnKey(graph.edges, kind) ? graph.edges[kind] : undefined) ?? {}; +} + +/** + * THE constraint-fence reason an edge write of `kind` owes: the kind's + * declared cardinality axes and its composition dimension — both invariant + * properties of the kind — folded through {@link edgeWriteNeedsConstraintFence} + * with an acyclicity answer. + * + * `acyclic` defaults to whether the kind participates in an acyclic relation + * at all, which is what a create (always re-admitting its row to that + * population) owes. A write that re-admits the row only conditionally — a + * resurrect that clears the tombstone, a window reopen of an `"openRows"` + * member — states the dimension it actually decides. + */ +export function edgeKindFenceReason( + graph: GraphDef, + registry: KindRegistry, + kind: string, + acyclic: boolean = edgeKindIsInAcyclicRelation(graph, registry, kind), ): ConstraintFenceReason | undefined { - return cardinality === "many" ? undefined : "edgeCardinality"; + return edgeWriteNeedsConstraintFence({ + ...edgeKindCardinalityDeclarations(graph, kind), + acyclic, + composition: registry.isCompositionEdge(kind), + }); } /** @@ -130,6 +219,68 @@ export function nodeWriteNeedsConstraintFence( )?.refusalReason; } +/** + * WHICH constraint makes a node DELETE a constrained write: a composition + * WHOLE's delete cascades leaf-first through its parts closure under the + * per-graph write lock (`planCompositionCascade`), so a concurrent attach + * cannot slip a part past it. The classification is STATIC — a declared- + * schema property (`registry.isCompositionWhole`), decided before any row + * read — which is what lets a part-less kind's delete stay unfenced on + * every backend, interactive transactions or not. + * + * Sibling of {@link nodeWriteNeedsConstraintFence}: that one folds + * `nodeClaimSites` (a create/update write owes a claim); this one is a + * one-line registry lookup because a composition delete owes no claim row — + * it owes exclusive access to the closure it is about to walk. Kept as its + * own function rather than a third value in that one's `operation` union so + * a delete's very different reason ("this kind cascades") is never confused + * with a create/update's ("this kind claims a scope"). + */ +export function nodeDeleteNeedsConstraintFence( + registry: KindRegistry, + kind: string, +): ConstraintFenceReason | undefined { + return registry.isCompositionWhole(kind) ? "edgeComposition" : undefined; +} + +/** + * WHICH constraint makes a node RESTORE — any write that clears a tombstone + * without stating an attachment — a constrained write: a required-existence + * part may only come back holding a live whole + * (`assertRestoredRequiredPartsAttached`), a verdict read from the part's + * composition edge and its whole that no claim row records. A concurrent + * detach ({@link edgeDetachNeedsConstraintFence}) or whole delete + * ({@link nodeDeleteNeedsConstraintFence}) reads the part's liveness just as + * unbacked, so all three serialize on the per-graph write fence, named for + * the composition relation. A kind that requires no whole restores unfenced. + */ +export function nodeRestoreNeedsConstraintFence( + registry: KindRegistry, + kind: string, +): ConstraintFenceReason | undefined { + return registry.compositionExistence(kind) === "required" ? + "edgeComposition" + : undefined; +} + +/** + * WHICH constraint makes an edge write that can SEPARATE a part from its + * whole — a soft delete, a hard delete, or a stated window end — a + * constrained write: `assertCompositionExistencePreserved` refuses it while + * the part is live, judged from a part-row read no claim row backs. The + * restore twin is {@link nodeRestoreNeedsConstraintFence}. Only a composition + * edge kind realizing a required-existence part pays; every other detach + * decides nothing and stays unfenced. + */ +export function edgeDetachNeedsConstraintFence( + registry: KindRegistry, + edgeKind: string, +): ConstraintFenceReason | undefined { + return compositionEdgeHasRequiredExistencePart(registry, edgeKind) ? + "edgeComposition" + : undefined; +} + /** * THE graph-level answer to "does writing into this graph owe a claim that must * precede the row it gates?", folded over the SAME per-kind functions the write @@ -153,6 +304,15 @@ export function nodeWriteNeedsConstraintFence( * It lives here rather than beside {@link nodeClaimSites} because it also folds * {@link edgeWriteNeedsConstraintFence}, and this module is the one that already * sees both per-kind predicates. + * + * Deliberately asks about CARDINALITY alone, never acyclicity: a claim row is + * a pre-insert reservation import's per-row recovery substitutes for the + * per-graph lock it does not take, and acyclicity has no claim row to + * reserve (`lockOnly`) — so this predicate would have nothing to report for + * it anyway. The lock-only question for an acyclic graph is + * {@link graphOwesLockOnlyFence}, a separate decision with a separate + * consumer: import takes the per-graph lock per chunk when it applies, + * rather than trying to substitute a claim that does not exist. */ export function graphOwesClaims( graph: GraphDef, @@ -169,15 +329,43 @@ export function graphOwesClaims( if (gating !== undefined) return gating.refusalReason; } } - for (const registration of Object.values(graph.edges)) { - const reason = edgeWriteNeedsConstraintFence( - registration.cardinality ?? "many", - ); - if (reason !== undefined) return reason; + for (const kind of Object.keys(graph.edges)) { + // Routed through `edgeKindFenceReason` — the one owner of "which reason + // does this edge kind's declaration qualify under, and in what preference + // order" — rather than re-spelling the cardinality-only half of that fold + // here. An acyclicity-only answer is filtered out below. + const reason = edgeKindFenceReason(graph, registry, kind); + if (reason === "edgeComposition" || reason === "edgeCardinality") { + return reason; + } } return undefined; } +/** + * Whether `importGraph` must take the per-graph write lock per chunk: the + * graph declares at least one `acyclic: true` edge kind, or any + * `partOf`/`hasPart` pair — the composition relation is an oriented union + * over the SAME acyclicity check, with the same `lockOnly` backing. + * + * Import takes no per-graph lock by design (`graphOwesClaims`'s docblock) and + * is fenced instead by the claim rows its constrained writes issue — + * acyclicity has no claim row, so that substitute does not exist here. This + * is therefore a real change to import's concurrency posture for such a + * graph: for the duration of each chunk transaction, every other writer of + * the graph blocks. `importGraphData` reads this before the first chunk and, + * on a backend with no transactions, refuses the whole import up front + * rather than probing unfenced. + */ +export function graphOwesLockOnlyFence( + graph: GraphDef, + registry: KindRegistry, +): ConstraintFenceReason | undefined { + return acyclicEdgeRelations(graph, registry).length === 0 ? + undefined + : "edgeAcyclicity"; +} + /** * Checks disjointness constraints for a node. * @@ -196,7 +384,7 @@ export async function checkDisjointnessConstraint( // For each disjoint kind, check if a node with this ID exists for (const disjointKind of disjointKinds) { const existing = await ctx.backend.getNode(ctx.graphId, disjointKind, id); - if (existing !== undefined && isLiveNodeRow(existing)) { + if (isLiveNodeRowPresent(existing)) { const error = checkDisjointness(id, kind, [disjointKind], ctx.registry); if (error) throw error; } @@ -204,70 +392,137 @@ export async function checkDisjointnessConstraint( } /** - * Checks cardinality constraints for an edge. + * The portable probe for every claim one edge write owes: the claim set + * itself, read against the live graph instead of the claim relation. * - * Reads {@link EDGE_CARDINALITY_SPECS} rather than re-spelling each - * cardinality's rules: which endpoints the axis covers (`keyShape`), whether an - * edge born already ended joins the population at all (`claimsWhenBornEnded`) - * and whether the population is the live one or the active one - * (`holderLiveness`) are the same three facts the claim's SQL reads. A probe - * that spelled its own copy would be the drift that accepts a write the fence + * Takes the CLAIMS — `edgeInsertClaims` for a create, the re-entry set for a + * resurrect or window reopen — rather than a declaration, so a write probes + * exactly what it then claims, never a superset and never a subset. The claim + * owners already applied every exemption (an edge born ended joins no + * active-only population; a window reopen re-enters only the axes it left), + * so this function decides nothing about WHICH axes are owed. Per claim it + * reads {@link edgeCardinalitySpec} rather than re-spelling each + * cardinality's rules: which endpoint the axis covers (`keyShape`) and + * whether the population is the live one or the active one + * (`holderLiveness`) are the same facts the claim's SQL reads. A probe that + * spelled its own copy would be the drift that accepts a write the fence * then refuses (or the reverse). * - * @throws CardinalityError if cardinality constraint is violated + * Probe order is {@link claimsInProbeOrder}: composition first. "One whole + * per part" spans every realizing edge kind, so it is counted across all of + * the claim's holders and refused as `CompositionError` naming the incumbent + * edge — the refusal the claim row raises on a backend that takes the fused + * path, so the two sides of that seam cannot disagree about the error class. + * It is also the only fence that invariant has on a backend with no claim + * relation, where the per-graph lock serializes writers but no row collides. + * + * **These are limits on edge count, not on distinct neighbours**: nothing in + * the count mentions the opposite endpoint on a `from`/`to` axis, so a second + * distinct edge from the same source to a target-`one` target is refused + * exactly as a second edge from a different source would be. + * + * @throws CompositionError if the part already holds a whole + * @throws CardinalityError if any constrained axis is violated */ -export async function checkCardinalityConstraint( +export async function checkEdgeCardinalityConstraints( ctx: ConstraintContext, - edgeKind: string, - cardinality: Cardinality, - fromKind: string, - fromId: string, - toKind: string, - toId: string, - validTo: string | undefined, + claims: readonly ClaimEdgeCardinalityParams[], ): Promise { - if (cardinality === "many") return; - const spec = EDGE_CARDINALITY_SPECS[cardinality]; + const ordered = claimsInProbeOrder(claims); + const composition = ordered.find((claim) => claim.scope !== undefined); + for (const claim of ordered) { + if (claim.scope !== undefined) { + const refusal = await compositionAttachmentViolation( + ctx, + claim, + claim.scope, + ); + if (refusal !== undefined) throw refusal; + continue; + } + // The composition count above already read this kind's own part-side + // population, so the ordinary axis that names the same population has + // nothing left to decide. + if (composition !== undefined && sameAxis(composition, claim)) continue; - // An edge born ended never joins an active-only population, so it has - // nothing to check and nothing to claim. - if (!spec.claimsWhenBornEnded && validTo !== undefined) return; + const spec = edgeCardinalitySpec(claim); + if (spec.keyShape === "fromAndTo") { + const exists = await ctx.backend.edgeExistsBetween({ + graphId: ctx.graphId, + edgeKind: claim.edgeKind, + fromKind: claim.fromKind, + fromId: claim.fromId, + toKind: claim.toKind, + toId: claim.toId, + }); + const error = edgeCardinalityViolation(claim, claim, exists ? 1 : 0); + if (error) throw error; + continue; + } - if (spec.keyShape === "fromAndTo") { - const exists = await ctx.backend.edgeExistsBetween({ + const endpoint = spec.keyShape; + const { endpointKind, endpointId } = + endpoint === "from" ? + { endpointKind: claim.fromKind, endpointId: claim.fromId } + : { endpointKind: claim.toKind, endpointId: claim.toId }; + const count = await ctx.backend.countEdgesAtEndpoint({ graphId: ctx.graphId, - edgeKind, - fromKind, - fromId, - toKind, - toId, + edgeKind: claim.edgeKind, + endpoint, + endpointKind, + endpointId, + activeOnly: spec.holderLiveness === "liveAndActive", }); - const error = checkUniqueEdge( - edgeKind, - fromKind, - fromId, - toKind, - toId, - exists ? 1 : 0, - ); + const error = edgeCardinalityViolation(claim, claim, count); if (error) throw error; - return; } +} - const count = await ctx.backend.countEdgesFrom({ - graphId: ctx.graphId, - edgeKind, - fromKind, - fromId, - activeOnly: spec.holderLiveness === "liveAndActive", - }); - const error = checkCardinality( - edgeKind, - fromKind, - fromId, - cardinality, - count, - count > 0, - ); - if (error) throw error; +function sameAxis( + left: EdgeCardinalityAxisRef, + right: EdgeCardinalityAxisRef, +): boolean { + return edgeCardinalityAxisName(left) === edgeCardinalityAxisName(right); +} + +/** + * The relation-wide half of the probe: whether the part a composition claim + * keys on already has an attachment through ANY realizing edge kind, in the + * population the claim's own axis counts. The portable rendering of the claim + * statement's competing-holder predicate — same holders, same part-side + * endpoint, same liveness. + * + * Counted through `countEdgesAtEndpoint`, one read per holder kind, so a + * batch's pending rows and a move's retiring row are seen through the same + * overlays that already adjust the ordinary axes. The incumbent edge is read + * only once a count has refused, to name it. + */ +async function compositionAttachmentViolation( + ctx: ConstraintContext, + claim: ClaimEdgeCardinalityParams, + scope: CompositionClaimScope, +): Promise { + const { part } = compositionClaimEndpoints(claim); + const activeOnly = + edgeCardinalitySpec(claim).holderLiveness === "liveAndActive"; + for (const holder of scope.holders) { + const count = await ctx.backend.countEdgesAtEndpoint({ + graphId: ctx.graphId, + edgeKind: holder.edgeKind, + endpoint: holder.partSide, + endpointKind: part.kind, + endpointId: part.id, + activeOnly, + }); + if (count <= 0) continue; + const incumbent = await findLiveCompositionAttachment( + ctx.registry, + ctx.backend, + ctx.graphId, + part.kind, + part.id, + ); + return claimRefusalFor(claim, incumbent?.edge.id); + } + return undefined; } diff --git a/packages/typegraph/src/store/evolution.ts b/packages/typegraph/src/store/evolution.ts index e0cb4a9d2..5567a10ee 100644 --- a/packages/typegraph/src/store/evolution.ts +++ b/packages/typegraph/src/store/evolution.ts @@ -5,6 +5,7 @@ import { StaleVersionError, } from "../errors"; import type { EvolutionPlan } from "../schema/evolution-plan"; +import { activeSchemaHash } from "../schema/manager"; import type { SchemaIdentity } from "../schema/types"; import { type StoreRef, type TransactionOutcome } from "./types"; @@ -96,10 +97,10 @@ export function assertEvolutionOptions( } /** Checks the fenced active schema against the snapshot that minted a plan. */ -export function assertEvolutionPlanBaseline( +export async function assertEvolutionPlanBaseline( plan: EvolutionPlan, active: SchemaVersionRow | undefined, -): void { +): Promise { if (active?.version !== plan.baseline.version) { throw new StaleVersionError({ graphId: plan.graphId, @@ -107,11 +108,12 @@ export function assertEvolutionPlanBaseline( actual: active?.version ?? 0, }); } - if (active.schema_hash !== plan.baseline.hash) { + const activeHash = await activeSchemaHash(active); + if (activeHash !== plan.baseline.hash) { throw new SchemaContentConflictError({ graphId: plan.graphId, version: active.version, - existingHash: active.schema_hash, + existingHash: activeHash, incomingHash: plan.baseline.hash, }); } diff --git a/packages/typegraph/src/store/history-store-backend.ts b/packages/typegraph/src/store/history-store-backend.ts index 70aee1480..70b4767c7 100644 --- a/packages/typegraph/src/store/history-store-backend.ts +++ b/packages/typegraph/src/store/history-store-backend.ts @@ -37,7 +37,7 @@ const HISTORY_STORE_BACKEND_KEYS = [ "lockSchemaVersionAndGraphWrite", "compileSql", "countEdgesByKind", - "countEdgesFrom", + "countEdgesAtEndpoint", "countNodesByKind", "createVectorIndex", "deleteEdge", @@ -188,6 +188,7 @@ type UnsafeHistoryStoreBackendMember = | "repairContributions" | "registerGraphTemplate" | "schemaWriteTransaction" + | "setActiveVersionWithPreflight" | "transaction" | "trustedImport"; diff --git a/packages/typegraph/src/store/index.ts b/packages/typegraph/src/store/index.ts index 49658131f..1dca8d24a 100644 --- a/packages/typegraph/src/store/index.ts +++ b/packages/typegraph/src/store/index.ts @@ -126,6 +126,7 @@ export { RecordedStoreView, StoreView } from "./store-view"; export type { AnyEdge, AnyNode, + SubgraphCompositionSelection, SubgraphEdgeKey, SubgraphEdgeLookup, SubgraphIndex, @@ -134,6 +135,7 @@ export type { SubgraphNodeLookup, SubgraphOptions, SubgraphResult, + SubgraphResultEdgeKinds, SubsetEdge, SubsetNode, } from "./subgraph"; diff --git a/packages/typegraph/src/store/introspect.ts b/packages/typegraph/src/store/introspect.ts index 7b60d7dc3..a4b1a55b7 100644 --- a/packages/typegraph/src/store/introspect.ts +++ b/packages/typegraph/src/store/introspect.ts @@ -20,6 +20,7 @@ import { type GraphDef, } from "../core/define-graph"; import { projectTargetKinds } from "../core/edge-endpoints"; +import { effectiveTargetCardinality } from "../core/edge-integrity-options"; import { canonicalAnnotations } from "../core/json-value"; import { type Cardinality, @@ -30,8 +31,13 @@ import { type KindAnnotations, type NodeRegistration, type NodeType, + type TargetCardinality, type UniquenessScope, } from "../core/types"; +import { + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + withUniqueConstraintDefaults, +} from "../core/unique-constraint-options"; import { type GraphExtension } from "../graph-extension/extension-types"; import { buildGraphExtensionOntologyKeySet, @@ -39,8 +45,15 @@ import { extensionKindNames, } from "../graph-extension/ontology-keys"; import { getTypeName } from "../ontology/types"; +import { + type CompositionExistence, + type CompositionPartSide, + compositionRelationFields, +} from "../registry/composition-relation"; +import { type KindRegistry } from "../registry/kind-registry"; import { serializeSchemaProperties } from "../schema/serializer"; import { type JsonSchema } from "../schema/types"; +import { edgeKindIsInAcyclicRelation } from "./acyclicity"; export type SchemaIntrospection = Readonly<{ graphId: string; @@ -86,7 +99,18 @@ export type EdgeIntrospection = Readonly<{ from: readonly string[]; to: readonly string[]; cardinality: Cardinality; + targetCardinality: TargetCardinality; endpointExistence: EndpointExistence; + /** + * Whether this edge kind's rows are enforced acyclic: declared + * `acyclic: true`, or a composition-realizing kind (a `partOf`/`hasPart` + * `via`), which the composition relation enforces acyclic across every + * realizing kind together. Required (never optional): introspection reports + * the RESOLVED value, not the declaration's sparse form — see + * `SerializedEdgeDef.acyclic`'s docblock for why the declaration itself is + * emit-when-true only. + */ + acyclic: boolean; properties: JsonSchema; annotations: KindAnnotations | undefined; deprecated: boolean; @@ -96,6 +120,12 @@ export type OntologyIntrospection = Readonly<{ metaEdge: string; from: string; to: string; + /** The realizing edge kind name. Present only for `partOf`/`hasPart`. */ + via?: string; + /** Which end of the edge is the part. Meaningful only alongside `via`. */ + partSide?: CompositionPartSide; + /** Whether the part must have a live whole. Meaningful only alongside `via`. */ + existence?: CompositionExistence; origin: "compile-time" | "runtime"; }>; @@ -108,6 +138,7 @@ export type UniqueIntrospection = Readonly<{ type IntrospectContext = Readonly<{ graphId: string; + registry: KindRegistry; schemaVersion: number | undefined; schemaHash: string | undefined; }>; @@ -131,12 +162,18 @@ export function introspectSchema( annotations: reg.type.annotations, deprecated: deprecated.has(name), properties: serializeSchemaProperties(reg.type.schema), - unique: (reg.unique ?? []).map((constraint) => ({ - name: constraint.name, - fields: [...constraint.fields], - scope: constraint.scope, - collation: constraint.collation, - })), + unique: (reg.unique ?? []).map((constraint) => { + const { scope, collation } = withUniqueConstraintDefaults( + constraint, + NODE_UNIQUE_CONSTRAINT_DEFAULTS, + ); + return { + name: constraint.name, + fields: [...constraint.fields], + scope, + collation, + }; + }), }); } @@ -151,7 +188,9 @@ export function introspectSchema( from: reg.from.map((entry) => (entry as AllNodeTypes & NodeType).kind), to: projectTargetKinds(reg.to), cardinality: reg.cardinality ?? "many", + targetCardinality: effectiveTargetCardinality(reg.targetCardinality), endpointExistence: reg.endpointExistence ?? "notDeleted", + acyclic: edgeKindIsInAcyclicRelation(graph, context.registry, name), properties: serializeSchemaProperties(edgeType.schema), annotations: edgeType.annotations, deprecated: deprecated.has(name), @@ -163,6 +202,7 @@ export function introspectSchema( metaEdge: relation.metaEdge.name, from: getTypeName(relation.from), to: getTypeName(relation.to), + ...compositionRelationFields(relation), origin: runtimeOntologyKeys.has(compileTimeOntologyKey(relation)) ? "runtime" : ( "compile-time" diff --git a/packages/typegraph/src/store/neighbors.ts b/packages/typegraph/src/store/neighbors.ts index 02c48327e..91c1f3fd6 100644 --- a/packages/typegraph/src/store/neighbors.ts +++ b/packages/typegraph/src/store/neighbors.ts @@ -26,7 +26,7 @@ import { rowToEdge, rowToNode } from "./row-mappers"; import type { Edge, GraphEdgeForKinds, - GraphNodeReference, + GraphNodeReferenceInput, Node, } from "./types"; @@ -130,7 +130,7 @@ export type NeighborRead< export function createNeighborRead>( ctx: NeighborContext, - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ): NeighborRead { validateOptions(ctx, options); @@ -165,7 +165,7 @@ export function createNeighborRead>( export async function readNeighbors>( ctx: NeighborContext, - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ): Promise[]> { return createNeighborRead(ctx, source, options).execute(); @@ -176,7 +176,7 @@ export async function countNeighbors< K extends EdgeKinds, >( ctx: NeighborContext, - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ): Promise { return createNeighborCountRead(ctx, source, options).execute(); @@ -187,7 +187,7 @@ export function createNeighborCountRead< K extends EdgeKinds, >( ctx: NeighborContext, - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ): ExecutableOneStatementRead { validateEdgeReadBounds(options, "countNeighbors"); @@ -214,7 +214,7 @@ export function createNeighborCountRead< function buildNeighborQuery>( ctx: NeighborContext, - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, aggregate: boolean, ): SqlFragment { diff --git a/packages/typegraph/src/store/node-reference-read.ts b/packages/typegraph/src/store/node-reference-read.ts new file mode 100644 index 000000000..b2dae9565 --- /dev/null +++ b/packages/typegraph/src/store/node-reference-read.ts @@ -0,0 +1,25 @@ +import { type GraphDef } from "../core/define-graph"; +import { requireDefined } from "../utils/presence"; +import { assertRegisteredNodeKinds } from "./registered-kinds"; + +/** The point read a node collection — live, transactional or pinned — exposes. */ +export type NodeByIdReader = Readonly<{ + getById: (id: string) => Promise; +}>; + +/** + * Reads the node a `{ kind, id }` reference names through the node collection + * `collectionFor(kind)` resolves, so every surface (store, transaction, view) + * resolves a reference with its own collections' scope and coordinate. A kind + * the graph does not register is refused with `KindNotFoundError` rather than + * read as an absent node; the refusal rejects the returned promise like a + * failed read. + */ +export async function readNodeByReference( + graph: GraphDef, + collectionFor: (kind: string) => NodeByIdReader | undefined, + reference: Readonly<{ kind: string; id: string }>, +): Promise { + assertRegisteredNodeKinds(graph, [reference.kind]); + return requireDefined(collectionFor(reference.kind)).getById(reference.id); +} diff --git a/packages/typegraph/src/store/operations/atomic-mutation-program.ts b/packages/typegraph/src/store/operations/atomic-mutation-program.ts index c1c3c2d15..8e7ebdc94 100644 --- a/packages/typegraph/src/store/operations/atomic-mutation-program.ts +++ b/packages/typegraph/src/store/operations/atomic-mutation-program.ts @@ -28,14 +28,49 @@ import type { GraphDef } from "../../core/define-graph"; import { DatabaseOperationError } from "../../errors"; import type { KindRegistry } from "../../registry/kind-registry"; import { hasOwnKey } from "../../utils/object"; +import { edgeKindIsInAcyclicRelation } from "../acyclicity"; +import { edgeCardinalityAxisReferences } from "../claims/edge-claims"; +import { edgeKindFenceReason } from "../constraints"; import { getEmbeddingFields } from "../embedding-sync"; import { getSearchableFields } from "../fulltext-sync"; import type { CreateEdgeInput, CreateNodeInput } from "../types"; +import { compositionEdgeHasRequiredExistencePart } from "./composition-create"; import { diagnoseFusedSchemaFenceNoRow } from "./write-transaction"; +/** + * Whether every live node of `kind` owes a composition edge — a + * required-existence part kind. A node write that has no shape for that + * edge (a fused program, a node-only upsert batch) must refuse or fall back + * for such a kind, even when no `partOf` is stated: `resolveCompositionCreate` + * is what refuses the bare create on the portable path. + */ +export function nodeKindOwesCompositionEdge( + registry: KindRegistry, + kind: string, +): boolean { + return registry.compositionExistence(kind) === "required"; +} + +/** + * Whether `item` owes a composition edge a fused node-create + * program has no shape for — a stated `partOf`, or a kind for which + * {@link nodeKindOwesCompositionEdge} holds. The one predicate both + * node-create fused-eligibility checks below share, so neither re-spells it. + */ +function nodeCreateOwesCompositionEdge( + registry: KindRegistry, + item: CreateNodeInput, +): boolean { + return ( + item.partOf !== undefined || + nodeKindOwesCompositionEdge(registry, item.kind) + ); +} + type CommonAtomicMutationEligibility = Readonly<{ backend: GraphBackend | TransactionBackend; graph: GraphDef; + registry: KindRegistry; schemaVersion: number | undefined; historyEnabled: boolean; revisionTrackingEnabled: boolean; @@ -96,6 +131,18 @@ export function resolveAtomicNodeBatchExecutor( input: AtomicNodeBatchEligibilityInput, ): BackendAtomicNodeBatchExecutor | undefined { if (input.inputs.length === 0 || input.identityEnabled) return; + // A fused node-batch program writes node rows only — it has no + // shape for the composition edge a required-existence kind or a stated + // `partOf` also owes. Declined by declaration, before any row is read: a + // fused command is an optimization attempt, not evidence that its + // dimensions ran. + if ( + input.inputs.some((item) => + nodeCreateOwesCompositionEdge(input.registry, item), + ) + ) { + return; + } const profile = resolveAtomicMutationProfile(input); if (profile?.createNodes === undefined) return; const claimSupport = profile.createNodes.claimSupport; @@ -158,6 +205,12 @@ export function resolveAtomicNodeReplacementBatchProgram( if (!hasOwnKey(input.graph.nodes, input.kind)) return; const registration = input.graph.nodes[input.kind]; if (registration === undefined) return; + // A replacement that lands on no existing row CREATES the node, + // and this fused program has no `partOf` parameter and no composition-edge + // shape — a required-existence kind must take the portable path so + // `resolveCompositionCreate` gets to refuse the bare create rather than + // this program silently writing an orphan. + if (nodeKindOwesCompositionEdge(input.registry, input.kind)) return; const executor = resolveAtomicMutationProfile(input)?.replaceNodes; if (executor === undefined) return; const releasedClaimFamilies = new Set(executor.releasedClaimFamilies); @@ -238,7 +291,13 @@ export function resolveAtomicEdgeConvergenceExecutor( if (registration?.matchIdentity === undefined) { return; } - if ((registration.cardinality ?? "many") !== "many") return; + // No native program applies a constrained cardinality axis or acyclicity; + // such a create must re-enter the portable path, which probes and refuses. + if ( + edgeKindFenceReason(input.graph, input.registry, input.kind) !== undefined + ) { + return; + } const declaredFields = registration.matchIdentity.fields; if ( declaredFields.length !== input.matchOn.length || @@ -267,11 +326,51 @@ export function resolveAtomicEdgeBatchExecutor( !input.inputs.every( (item) => hasOwnKey(input.graph.edges, item.kind) && - input.graph.edges[item.kind] !== undefined, + input.graph.edges[item.kind] !== undefined && + // No native program applies acyclicity: acyclicity's fence has no + // database key to claim through (`CONSTRAINT_FENCE_BACKING. + // edgeAcyclicity === "lockOnly"`), which this fused program's claim + // rows cannot express. Any acyclic kind in the batch sends the WHOLE + // batch through the portable path, which probes the combined set + // once, as an overlay on the stored relation, before its first + // insert. + // This ALREADY excludes every composition edge kind, with no + // separate check needed: `compositionAcyclicRelation` + // (`src/store/acyclicity.ts`) folds every composition-realizing edge + // kind into the acyclicity relation's union (`src/store/acyclicity.ts`) the moment the graph declares ANY + // `partOf`/`hasPart` pair, so `edgeKindIsInAcyclicRelation` answers + // `true` for such a kind regardless of whether ITS OWN write would + // close a cycle. That is load-bearing here for an unrelated reason: + // a composition edge kind owes a SECOND claim (the reserved + // relation-wide axis, `compositionClaim`) beyond whatever ordinary + // axis its own registration declares — a fact the two-axis check + // below cannot see, because it counts only + // `edgeCardinalityAxisReferences`. `assertMatchingFusedEdgeClaim` + // (`operation-backend-core.ts`) is the belt behind this gate, for + // both reasons alike. + !edgeKindIsInAcyclicRelation(input.graph, input.registry, item.kind), ) ) { return; } + // A kind declaring BOTH axes claims two rows per edge; the native program + // matches claim results back to edges one-to-one + // (`executeAtomicEdgeBatch`), so it refuses the whole batch rather than + // silently dropping the second axis for the affected rows. The portable + // path (`createEdge`'s single-write route) handles a two-axis kind fine — + // it issues every claim in `claimEdgeCardinalities`'s loop, not a single + // matched statement. + if ( + input.inputs.some((item) => { + const registration = input.graph.edges[item.kind]; + return ( + registration !== undefined && + edgeCardinalityAxisReferences(registration).length > 1 + ); + }) + ) { + return; + } return profile.createEdges; } @@ -288,6 +387,17 @@ export function resolveAtomicEdgeDeleteBatchExecutor( ): AtomicEdgeDeleteBatchExecutor | undefined { if (input.ids.length === 0) return; if (!hasOwnKey(input.graph.edges, input.expectedKind)) return; + // `assertCompositionExistencePreserved` reads the part row under + // the held write lock — a decision this read-free fused command cannot + // express. A composition edge kind realizing a required-existence part + // must take the portable path for its delete, exactly as its create-side + // counterpart (`resolveAtomicNodeBatchExecutor`) declines the fused + // program for a required-existence node create. + if ( + compositionEdgeHasRequiredExistencePart(input.registry, input.expectedKind) + ) { + return; + } return resolveAtomicMutationProfile(input)?.deleteEdges; } @@ -308,6 +418,31 @@ export function resolveAtomicNodeDeleteBatchExecutor( if (!hasOwnKey(input.graph.nodes, input.kind)) return; const registration = input.graph.nodes[input.kind]; if (registration === undefined) return; + // A composition whole's delete cascades to its parts through the portable + // pipeline (`planCompositionCascade`, `node-operations.ts`) — a decision + // that reads rows and recurses, which the fused command's single read-free + // SQL shape cannot express. Statically ineligible by declaration, before + // any row is read: "does this kind declare parts?" is a property of the + // registry, not of this particular delete. + // + // A kind that is itself a composition PART (`registry.isCompositionPart`) + // is equally ineligible, for a second, independent reason: composition edges + // never count against `restrict`, on either end + // (a composition edge is never a restrict obstacle), and only the portable + // path's `enforceNodeDeleteBehavior` (`node-write-pipeline.ts`) knows how + // to exclude them from its restrict count — the fused command's read-free + // refusal diagnosis has no such filter and would misreport a composition + // edge as a live restrict obstacle. This is the second, declared owner + // `nodeDeleteRequiresPortablePath`'s doc cross-references: that predicate + // owns the request-shaped dimensions (`enforceDeleteBehavior`, + // `consumedEdgeIds`, an `expect` plan); this guard owns the registry-shaped + // one (static composition participation). + if ( + input.registry.isCompositionWhole(input.kind) || + input.registry.isCompositionPart(input.kind) + ) { + return; + } const executor = resolveAtomicMutationProfile(input)?.deleteNodes; if (executor === undefined) return; const releasedClaimFamilies = new Set(executor.releasedClaimFamilies); @@ -375,13 +510,17 @@ function supportsAtomicResolvedNodeKindProjections( } function isAtomicResolvedEdgeKindEligible( - input: Readonly<{ graph: GraphDef; kind: string }>, + input: Readonly<{ graph: GraphDef; registry: KindRegistry; kind: string }>, ): boolean { if (!hasOwnKey(input.graph.edges, input.kind)) return false; const registration = input.graph.edges[input.kind]; return ( registration !== undefined && - (registration.cardinality ?? "many") === "many" && + // No native program applies a constrained cardinality axis, acyclicity, + // or composition; such a create must re-enter the portable path, which + // probes and refuses. + edgeKindFenceReason(input.graph, input.registry, input.kind) === + undefined && registration.matchIdentity === undefined ); } @@ -450,6 +589,16 @@ export function resolveAtomicNodeResolvedMutationSetExecutor( ) { return; } + // Same reasoning as the other two node-create fused resolvers — + // no shape here for the composition edge a `partOf` or a required-existence + // kind also owes. + if ( + input.creates.some((item) => + nodeCreateOwesCompositionEdge(input.registry, item), + ) + ) { + return; + } const executor = resolveAtomicMutationProfile(input)?.mutateNodes; if ( executor === undefined || diff --git a/packages/typegraph/src/store/operations/autocommit-single-statement.ts b/packages/typegraph/src/store/operations/autocommit-single-statement.ts index 7bb187df9..61ed7de41 100644 --- a/packages/typegraph/src/store/operations/autocommit-single-statement.ts +++ b/packages/typegraph/src/store/operations/autocommit-single-statement.ts @@ -46,7 +46,14 @@ export type EdgeAutocommitSingleStatementCandidate = Readonly<{ revisionTrackingEnabled: boolean; kindRegistered: boolean; convergesDynamically: boolean; - cardinality: "many" | "one" | "unique" | "oneActive"; + /** + * Whether the edge kind declares any cardinality axis, source or target, or + * `acyclic: true` — the same {@link edgeWriteNeedsConstraintFence} fold the + * write path itself consults. No native single-statement program applies + * either check, so a constrained edge declines both fused paths below and + * re-enters the portable route, which probes and refuses. + */ + constrained: boolean; }>; export type AutocommitSingleStatementCandidate = @@ -123,7 +130,7 @@ export function canFuseSchemaFenceInFirstWrite( !candidate.revisionTrackingEnabled && candidate.kindRegistered && !candidate.convergesDynamically && - candidate.cardinality === "many" + !candidate.constrained ); } } @@ -197,7 +204,7 @@ export function isAutocommitSingleStatementWrite( !candidate.revisionTrackingEnabled && candidate.kindRegistered && !candidate.convergesDynamically && - candidate.cardinality === "many" + !candidate.constrained ); } } diff --git a/packages/typegraph/src/store/operations/composition-cascade.ts b/packages/typegraph/src/store/operations/composition-cascade.ts new file mode 100644 index 000000000..036fbb3d7 --- /dev/null +++ b/packages/typegraph/src/store/operations/composition-cascade.ts @@ -0,0 +1,583 @@ +/** + * The composition cascade's read side: the parts closure of one whole, + * planned under the per-graph write lock so a concurrent attach cannot slip + * a part past it (see {@link planCompositionCascade}). + * + * This module owns exactly one decision, `compositionEdgeCounts`, and one + * traversal, `planCompositionCascade`. Everything that deletes a composition + * whole — the node-delete pipeline's runtime cascade and merge's plan-time + * and apply-time orphan checks — reads the closure through this module, never + * by re-walking composition edges itself. + */ +import { + type EdgeRow, + type GraphBackend, + type GraphReadBackend, + isLiveNodeRowPresent, + type NodeRow, +} from "../../backend/types"; +import { CompilerInvariantError, CompositionCycleError } from "../../errors"; +import { + type CompositionPair, + type CompositionPartSide, + compositionPopulationMembership, + orientCompositionEndpoints, +} from "../../registry/composition-relation"; +import { type KindRegistry } from "../../registry/kind-registry"; +import { compareStringTuples } from "../../utils/compare"; +import { nodeRefKey } from "../../utils/tuple-key"; +import { membershipCountsRow } from "../claims/edge-claims"; +import { + fenceLockOnlyDecision, + type GraphWriteLock, +} from "../recorded-capture/clock"; +import { type CompositionCascadedPart } from "../types"; + +/** + * One node the walk discovered, with the composition edge that binds it. + * {@link liveDiscoveredMembers} keeps it in `CompositionCascadePlan.members` + * only while its node row is live. + * + * The plan carries no node row: the liveness read proves the member was there + * when the plan was made, and each member's own delete reads its pre-image + * again at the moment it writes. A writer that takes no per-graph lock (an + * ordinary property update) can commit between the two, and a delete written + * against the planned row would release the uniqueness entries of props the + * node no longer has. + * + * Not exported beyond this module: every consumer reaches a member only + * through `CompositionCascadePlan.members`, structurally. + */ +type DiscoveredCascadeMember = Readonly<{ + kind: string; + id: string; + /** The composition edge id binding this member to its whole in this cascade. */ + viaEdgeId: string; + /** The composition edge KIND realizing this member's membership (`row.kind`). */ + viaEdgeKind: string; + /** + * This member's OWN immediate whole — the node it is directly a part of, + * which for a depth >= 2 member is an intermediate part, not the cascade's + * root. Consumers that need "which composition pair realizes this + * membership" read `viaEdgeKind`/`whole` off the member rather than + * re-deriving it via `registry.compositionPairsBetween(member.kind, root.kind)` + * — that re-derivation is wrong past depth 1 (the root is not necessarily + * the immediate whole) and is exactly the kind of second spelling this + * module exists to avoid. + */ + whole: CascadeNode; +}>; + +export type CompositionCascadePlan = Readonly<{ + /** + * LIVE parts in the cascade's delete order: LEAF-FIRST, then a + * deterministic order by kind, then id, within one level of the closure + * ({@link cascadeDeletionOrder}). Empty when the whole declares no parts. + * A member whose node row is already dead (see {@link liveDiscoveredMembers}) + * is excluded — it is not something for a caller to retire/purge, nor a + * composition orphan for merge to report. + */ + members: readonly DiscoveredCascadeMember[]; + /** Every composition edge id the cascade consumes, including the roots'. */ + consumedEdgeIds: ReadonlySet; +}>; + +const EMPTY_COMPOSITION_CASCADE_PLAN: CompositionCascadePlan = { + members: [], + consumedEdgeIds: new Set(), +}; + +/** + * The plan's members as {@link CompositionCascadedPart}s — each part with its + * own immediate whole and the edge kind that bound it — in the cascade's own + * delete order: leaf-first, then deterministically by kind, then id, within + * each level ({@link cascadeDeletionOrder}). What a whole's delete reports to + * its operation hook and to the transaction receipt (`cascadedParts`), and + * what `planDelete` previews. + * + * A projection of the plan the cascade ALREADY computes, not a second walk. + * A writing delete reports the members its cascade actually deleted + * (`applyCompositionCascade`, `node-operations.ts`), so a consumer + * invalidating a cache from `cascadedParts` can never be told about a part + * the cascade did not delete (or miss one it did). + */ +export function cascadedPartReferences( + plan: CompositionCascadePlan, +): readonly CompositionCascadedPart[] { + return plan.members.map((member) => ({ + kind: member.kind, + id: member.id, + whole: { kind: member.whole.kind, id: member.whole.id }, + viaEdgeKind: member.viaEdgeKind, + })); +} + +/** + * Whether one composition edge row still counts as a live membership under + * its pair's declared whole-side population. + * + * {@link membershipCountsRow} under {@link compositionPopulationMembership}'s rule, + * applied to a row already known non-deleted (every reader of this predicate + * excludes deleted rows before calling it). + * + * `population: "one"` (`holderLiveness: "live"`) counts unconditionally: the + * part/whole binding persists for the row's entire life, ended or not. + * `population: "oneActive"` (`holderLiveness: "liveAndActive"`) counts only + * an open-ended row (`valid_to === undefined`) — an ended row survives as + * history for a reparented part and must not re-attach it to a whole that no + * longer holds it. + */ +export function compositionEdgeCounts( + pair: Pick, + row: Pick, +): boolean { + return membershipCountsRow(compositionPopulationMembership(pair.population), { + validTo: row.valid_to, + }); +} + +type CascadeNode = Readonly<{ kind: string; id: string }>; + +/** Which endpoint of `edgeKind` carries the WHOLE — the mirror of `compositionPartSide`. */ +export function wholeSide(partSide: CompositionPartSide): "from" | "to" { + return partSide === "from" ? "to" : "from"; +} + +/** A composition row's two endpoints, named by the role each one plays. */ +type CompositionRowEndpoints = Readonly<{ + part: Readonly<{ kind: string; id: string }>; + whole: Readonly<{ kind: string; id: string }>; +}>; + +/** + * THE owner of "which endpoint of this composition row is the part and which + * is the whole", for one row and the orientation its edge kind declares. + * Every consumer — the cascade's walk, the existence-preservation refusal, + * the live-attachment reader — reads both roles from here rather than + * re-spelling the orientation fold, so a change in how a row's part endpoint + * is chosen has one place to land. + */ +export function compositionRowEndpoints( + partSide: CompositionPartSide, + row: Pick, +): CompositionRowEndpoints { + return orientCompositionEndpoints( + partSide, + { kind: row.from_kind, id: row.from_id }, + { kind: row.to_kind, id: row.to_id }, + ); +} + +/** + * The declared composition pair a live row realizes, or a + * should-be-impossible invariant refusal. + * + * The lookup is by `row.kind`, not "the first declared pair between these two + * kinds": two realizing edges may hold the same (part, whole) pair, and the + * pair a row is judged under must be the one the row itself realizes. The + * refusal is unreachable from any registry-built graph — every endpoint pair + * a composition edge kind admits must itself be a declared pair + * (`ONTOLOGY_COMPOSITION_VIA_MIXED`) — which is what makes it an invariant + * rather than a user-facing error. One owner so both the cascade's walk and + * the attachment move raise the identical message and payload. + */ +export function requireCompositionPairVia( + registry: KindRegistry, + partKind: string, + wholeKind: string, + edgeKind: string, +): CompositionPair { + const pair = registry.compositionPairVia(partKind, wholeKind, edgeKind); + if (pair !== undefined) return pair; + throw new CompilerInvariantError( + `A composition read resolved composition edge "${edgeKind}" between "${partKind}" and "${wholeKind}", but the registry declares no composition pair between them realized by that edge kind.`, + { edgeKind, partKind, wholeKind }, + ); +} + +/** {@link readWholeSideEdges}'s result: the rows, and whether the set-read port answered. */ +type WholeSideEdgesResult = Readonly<{ + rows: readonly EdgeRow[]; + /** + * Whether `findEdgesByHeterogeneousEndpointSet` answered this round at + * all — with rows, or with a caller-trusted empty result. `false` only + * when the port is unavailable, or when this round paid the one-time + * fallback confirmation itself. The caller uses this to stop asking for + * that confirmation once the port has proven itself for this cascade. + */ + setReadAnswered: boolean; +}>; + +/** + * Reads one round's whole-side composition edges: every live composition + * edge (of the given kinds) whose WHOLE endpoint is a member of `frontier`. + * + * Prefers `findEdgesByHeterogeneousEndpointSet` — the same set read + * `findConnectedEdgesForNodeBatch` (`node-operations.ts`) uses — split into + * (at most) two calls, one per orientation, since one call's `side` applies + * uniformly to every edge kind it names, and always combined into ONE + * result before any trust decision: an empty result is judged on the + * COMBINED rows across both orientations, never per-orientation, so a + * nonempty `from`-side never short-circuits trust of an empty `to`-side (or + * vice versa) — the same disposition applies uniformly to both. + * + * Falls back to the kind-blind `findEdgesConnectedTo`, filtered to this + * round's edge kinds and their whole-side orientation, only when the set + * read is unavailable, or when `trustEmptyResult` is `false` AND the set + * read came back with no rows at all: no licensed rows is insufficient + * evidence that none exist (the same disposition `findConnectedEdgesForNodeBatch` + * states) until the caller has confirmed it once for this cascade. + * `trustEmptyResult: true` (every round after the first has confirmed the + * port) skips that confirmation — an unbounded closure would otherwise pay + * one extra per-frontier-node read at EVERY childless round, not just the + * first, for evidence the first round already established. + * `findEdgesByHeterogeneousEndpointSet` itself applies no temporal filter + * beyond `excludeDeleted` (see `buildTemporalConditions`), so it always + * returns an ended-but-undeleted row exactly as `findEdgesConnectedTo` + * does — the population decision is `compositionEdgeCounts`' alone, applied + * to whichever read answered. See `tests/composition-cascade.test.ts`'s + * direct assertion on the set read. + */ +async function readWholeSideEdges( + ctx: Readonly<{ graphId: string; registry: KindRegistry }>, + frontier: readonly CascadeNode[], + edgeKinds: readonly string[], + backend: GraphReadBackend, + trustEmptyResult: boolean, +): Promise { + const combined = await readCompositionEdgesAtRole( + ctx, + backend, + "whole", + frontier, + edgeKinds, + ); + if (combined !== undefined && (combined.length > 0 || trustEmptyResult)) { + return { rows: combined, setReadAnswered: true }; + } + + const edgeKindSet = new Set(edgeKinds); + const rowsPerNode = await Promise.all( + frontier.map((node) => + backend.findEdgesConnectedTo({ + graphId: ctx.graphId, + nodeKind: node.kind, + nodeId: node.id, + }), + ), + ); + const seenIds = new Set(); + const filtered: EdgeRow[] = []; + for (const [index, node] of frontier.entries()) { + for (const row of rowsPerNode[index] ?? []) { + if (row.deleted_at !== undefined) continue; + if (!edgeKindSet.has(row.kind)) continue; + const { whole: wholeEndpoint } = compositionRowEndpoints( + requirePartSide(ctx.registry, row.kind), + row, + ); + if (wholeEndpoint.kind !== node.kind || wholeEndpoint.id !== node.id) { + continue; + } + if (seenIds.has(row.id)) continue; + seenIds.add(row.id); + filtered.push(row); + } + } + // The set-read port (if any) is now confirmed for this cascade: this + // round paid the fallback and it agreed with the port's empty verdict + // (or there was no port to confirm), so future rounds may trust it. + return { + rows: filtered, + setReadAnswered: backend.findEdgesByHeterogeneousEndpointSet !== undefined, + }; +} + +/** + * Every undeleted row of `edgeKinds` whose `role` endpoint is one of + * `endpoints`, read with `findEdgesByHeterogeneousEndpointSet` — one statement + * per orientation (an orientation no edge kind takes issues none), since one + * call's `side` applies uniformly to every edge kind it names — and combined + * from-side first. `undefined` when the backend omits that optional port, + * which leaves the caller on its own fallback. + * + * The port applies no temporal filter beyond `excludeDeleted`, exactly like + * the `findEdgesConnectedTo` read it stands in for, so the population + * decision stays the caller's. + */ +export async function readCompositionEdgesAtRole( + ctx: Readonly<{ graphId: string; registry: KindRegistry }>, + backend: GraphReadBackend, + role: "part" | "whole", + endpoints: readonly CascadeNode[], + edgeKinds: readonly string[], +): Promise { + const setRead = backend.findEdgesByHeterogeneousEndpointSet; + if (setRead === undefined) return undefined; + const fromSideEdgeKinds: string[] = []; + const toSideEdgeKinds: string[] = []; + for (const edgeKind of edgeKinds) { + const partSide = requirePartSide(ctx.registry, edgeKind); + const side = role === "part" ? partSide : wholeSide(partSide); + (side === "from" ? fromSideEdgeKinds : toSideEdgeKinds).push(edgeKind); + } + const endpointReferences = endpoints.map((node) => ({ + kind: node.kind, + id: node.id, + })); + const [fromSideRows, toSideRows] = await Promise.all([ + fromSideEdgeKinds.length === 0 ? + Promise.resolve([]) + : setRead({ + graphId: ctx.graphId, + side: "from", + endpoints: endpointReferences, + edgeKinds: fromSideEdgeKinds, + excludeDeleted: true, + }), + toSideEdgeKinds.length === 0 ? + Promise.resolve([]) + : setRead({ + graphId: ctx.graphId, + side: "to", + endpoints: endpointReferences, + edgeKinds: toSideEdgeKinds, + excludeDeleted: true, + }), + ]); + return [...fromSideRows, ...toSideRows]; +} + +function requirePartSide( + registry: KindRegistry, + edgeKind: string, +): CompositionPartSide { + const partSide = registry.compositionPartSide(edgeKind); + if (partSide === undefined) { + throw new CompilerInvariantError( + `planCompositionCascade read edge kind "${edgeKind}" as a composition edge, but the registry no longer classifies it as one.`, + { edgeKind }, + ); + } + return partSide; +} + +/** + * How a cascade reads its discovered members' node rows: one row (or + * `undefined`) per member, index-aligned with `members`. A delete that goes on + * to write supplies a reader that takes each member's delete lock as it reads + * (`node-operations.ts`), so the liveness the plan keeps cannot change before + * the member's own delete; a preview supplies {@link readCascadeMemberRows}. + */ +export type CascadeMemberRowReader = ( + members: readonly Readonly<{ kind: string; id: string }>[], +) => Promise; + +/** The lock-free {@link CascadeMemberRowReader}: one `getNode` per member. */ +export function readCascadeMemberRows( + ctx: Readonly<{ graphId: string }>, + backend: GraphReadBackend, +): CascadeMemberRowReader { + return (members) => + Promise.all( + members.map((member) => + backend.getNode(ctx.graphId, member.kind, member.id), + ), + ); +} + +/** + * Drops discovered members whose node row is no longer live. + * + * A composition edge row and its endpoint's node row can go stale relative + * to each other: a direct part delete cleans up its OWN composition edges + * (see `enforceNodeDeleteBehavior`'s restrict arm, `node-write-pipeline.ts`), + * but an ended-but-undeleted `oneActive` reparent or a pre-fix write can + * still leave a live composition edge pointing at an already-tombstoned + * node. A dead node is not a composition ORPHAN — there is nothing left for + * a caller to act on — so this is the ONE place that liveness is checked + * before a member is reported: both the runtime cascade (which retires/ + * purges each `members` entry) and merge's plan/apply-time orphan reports + * read the SAME filtered closure, rather than each re-deriving "is this + * member actually still there" on its own. + * + * One batched read: `discoveryOrder` is already the complete, deduplicated + * closure, so one call of `readMemberRows` suffices — no need to interleave + * this with the round-by-round edge traversal above, which must still walk + * THROUGH a dead member to find any live descendants beneath it (edge reads + * are keyed by `(kind, id)`, not by the node's own liveness). + */ +async function liveDiscoveredMembers( + readMemberRows: CascadeMemberRowReader, + discoveryOrder: readonly DiscoveredCascadeMember[], +): Promise { + const nodeRows = await readMemberRows(discoveryOrder); + return discoveryOrder.filter((_member, index) => + isLiveNodeRowPresent(nodeRows[index]), + ); +} + +/** + * THE order a cascade deletes its members in, and therefore the order every + * consumer reports them in (`cascadedParts` on a receipt and on the delete's + * operation-hook context, merge's orphan reports): LEAF-FIRST — every part + * before the whole it belongs to — and, within one level of the closure, a + * deterministic order by kind, then id. + * + * Leaf-first is the part of the order that carries meaning: a part's own + * delete must run while its whole is still there. Two SIBLING parts of one + * whole have no such relation, and the order the walk discovered them in is + * the order the whole-side edge read happened to return — which is not a + * promise any engine makes and, in practice, varies with the generated ids of + * the rows involved. Sorting each level closes that: one whole's cascade + * reports the same list on every run and on every backend, so a consumer may + * compare `cascadedParts` for equality, and two concurrent cascades over + * overlapping closures take their row locks in one agreed order. + * + * The order itself is {@link compareStringTuples}' — UTF-16 code-unit order on + * `kind`, then `id`. Any total order would serve: nothing compares this list + * against a SQL `ORDER BY`, so the cheaper comparator is the right one and + * `compareCodePoints` (which exists for orders that must match an engine's) + * would be a promise this list does not need to keep. + * + * Rounds arrive deepest-LAST (breadth-first discovery) and are reversed here; + * each round is sorted on the way out, so the sort is ascending in the + * FINAL order rather than in discovery order. + */ +function cascadeDeletionOrder( + discoveryRounds: readonly (readonly DiscoveredCascadeMember[])[], +): readonly DiscoveredCascadeMember[] { + return discoveryRounds + .toReversed() + .flatMap((round) => + round.toSorted((left, right) => + compareStringTuples([left.kind, left.id], [right.kind, right.id]), + ), + ); +} + +/** + * THE parts closure of one whole, under the per-graph write lock. + * + * `lock` is compile-time evidence the caller took the per-graph write lock + * before any row read, so a concurrent attach cannot slip a part past the + * cascade. Breadth-first, one round per level: each round reads the + * frontier's whole-side composition edges, admits each row through + * {@link compositionEdgeCounts}, and folds newly-discovered `(kind, id)` + * pairs into the next frontier. + * + * Termination is the VISITED SET, not a kind-level depth bound: composition + * may be reflexive, so a kind-level closure of one edge kind places no + * bound on instance depth. The visited set is finite because the graph is; a + * round that discovers no new member ends the walk normally. A row that + * resolves to an ALREADY-visited member is an INSTANCE-level cycle. The + * write path refuses one: every composition-realizing edge kind belongs to + * the oriented composition union (`compositionAcyclicRelation`, + * `src/store/acyclicity.ts`), probed at write time, so a cycle can only + * reach this walk through rows written before the relation was declared, by + * trusted import, or by direct SQL. The walk cannot silently truncate a + * revisit — that would produce a silent orphan — so it stops and throws the + * typed, user-facing {@link CompositionCycleError} instead. + * + * Deliberately not `buildReachableCte`: one CTE carries one temporal mode, + * and a closure spanning a `one` level and an `oneActive` level needs both. + * Level-by-level with the population predicate applied by its one owner is + * exact on every backend and needs no recursive-traversal capability. + * + * `members` is ordered by {@link cascadeDeletionOrder} — leaf-first, then + * deterministically by kind, then id, within each level — never in the order + * the edge reads returned their rows. Their liveness is read through + * `readMemberRows` once the closure is complete; a member whose row is locked + * by that read stays exactly as live as the plan says until the frame ends. + */ +export async function planCompositionCascade( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + }>, + wholeKind: string, + wholeId: string, + backend: GraphReadBackend & + Pick, + readMemberRows: CascadeMemberRowReader = readCascadeMemberRows(ctx, backend), +): Promise { + const edgeKinds = ctx.registry.compositionEdgeKindsUnder(wholeKind); + if (edgeKinds.length === 0) return EMPTY_COMPOSITION_CASCADE_PLAN; + + // The closure below is a lock-only read: no claim row records "this whole + // has these parts", so a part attached while this session waited for the + // fence is found only by a snapshot taken after the wait. + await fenceLockOnlyDecision(backend, ctx.graphId, ctx.lock, { + code: "COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT", + subject: "Deleting a composition whole", + }); + + const root: CascadeNode = { kind: wholeKind, id: wholeId }; + const visited = new Set([nodeRefKey(root)]); + const discoveryRounds: DiscoveredCascadeMember[][] = []; + const consumedEdgeIds = new Set(); + + let frontier: readonly CascadeNode[] = [root]; + let setReadTrusted = false; + while (frontier.length > 0) { + const { rows, setReadAnswered } = await readWholeSideEdges( + ctx, + frontier, + edgeKinds, + backend, + setReadTrusted, + ); + setReadTrusted ||= setReadAnswered; + const nextFrontier: CascadeNode[] = []; + const roundMembers: DiscoveredCascadeMember[] = []; + for (const row of rows) { + const { part, whole: wholeOfRow } = compositionRowEndpoints( + requirePartSide(ctx.registry, row.kind), + row, + ); + // Population cannot differ between two pairs applicable to one concrete + // part kind (`ONTOLOGY_COMPOSITION_POPULATION_MIXED` refuses that + // ontology), so the verdict below is the same for either; the by-`via` + // lookup is what keeps the invariant honest. + const pair = requireCompositionPairVia( + ctx.registry, + part.kind, + wholeOfRow.kind, + row.kind, + ); + if (!compositionEdgeCounts(pair, row)) continue; + + const key = nodeRefKey(part); + if (visited.has(key)) { + throw new CompositionCycleError({ + wholeKind, + wholeId, + revisitedKind: part.kind, + revisitedId: part.id, + }); + } + visited.add(key); + nextFrontier.push(part); + consumedEdgeIds.add(row.id); + roundMembers.push({ + kind: part.kind, + id: part.id, + viaEdgeId: row.id, + viaEdgeKind: row.kind, + whole: wholeOfRow, + }); + } + if (roundMembers.length > 0) discoveryRounds.push(roundMembers); + frontier = nextFrontier; + } + + // Ordered BEFORE the liveness read, which preserves the order it is given, + // so the plan's order is fixed by the closure alone — never by the order any + // read returned its rows. + const liveMembers = await liveDiscoveredMembers( + readMemberRows, + cascadeDeletionOrder(discoveryRounds), + ); + return { members: liveMembers, consumedEdgeIds }; +} diff --git a/packages/typegraph/src/store/operations/composition-create.ts b/packages/typegraph/src/store/operations/composition-create.ts new file mode 100644 index 000000000..07691962f --- /dev/null +++ b/packages/typegraph/src/store/operations/composition-create.ts @@ -0,0 +1,2213 @@ +/** + * `existence: "required"`: a composition part that cannot exist + * without a live whole. + * + * THE two owners, in one module because both directions of + * the same rule ("a required part always has a live whole") belong beside + * each other: + * + * - {@link resolveCompositionCreate} — every node-create path's ONE answer + * to "given this kind's declared existence and the caller's stated + * `partOf`, what composition edge does this create owe, and is the pair + * legal". Reached by `create`, `bulkCreate`, and both get-or-create + * entries (`src/store/operations/node-operations.ts`). + * - {@link assertCompositionExistencePreserved} — the refusal every path + * that would separate a live required part from its whole raises: ending + * a composition edge's window, soft-deleting it, or hard-deleting it. The + * three call sites live in `src/store/operations/edge-operations.ts`, + * at the work-assembly sites that feed `edge-write-pipeline.ts`'s + * pipeline entries — never inside those entries, which deliberately + * resolve no schema and no constraints (see that module's docblock). + * + * Neither function issues a claim: `edgeInsertClaims` + * (`src/store/claims/composition-claims.ts`) remains the one owner of the + * one-whole-per-part invariant's + * "at most one whole" claim, unchanged here. + * + * Alongside them, the DISPOSITION owner every attachment against an + * already-existing row runs through: + * {@link resolveCompositionAttachmentRequest} builds the request read-free, + * and {@link decideCompositionAttachmentUnderFence} re-reads the incumbent on + * the frame's own fenced target and answers with the decision itself — + * "satisfied", "attach" or "replace" — or refuses. The halves that apply + * that decision (`prepareCompositionAttachmentMoves` and + * `writeCompositionAttachmentMoves`, `node-operations.ts`) take the answer as + * a parameter, which is what lets a frame that owes other statements, or + * several decisions, decide BEFORE its first one. These functions are here rather than + * beside the write plan because the decision is pure: the write plan owns the + * lock and the statements, this module owns what they mean. + */ +import { bindExtraIfReachable } from "../../backend/capabilities/bind"; +import { BATCH_POINT_READ } from "../../backend/capabilities/bundle-registry"; +import { type BundleVerdictOf } from "../../backend/capabilities/resolve"; +import { backendDerivationChain } from "../../backend/derive-backend"; +import { + type EdgeRow, + type GraphBackend, + type GraphReadBackend, + isLiveNodeRowPresent, + type NodeRow, + rowPropsToObject, + type TransactionBackend, +} from "../../backend/types"; +import { + CompositionError, + CompositionExistenceError, + ConfigurationError, + EndpointNotFoundError, + ValidationError, +} from "../../errors"; +import { validateEdgeProps } from "../../errors/validation"; +import { + type CompositionPair, + compositionViaKind, +} from "../../registry/composition-relation"; +import { type KindRegistry } from "../../registry/kind-registry"; +import { canonicalEqual } from "../../schema/canonical"; +import { compareCodePoints } from "../../utils/compare"; +import { + assertOrderedValidityWindow, + isInvertedValidityWindow, + nowIso, + statedBoundMatchesStored, + validateCanonicalIsoDate, + validateOptionalCanonicalIsoDate, + validateStatedValidityLowerBound, +} from "../../utils/date"; +import { isObjectRecord } from "../../utils/object"; +import { requireDefined } from "../../utils/presence"; +import { nodeRefKey } from "../../utils/tuple-key"; +import { + fenceLockOnlyDecision, + type GraphWriteLock, +} from "../recorded-capture/clock"; +import { pageEdgesByKind, pageNodesByKind } from "../row-fetch"; +import { + type CompositionAttachment, + type CompositionNodeRef, + type CreateEdgeInput, + type CreateNodeInput, + type NodeReparentOptions, +} from "../types"; +import { + compositionEdgeCounts, + compositionRowEndpoints, + readCompositionEdgesAtRole, + wholeSide, +} from "./composition-cascade"; + +const ATTACHMENT_KEYS = ["whole", "via", "props", "validFrom", "validTo"]; +const REPARENT_OPTION_KEYS = ["whole", "via", "props", "at"]; + +/** A stated window bound differs from the one an already-held attachment stores. */ +const COMPOSITION_ATTACHMENT_WINDOW_CONFLICT_CODE = + "COMPOSITION_ATTACHMENT_WINDOW_CONFLICT"; +/** A stated `validTo` would write an edge that does not attach its part. */ +const COMPOSITION_ATTACHMENT_WINDOW_BOUNDED_CODE = + "COMPOSITION_ATTACHMENT_WINDOW_BOUNDED"; + +/** + * Where an attachment was stated: the argument name its refusals are + * reported under, and the top-level keys that argument accepts. + */ +type AttachmentSurface = Readonly<{ path: string; keys: readonly string[] }>; + +const PART_OF_SURFACE: AttachmentSurface = { + path: "partOf", + keys: ATTACHMENT_KEYS, +}; +const REPARENT_SURFACE: AttachmentSurface = { + path: "options", + keys: REPARENT_OPTION_KEYS, +}; + +/** + * A caller's attachment as the rest of this module reads it: the whole + * reduced to its `kind` and `id`, `via` to its edge-kind string, the window + * gathered into the shape the edge insert takes. `props` stays optional + * because "no `props` stated" and `props: {}` are different requests to an + * already-satisfied attachment. + */ +export type StatedCompositionAttachment = Readonly<{ + whole: CompositionNodeRef; + viaKind?: string; + props?: Record; + edgeWindow: Readonly<{ validFrom?: string | null; validTo?: string }>; +}>; + +function readWholeRef(whole: unknown): CompositionNodeRef | undefined { + if (!isObjectRecord(whole)) return undefined; + const { kind, id } = whole; + return typeof kind === "string" && typeof id === "string" ? + { kind, id } + : undefined; +} + +/** + * THE one place a caller-stated attachment object is read. Every surface — + * `create`, `bulkCreate`, both get-or-create entries, `reparent` — reaches + * the attachment through here, so: + * + * - only `whole.kind` and `whole.id` are ever taken from the whole. A whole + * passed as a node object carries its own schema properties beside them, + * and a property there called `via` or `props` must never be mistaken for + * the attachment's; + * - a top-level key the surface does not accept is refused rather than + * dropped — including the whole's `kind`/`id` stated beside `via` instead + * of under `whole`, and `validFrom` / `validTo` on `reparent`, whose one + * instant is `at` and which states no window; + * - a stated window is validated here, once, as the edge insert would validate + * it (canonical bounds, ordered pair), so a path that ends up writing no + * edge — an already-satisfied attachment — refuses a malformed or inverted + * window exactly as a create does. + */ +function readStatedAttachment( + value: unknown, + surface: AttachmentSurface, + part: Readonly<{ kind: string; id?: string }>, +): StatedCompositionAttachment { + const stated = isObjectRecord(value) ? value : {}; + const whole = readWholeRef(stated["whole"]); + const unknownKeys = Object.keys(stated).filter( + (key) => !surface.keys.includes(key), + ); + if (whole === undefined || unknownKeys.length > 0) { + const issues = [ + ...unknownKeys.map((key) => ({ + path: `${surface.path}.${key}`, + message: `Unknown key. Accepted keys: ${surface.keys.join(", ")}.`, + })), + ...(whole === undefined ? + [ + { + path: `${surface.path}.whole`, + message: + "Expected the whole as a node or a { kind, id } reference with string kind and id.", + }, + ] + : []), + ]; + throw new ValidationError( + `Invalid composition attachment for ${part.kind}: ${issues.map((issue) => issue.path).join(", ")}`, + { + entityType: "node", + kind: part.kind, + ...(part.id === undefined ? {} : { id: part.id }), + issues, + }, + { + suggestion: `Pass \`${surface.path}: { whole: { kind, id }, ... }\` with only these keys: ${surface.keys.join(", ")}.`, + }, + ); + } + + const attachment = stated as Omit; + const validFrom = validateStatedValidityLowerBound( + attachment.validFrom, + `${surface.path}.validFrom`, + ); + const validTo = validateOptionalCanonicalIsoDate( + attachment.validTo, + `${surface.path}.validTo`, + ); + assertOrderedValidityWindow( + `the composition attachment of ${part.kind}${part.id === undefined ? "" : ` "${part.id}"`}`, + validFrom, + validTo, + ); + return { + whole, + ...(attachment.via === undefined ? + {} + : { viaKind: compositionViaKind(attachment.via) }), + ...(attachment.props === undefined ? {} : { props: attachment.props }), + edgeWindow: { + ...(validFrom === undefined ? {} : { validFrom }), + ...(validTo === undefined ? {} : { validTo }), + }, + }; +} + +/** Reads a `partOf` attachment. See {@link readStatedAttachment}. */ +export function readCompositionAttachment( + partOf: CompositionAttachment, + part: Readonly<{ kind: string; id?: string }>, +): StatedCompositionAttachment { + return readStatedAttachment(partOf, PART_OF_SURFACE, part); +} + +/** + * `reparent`'s move instant and whether the caller stated it. The two are + * judged differently against the attachment the move ends + * ({@link resolveCompositionMoveInstant}): a stated instant is applied or + * refused, a defaulted one — a clock read nobody asked for — gives way. + */ +export type CompositionMoveInstant = + | Readonly<{ source: "stated"; at: string }> + | Readonly<{ source: "defaulted"; at: string }>; + +declare const RESOLVED_COMPOSITION_MOVE_INSTANT_BRAND: unique symbol; + +/** + * A move instant {@link resolveCompositionMoveInstant} decided — the only + * value a `reparent`'s two edge writes accept as the instant their windows + * are judged against (`assertCompositionEdgeWindowCurrent`, + * `edge-operations.ts`). Only that function mints one, so the instant a + * move's writes are judged at can exceed the clock only where it says so: + * the `validFrom` of the incumbent window the move ends, already stored. + */ +export type ResolvedCompositionMoveInstant = string & + Readonly<{ [RESOLVED_COMPOSITION_MOVE_INSTANT_BRAND]: true }>; + +/** + * Reads `reparent`'s options: the destination attachment and the move + * instant, which is `at` or one clock read. The new edge's lower bound is + * that instant, never a separately stated `validFrom`. Only a stated `at` is + * judged against the clock: a defaulted one IS the clock read, and comparing + * it against a second read could refuse it across a backward clock step. + */ +export function readReparentOptions( + options: NodeReparentOptions, + part: Readonly<{ kind: string; id: string }>, +): Readonly<{ + attachment: StatedCompositionAttachment; + moveAt: CompositionMoveInstant; +}> { + const attachment = readStatedAttachment(options, REPARENT_SURFACE, part); + if (options.at === undefined) { + return { attachment, moveAt: { source: "defaulted", at: nowIso() } }; + } + const at = validateCanonicalIsoDate( + options.at, + `${REPARENT_SURFACE.path}.at`, + ); + assertCompositionMoveCurrent(part, attachment.whole, at, nowIso()); + return { attachment, moveAt: { source: "stated", at } }; +} + +/** + * THE effective instant of a composition move: the `validTo` it gives the + * incumbent whose window it ends, and the new attachment's `validFrom`. + * + * `endedIncumbent` is the row whose window the move ends — a + * `population: "oneActive"` incumbent. A `population: "one"` incumbent is + * deleted, and a part with no incumbent has nothing to end, so neither + * passes one and the instant stands as read. + * + * Ending a window at an instant earlier than its `validFrom` would invert it. + * A STATED `at` there is refused (`COMPOSITION_MOVE_BEFORE_INCUMBENT`). A + * DEFAULTED one is a clock read that has fallen behind the incumbent — the + * clock stepped backward since the incumbent was written — so the move + * happens at the incumbent's `validFrom` instead, retiring a zero-width + * window, the way the recorded clock clamps to its previous anchor. The two + * halves still abut. + * + * So the instant returned is `moveAt.at` — a stated `at` the clock already + * admitted ({@link assertCompositionMoveCurrent}) or a defaulted clock read — + * or, for a defaulted read only, the incumbent's stored `validFrom`. + * + * Read-free over the incumbent the frame's fenced decision already read, so + * every move of a frame is judged before its first statement. + */ +export function resolveCompositionMoveInstant( + move: Readonly<{ + part: Readonly<{ kind: string; id: string }>; + whole: CompositionNodeRef; + moveAt: CompositionMoveInstant; + }>, + endedIncumbent: EdgeRow | undefined, +): ResolvedCompositionMoveInstant { + const { part, whole, moveAt } = move; + const incumbentValidFrom = endedIncumbent?.valid_from; + if ( + endedIncumbent === undefined || + incumbentValidFrom === undefined || + !isInvertedValidityWindow(incumbentValidFrom, moveAt.at) + ) { + return moveAt.at as ResolvedCompositionMoveInstant; + } + if (moveAt.source === "defaulted") { + return incumbentValidFrom as ResolvedCompositionMoveInstant; + } + throw new CompositionError({ + situation: "moveBeforeIncumbent", + partKind: part.kind, + partId: part.id, + wholeKind: whole.kind, + wholeId: whole.id, + incumbentEdgeKind: endedIncumbent.kind, + incumbentEdgeId: endedIncumbent.id, + incumbentValidFrom, + at: moveAt.at, + }); +} + +/** + * Refuses a move instant later than the operation clock + * (`COMPOSITION_MOVE_FUTURE`): a move ends the incumbent attachment and + * starts the new one at `at`, so a later `at` would schedule both + * transitions — see {@link assertCompositionWindowCurrent}. Read-free, so it + * precedes the frame's first statement on `reparent` and `bulkReparent`. + */ +function assertCompositionMoveCurrent( + part: Readonly<{ kind: string; id: string }>, + whole: CompositionNodeRef, + at: string, + operationInstant: string, +): void { + if (compareCodePoints(at, operationInstant) <= 0) return; + throw new CompositionError({ + situation: "futureMove", + partKind: part.kind, + partId: part.id, + wholeKind: whole.kind, + wholeId: whole.id, + at, + operationInstant, + }); +} + +/** + * The composition edge a window bound is judged for: its kind and, when it + * exists already, its id, with the endpoints named by the role each plays. + * `part.id` is absent while the part itself is being created under a + * generated id. + */ +type CompositionWindowSubject = Readonly<{ + edgeKind: string; + edgeId?: string; + part: Readonly<{ kind: string; id?: string }>; + whole: CompositionNodeRef; +}>; + +/** The bounds a composition edge write states; omitted or `undefined` states none. */ +type CompositionWindow = Readonly<{ + validFrom?: string | null | undefined; + validTo?: string | undefined; +}>; + +/** + * THE no-future-windows rule of composition: a composition edge write may not + * state a `validFrom` (`COMPOSITION_VALIDITY_FUTURE_START`) or a `validTo` + * (`COMPOSITION_VALIDITY_FUTURE_END`) later than the operation clock. + * + * This is what makes composition membership a fact of the ROW: with no window + * scheduled to open or close later, a row with no `validTo` is the part's + * attachment now and at every later instant, and an ended row is history for + * good — so `membershipCountsRow` (`src/store/claims/edge-claims.ts`) can + * decide membership statically for the claim, the cascade, the audit and the + * acyclicity relation, and every current-time read agrees with them. A + * future end would leave a part holding two current wholes behind a released + * claim; a future start a required part with no current whole. + * + * Read-free. Every surface that writes a composition edge's window reaches + * it before its first statement: `partOf` on every node create + * ({@link compositionCreateWork}), every store edge create and update + * (`validateAndPrepareEdgeCreate` / `prepareEdgeUpdate`, + * `edge-operations.ts` — which a graph merge's apply writes through), and + * import ({@link compositionWindowRefusal}, as a per-row error). `reparent`'s + * one instant is judged by its own rules instead, under its own codes — + * `assertCompositionMoveCurrent` against the clock and + * {@link resolveCompositionMoveInstant} against the window it ends — and its + * two edge writes judge their windows against that instant. A row no such + * write judged — one written before its edge kind realized a pair — is + * refused by the schema commit that makes it a composition row and reported + * by `store.verifyConstraintFences()`, both through + * {@link readCompositionFutureWindowEdges}. + */ +export function assertCompositionWindowCurrent( + subject: CompositionWindowSubject, + window: CompositionWindow, + operationInstant: string, +): void { + const refusal = compositionWindowRefusal(subject, window, operationInstant); + if (refusal !== undefined) throw refusal; +} + +/** A composition window bound later than the operation clock, and which bound it is. */ +type CompositionWindowFutureBound = Readonly<{ + situation: "futureStart" | "futureEnd"; + bound: string; +}>; + +/** + * THE comparison behind the no-future-windows rule: the first of `window`'s + * bounds later than `operationInstant`, or `undefined` when neither is. + * Judges a stated window ({@link compositionWindowRefusal}) and a stored row + * ({@link readCompositionFutureWindowEdges}) alike, so a write and the audit + * of what it left behind cannot disagree about which bound is "later than now". + */ +function compositionWindowFutureBound( + window: CompositionWindow, + operationInstant: string, +): CompositionWindowFutureBound | undefined { + if ( + typeof window.validFrom === "string" && + compareCodePoints(window.validFrom, operationInstant) > 0 + ) { + return { situation: "futureStart", bound: window.validFrom }; + } + if ( + window.validTo !== undefined && + compareCodePoints(window.validTo, operationInstant) > 0 + ) { + return { situation: "futureEnd", bound: window.validTo }; + } + return undefined; +} + +/** + * {@link assertCompositionWindowCurrent}'s verdict as a value, for a caller + * that reports a refusal per row instead of throwing it (import). + */ +export function compositionWindowRefusal( + subject: CompositionWindowSubject, + window: CompositionWindow, + operationInstant: string, +): CompositionError | undefined { + const futureBound = compositionWindowFutureBound(window, operationInstant); + if (futureBound === undefined) return undefined; + const { situation, bound } = futureBound; + return new CompositionError({ + situation, + partKind: subject.part.kind, + ...(subject.part.id === undefined ? {} : { partId: subject.part.id }), + wholeKind: subject.whole.kind, + wholeId: subject.whole.id, + edgeKind: subject.edgeKind, + ...(subject.edgeId === undefined ? {} : { edgeId: subject.edgeId }), + bound, + operationInstant, + }); +} + +/** + * The {@link CompositionWindowSubject} of an edge of `edgeKind` between these + * endpoints, or `undefined` when `edgeKind` realizes no composition pair and + * the window rule does not apply. + */ +export function compositionWindowSubject( + registry: KindRegistry, + edge: Readonly<{ + kind: string; + id?: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + }>, +): CompositionWindowSubject | undefined { + const partSide = registry.compositionPartSide(edge.kind); + if (partSide === undefined) return undefined; + const { part, whole } = compositionRowEndpoints(partSide, { + from_kind: edge.fromKind, + from_id: edge.fromId, + to_kind: edge.toKind, + to_id: edge.toId, + }); + return { + edgeKind: edge.kind, + ...(edge.id === undefined ? {} : { edgeId: edge.id }), + part, + whole, + }; +} + +/** + * What one node create owes on the composition axis: the declared pair and + * the whole the caller named, plus the concrete part kind (which may be a + * subclass of `pair.partKind`) — everything {@link buildCompositionCreateEdgeInput} + * needs to build the `CreateEdgeInput` once the part's id is actually known. + * + * Deliberately carries no id and no `CreateEdgeInput`: a batch create only + * learns each item's real id from `finishNodeCreatePreparation` (a + * caller-supplied id is distinguished from a GENERATED one by whether + * `CreateNodeInput.id` was ever set — `draftNodeCreate`'s `idProvided` + * reads that field directly — so resolving one here and writing it back + * onto the input would corrupt that distinction for every batch member, + * regardless of composition). One node, one composition edge, one write + * plan still holds: the edge is built and issued inside the SAME frame, + * after the row's real id is known. + * + * `undefined` means this create owes no composition edge at all (an + * optional-existence kind with no `partOf`) — the common case, which every + * caller must be able to tell apart from "the edge is not built yet". + */ +export type CompositionCreateWork = Readonly<{ + pair: CompositionPair; + whole: CompositionNodeRef; + /** The concrete part kind this create declared — `input.kind`, verbatim. */ + partKind: string; + /** + * The realizing edge's own properties, as the caller stated them + * (`partOf.props`) or `{}`. Carried verbatim: validation against the edge + * kind's schema is `validateAndPrepareEdgeCreate`'s, exactly as it is for a + * caller's own `store.edges..create(...)` — a second Zod parse here + * would be a second spelling of that decision. + */ + props: Record; + /** + * The realizing edge's own window, copied from the attachment and never + * from the part node's `validFrom`/`validTo`. Empty means the edge insert's + * own default. + */ + edgeWindow: Readonly<{ validFrom?: string | null; validTo?: string }>; +}>; + +/** + * THE decision every attachment surface asks: given this part kind and the + * whole (and, optionally, the realizing edge) the caller named, WHICH + * declared composition pair does this attachment realize? + * + * The one owner of both `via` refusals — `create`, `bulkCreate`, both + * get-or-create entries, and `reparent` all reach it, so none of them can + * resolve an ambiguous attachment by sort order the way the removed + * `getCompositionEdge` did: + * + * - no declared pair at all between the two kinds — `ConfigurationError` + * (`COMPOSITION_WHOLE_NOT_DECLARED`); + * - `via` named, but it realizes no declared pair between them — + * `ConfigurationError` (`COMPOSITION_VIA_NOT_DECLARED`); + * - `via` omitted while more than one pair is declared between them — + * `ConfigurationError` (`COMPOSITION_VIA_AMBIGUOUS`). + * + * Pure and synchronous — no I/O and no claim. + */ +function resolveCompositionAttachment( + registry: KindRegistry, + partKind: string, + attachment: StatedCompositionAttachment, +): CompositionPair { + const wholeKind = attachment.whole.kind; + const declared = registry.compositionPairsBetween(partKind, wholeKind); + if (declared.length === 0) { + throw new ConfigurationError( + `Node kind "${partKind}" declares no composition pair to whole kind "${wholeKind}".`, + { + code: "COMPOSITION_WHOLE_NOT_DECLARED", + partKind, + wholeKind, + }, + { + suggestion: + `Declare \`partOf(${partKind}, ${wholeKind}, { via: ... })\` (or the mirrored \`hasPart\`) in the ontology, ` + + `or pass \`partOf\` naming a whole kind this part is actually declared under.`, + }, + ); + } + + const viaEdgeKinds = declared.map((pair) => pair.viaEdgeKind); + const viaKind = attachment.viaKind; + if (viaKind !== undefined) { + const pair = declared.find( + (candidate) => candidate.viaEdgeKind === viaKind, + ); + if (pair === undefined) { + throw new ConfigurationError( + `Edge kind "${viaKind}" realizes no declared composition pair between "${partKind}" and "${wholeKind}".`, + { + code: "COMPOSITION_VIA_NOT_DECLARED", + partKind, + wholeKind, + via: viaKind, + declaredVia: viaEdgeKinds, + }, + { + suggestion: `Pass \`via\` naming one of the declared realizing edges: ${viaEdgeKinds.join(", ")}.`, + }, + ); + } + return pair; + } + + if (declared.length > 1) { + throw new ConfigurationError( + `Attaching "${partKind}" to "${wholeKind}" is ambiguous: ${declared.length} declared composition pairs realize it.`, + { + code: "COMPOSITION_VIA_AMBIGUOUS", + partKind, + wholeKind, + declaredVia: viaEdgeKinds, + }, + { + suggestion: `Pass \`via\` naming the realizing edge: ${viaEdgeKinds.join(", ")}.`, + }, + ); + } + + return requireDefined( + declared[0], + "compositionPairsBetween returned a non-empty list with no first pair", + ); +} + +/** + * Parts whose required existence a still-running write frame verifies itself, + * once its last row has landed, keyed by the frame's transaction target. + */ +const deferredExistenceFrames = new WeakMap>(); + +/** + * Runs `write` with the required-existence rule deferred for exactly `parts` + * on `target`: inside it a named part may be created or restored before its + * composition edge exists, and may lose its attaching edge before the next one + * is written. + * + * For a caller replaying a complete, already-resolved write set whose rows + * satisfy the rule together and not one at a time (a graph merge's apply). The + * rule is postponed, never waived: the caller owes the same parts a check + * against the frame's final state on the same transaction, before it commits. + * + * Bound to the transaction target and to the parts by name, so a frame's + * deferral for one part licenses nothing for another part or another + * transaction, and it ends with `write`. + */ +export async function withDeferredCompositionExistence( + target: TransactionBackend, + parts: readonly CompositionNodeRef[], + write: () => Promise, +): Promise { + if (parts.length === 0) return write(); + if (deferredExistenceFrames.has(target)) { + throw new ConfigurationError( + "A composition existence deferral is already open on this transaction.", + { code: "COMPOSITION_EXISTENCE_DEFERRAL_NESTED" }, + ); + } + deferredExistenceFrames.set( + target, + new Set(parts.map((part) => nodeRefKey(part))), + ); + try { + return await write(); + } finally { + deferredExistenceFrames.delete(target); + } +} + +/** + * Whether a frame open on `target` (or on the transaction it was derived + * from) has taken over the required-existence check for `part`. The one + * reader every existence refusal in this module consults. + */ +function compositionExistenceDeferred( + target: object, + part: CompositionNodeRef, +): boolean { + const key = nodeRefKey(part); + return backendDerivationChain(target).some( + (link) => deferredExistenceFrames.get(link)?.has(key) === true, + ); +} + +/** + * THE decision every node-create path asks: given the declared existence of + * this kind and the caller's stated `partOf`, what composition edge does + * this create owe — and is the pair legal? Refuses; never returns a silent + * "nothing to do" for a required kind with no `partOf`, and never silently + * drops a `partOf` naming an undeclared or ambiguous pair. + * + * Pure and synchronous — no I/O, no claim, no endpoint-liveness read, and no + * id needed: the composition CLAIM and the whole's liveness are both the + * edge insert's own concern (`edgeInsertClaims`, `assertLiveEdgeEndpoints`), + * and re-checking either here would be a second spelling of that decision. + */ +export function resolveCompositionCreate( + registry: KindRegistry, + input: Pick, + target: object, +): CompositionCreateWork | undefined { + if ( + input.partOf === undefined && + input.id !== undefined && + compositionExistenceDeferred(target, { kind: input.kind, id: input.id }) + ) { + return undefined; + } + return compositionCreateWork( + registry, + input, + input.partOf === undefined ? + undefined + : readCompositionAttachment(input.partOf, input), + ); +} + +function compositionCreateWork( + registry: KindRegistry, + part: Readonly<{ kind: string; id?: string }>, + attachment: StatedCompositionAttachment | undefined, +): CompositionCreateWork | undefined { + const partKind = part.kind; + if (attachment === undefined) { + if (registry.compositionExistence(partKind) === "required") { + throw new CompositionExistenceError({ + partKind, + ...(part.id === undefined ? {} : { partId: part.id }), + situation: "create", + }); + } + return undefined; + } + + const pair = resolveCompositionAttachment(registry, partKind, attachment); + assertCompositionWindowCurrent( + { + edgeKind: pair.viaEdgeKind, + part, + whole: attachment.whole, + }, + attachment.edgeWindow, + nowIso(), + ); + assertStatedWindowAttachesPart(registry, part, pair, attachment); + return { + pair, + whole: attachment.whole, + partKind, + props: attachment.props ?? {}, + edgeWindow: attachment.edgeWindow, + }; +} + +/** + * Refuses a stated attachment whose realizing edge would be written already + * NOT attaching its part: a `validTo` on a `population: "oneActive"` pair, + * where only an open-ended row is a membership. + * + * `partOf` asks for an attachment, and an edge + * {@link compositionEdgeAttachesPart} does not count is not one: no later call + * could find it as the incumbent, so a repeated get-or-create would insert it + * again and a different whole would be accepted beside it; a required part + * would be written with no whole at all. Judged against the edge this request + * WOULD write, through the same predicate every reader of an attachment uses, + * and read-free — so it precedes the first statement on every surface. + */ +function assertStatedWindowAttachesPart( + registry: KindRegistry, + part: Readonly<{ kind: string; id?: string }>, + pair: CompositionPair, + attachment: StatedCompositionAttachment, +): void { + const { validTo } = attachment.edgeWindow; + if ( + compositionEdgeAttachesPart(registry, part.kind, { + kind: pair.viaEdgeKind, + deleted_at: undefined, + valid_to: validTo, + }) + ) { + return; + } + throw new ValidationError( + `Invalid composition attachment for ${part.kind}: a bounded window does not attach a part under a "${pair.population}" composition pair.`, + { + entityType: "node", + kind: part.kind, + ...(part.id === undefined ? {} : { id: part.id }), + issues: [ + { + path: `${PART_OF_SURFACE.path}.validTo`, + code: COMPOSITION_ATTACHMENT_WINDOW_BOUNDED_CODE, + message: `"${pair.viaEdgeKind}" counts as a membership only while its window is open, so an attachment stated with validTo "${validTo}" would not attach the part.`, + }, + ], + }, + { + suggestion: `Omit \`${PART_OF_SURFACE.path}.validTo\` to attach the part. To record a bounded membership as history, write the edge itself with \`store.edges.${pair.viaEdgeKind}.create(...)\`.`, + }, + ); +} + +/** + * Materializes {@link resolveCompositionCreate}'s work into a fully-formed + * `CreateEdgeInput` — oriented per `pair.partSide` — once the part's REAL id + * is known. The one place the from/to orientation is spelled; every + * attach-edge call site (single create, both batch shapes, the + * get-or-create resurrection leg) reaches it through here. + */ +export function buildCompositionCreateEdgeInput( + work: CompositionCreateWork, + partId: string, + temporal: Readonly<{ validFrom?: string | null; validTo?: string }> = {}, +): CreateEdgeInput { + const { pair, whole, partKind } = work; + const [fromKind, fromId, toKind, toId] = + pair.partSide === "from" ? + ([partKind, partId, whole.kind, whole.id] as const) + : ([whole.kind, whole.id, partKind, partId] as const); + + return { + kind: pair.viaEdgeKind, + fromKind, + fromId, + toKind, + toId, + props: work.props, + ...(temporal.validFrom === undefined ? + {} + : { validFrom: temporal.validFrom }), + ...(temporal.validTo === undefined ? {} : { validTo: temporal.validTo }), + }; +} + +/** + * Whether `edgeKind` realizes a composition pair whose part kind is + * `existence: "required"` for ANY of its declared pairs. Used to decline a + * composition edge from a fused/read-free delete or update program: those + * commands cannot express {@link assertCompositionExistencePreserved}'s + * held-lock part read, so a kind this returns `true` for must take the + * portable path for its delete/end mutations. + */ +export function compositionEdgeHasRequiredExistencePart( + registry: KindRegistry, + edgeKind: string, +): boolean { + if (!registry.isCompositionEdge(edgeKind)) return false; + return registry + .compositionRelation() + .pairs.some( + (pair) => + pair.viaEdgeKind === edgeKind && + registry.compositionExistence(pair.partKind) === "required", + ); +} + +/** + * THE answer to "does this composition edge row attach its part to a live + * whole" — a fact of the row, which is also its answer at the current read + * instant because no composition window starts or ends later than its write + * ({@link assertCompositionWindowCurrent}). Ignoring `deleted_at`, valid-time is + * everything `assertCompositionExistencePreserved`, `findLiveCompositionWhole` + * (and, through it, the import assertion and `verifyConstraintFences`'s + * `compositionExistence` audit) share, so none of them re-spell it apart and + * drift. {@link assertStatedWindowAttachesPart} also reuses it ahead of the + * write, against the not-yet-persisted edge's own `kind`/`validTo` — the + * CREATE-time mirror of the same question, refusing a stated attachment whose + * composition edge would be born already unattaching. + * + * Reuses {@link compositionEdgeCounts} (`./composition-cascade.ts`), the one + * owner of "does a composition edge row still count as a live membership + * under its pair's declared whole-side population" — that predicate already + * IS the temporal notion this one needs: a `population: "one"` binding + * persists for the row's entire life (ended or not), while a + * `population: "oneActive"` binding ends the moment the window closes. This + * function adds only the `deleted_at` gate `compositionEdgeCounts`'s callers + * are each individually documented to apply themselves. + */ +export function compositionEdgeAttachesPart( + registry: KindRegistry, + partKind: string, + edge: Pick, +): boolean { + if (edge.deleted_at !== undefined) return false; + const partSide = registry.compositionPartSide(edge.kind); + if (partSide === undefined) return false; + const population = requireDefined( + registry.compositionPopulation(partKind), + `compositionPopulation(${partKind}) is undefined for a row on a known composition edge kind`, + ); + return compositionEdgeCounts({ partSide, population }, edge); +} + +/** + * The frame target a required-existence verdict reads through: a read surface + * that can also gate the read behind the per-graph write fence + * ({@link fenceCompositionExistenceDecision}). + */ +type CompositionExistenceTarget = GraphReadBackend & + Pick; + +/** + * The gate of every required-existence verdict — whether a part keeps a live + * whole — taken before its first read. No claim row records that verdict, so + * it is a lock-only decision whatever reason the frame's own plan named: a + * frame fenced for another constraint (a shared-scope unique, say) took the + * fence without advancing the graph's fence row, and a snapshot that predates + * a concurrent detach would otherwise decide from it. + * + * @throws TransactionConflictError when another decider committed after this + * session's snapshot + * @throws ConfigurationError (`COMPOSITION_EXISTENCE_REQUIRES_FRESH_SNAPSHOT`) + */ +function fenceCompositionExistenceDecision( + target: CompositionExistenceTarget, + graphId: string, + lock: GraphWriteLock, +): Promise { + return fenceLockOnlyDecision(target, graphId, lock, { + code: "COMPOSITION_EXISTENCE_REQUIRES_FRESH_SNAPSHOT", + subject: "Deciding whether a required composition part holds a whole", + }); +} + +/** + * THE refusal every path that would separate a live required part from its + * whole raises: ending a composition edge's open window, soft-deleting it, + * or hard-deleting it. + * + * Fast path first (no read at all) when `edge.kind` is not a composition + * edge, or its declared pair's existence is `"optional"`. Only then does it + * read the part row — under the held per-graph write lock, which every + * caller's plan takes for exactly these kinds + * (`edgeDetachNeedsConstraintFence`, `constraints.ts`, or the composition + * fence a reparent holds), so a concurrent restore of the part or delete of + * its whole cannot land between the read and this write's own row change — + * and refuses only when the part is LIVE. A part that is already retired + * (soft-deleted or gone) is not orphaned by losing its edge: refusing that + * would make a soft-deleted part's composition edge permanently + * undeletable-from. + * + * `lock` is the frame's fence evidence, and the read is gated by + * {@link fenceCompositionExistenceDecision} on it — the same gate + * `planCompositionCascade` takes — so the verdict is a lock-only decision + * however the frame's plan was named. + * + * `reattachedPart` names the ONE part whose composition edge this same write + * frame retires only to attach it to a new whole immediately afterwards + * (`reparent`, `node-operations.ts`). That part is not being detached at + * all: the frame's final state has it attached, so the invariant this + * refusal protects is preserved end-to-end even though its intermediate + * state is not. Stated as the part itself rather than as a "skip the check" + * flag, so the exemption is bound to the resource that earned it — a + * frame's reparent of part A can never quietly license a detach of part B. + */ +export async function assertCompositionExistencePreserved( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + reattachedPart?: CompositionNodeRef; + }>, + edge: EdgeRow, + backend: CompositionExistenceTarget, +): Promise { + // `compositionPartSide` is total per edge KIND (a second, contradicting + // orientation for one edge kind is itself refused at registry-build time, + // `ONTOLOGY_COMPOSITION_VIA_MIXED`) even for a heterogeneous composition + // edge kind realizing more than one `(partKind, wholeKind)` pair — so the + // part endpoint is resolved from the ROW's own concrete kinds, never from + // a pair looked up by edge kind alone, which could pick the wrong + // declared `partKind` for this row's actual part. + const partSide = ctx.registry.compositionPartSide(edge.kind); + if (partSide === undefined) return; + + const { part } = compositionRowEndpoints(partSide, edge); + + if (ctx.registry.compositionExistence(part.kind) !== "required") return; + + const reattached = ctx.reattachedPart; + if (reattached?.kind === part.kind && reattached.id === part.id) { + return; + } + if (compositionExistenceDeferred(backend, part)) return; + + // A row that no longer currently attaches (an already-ended + // `population: "oneActive"` window) has nothing left to detach: the + // moment of detachment already passed when the window closed, so this + // write — ending an already-ended window again, or soft-/hard-deleting a + // row that is no longer an attachment — cannot be what orphans the part. + // Reads the SAME predicate `findLiveCompositionWhole` reads, so a row this + // refusal protects is never invisible to `verifyConstraintFences`, and a + // row that audit already reports unattached is never refused here. + if (!compositionEdgeAttachesPart(ctx.registry, part.kind, edge)) return; + + await fenceCompositionExistenceDecision(backend, ctx.graphId, ctx.lock); + const partRow = await backend.getNode(ctx.graphId, part.kind, part.id); + if (!isLiveNodeRowPresent(partRow)) return; + + throw new CompositionExistenceError({ + partKind: part.kind, + partId: part.id, + situation: "detach", + edgeKind: edge.kind, + edgeId: edge.id, + }); +} + +/** + * Whether a write stating `validTo` on an existing edge ENDS a currently OPEN + * window — `existing.valid_to === undefined` — rather than restating or + * tightening an end the row already carries. Only the former can detach a + * part: the moment of detachment already passed the first time the window + * closed, so re-touching an already-ended edge is not what orphans one. + * + * The gate in front of {@link assertCompositionExistencePreserved} for every + * write that can state a `validTo` on an existing edge — the store's edge + * update (`performEdgeUpdate`, `edge-operations.ts`) and interchange import's + * `onConflict: "update"` — one predicate, so neither can end a live required + * attachment the other refuses. + */ +export function edgeWriteEndsOpenWindow( + existing: Pick, + validTo: string | undefined, +): boolean { + return validTo !== undefined && existing.valid_to === undefined; +} + +/** + * The composition edge that currently attaches this part, together with the + * whole it attaches it to. `undefined` when `concreteKind` is not a + * composition part at all, or the part currently has no live whole. + * + * THE reader behind both "which whole does this part hold" + * ({@link findLiveCompositionWhole}) and "which edge row realizes that + * attachment right now" (`reparent`'s retire target, + * `node-operations.ts`) — one traversal, one orientation decision, one + * population predicate, so the mover and the reporter can never disagree + * about which edge is the incumbent. + * + * `excludeEdgeIds` (default none) skips a connected edge by id regardless of + * its own liveness — merge's plan-time preview + * (`unattachedRequiredPartOrphansAmong`, `src/graph-merge/merge.ts`) uses it + * to ask "does this part have a live whole AFTER this merge's own planned + * edge deletions land", against a backend that still shows those edges as + * live (nothing has been written yet at plan time), without a second, + * plan-aware spelling of this predicate. + */ +export async function findLiveCompositionAttachment( + registry: KindRegistry, + backend: Pick, + graphId: string, + concreteKind: string, + concreteId: string, + excludeEdgeIds?: ReadonlySet, +): Promise | undefined> { + if (!registry.isCompositionPart(concreteKind)) return undefined; + const connected = await backend.findEdgesConnectedTo({ + graphId, + nodeKind: concreteKind, + nodeId: concreteId, + }); + return selectLiveCompositionAttachment( + registry, + concreteKind, + concreteId, + connected, + excludeEdgeIds, + ); +} + +/** + * The in-memory half of {@link findLiveCompositionAttachment}: given the + * candidate edges incident to one part, the one that currently attaches it. + * + * Split out so a caller that already read a SET of parts' candidate edges in + * one statement ({@link readCompositionAttachmentsForPage}) reaches the + * identical verdict as the per-part reader, rather than re-spelling the + * orientation match and the population predicate over its own rows. + * + * Judges the EDGE alone, which is what the write path needs: a tombstoned + * whole still holds its part's attachment claim, so + * {@link decideCompositionIncumbent} must still see that incumbent. A caller + * that asks the stronger question — "does this part hang from a whole that is + * itself live" — composes this verdict with {@link readLiveCompositionWholes}, + * the way {@link readCompositionUnattachedParts} does. + * + * When several live edges attach the part, the lowest id in code-point order + * is named, the same incumbent the claim statements' `ORDER BY` reports. + */ +function selectLiveCompositionAttachment( + registry: KindRegistry, + concreteKind: string, + concreteId: string, + candidateEdges: readonly EdgeRow[], + excludeEdgeIds?: ReadonlySet, +): Readonly<{ edge: EdgeRow; whole: CompositionNodeRef }> | undefined { + let selected: + Readonly<{ edge: EdgeRow; whole: CompositionNodeRef }> | undefined; + for (const edge of candidateEdges) { + if (excludeEdgeIds?.has(edge.id) === true) continue; + const partSide = registry.compositionPartSide(edge.kind); + if (partSide === undefined) continue; + const { part, whole } = compositionRowEndpoints(partSide, edge); + if (part.kind !== concreteKind || part.id !== concreteId) continue; + if (!compositionEdgeAttachesPart(registry, concreteKind, edge)) continue; + if ( + selected === undefined || + compareCodePoints(edge.id, selected.edge.id) < 0 + ) { + selected = { edge, whole }; + } + } + return selected; +} + +/** + * What a stated attachment asks of an incumbent that already IS the requested + * attachment (same whole, same realizing edge) — the decision itself, not a + * boolean a caller re-derives the comparison from, so both consumers read the + * same verdict: {@link assertSatisfiedAttachmentHonored} (which turns a + * disagreement into the refusal) and the lock-free pre-check that decides + * whether an already-satisfied get-or-create can stay read-only + * ({@link incumbentSatisfiesRequestedAttachment}). + */ +type SatisfiedAttachmentVerdict = + | Readonly<{ honored: true }> + | Readonly<{ + honored: false; + dimension: "props"; + currentProps: Record; + requestedProps: Record; + }> + | Readonly<{ + honored: false; + dimension: "window"; + bound: "validFrom" | "validTo"; + stated: string | null; + stored: string | undefined; + }>; + +/** The columns of an incumbent edge the satisfied verdict reads. */ +type SatisfiedAttachmentEdge = Pick< + EdgeRow, + "props" | "valid_from" | "valid_to" +>; + +/** + * THE one place a stated attachment is compared against an incumbent that is + * ALREADY the requested attachment. The satisfied arm writes no edge, so every + * option the caller stated beyond the whole and the realizing edge is either + * verified equal to what the edge stores or reported as a disagreement — + * never dropped. + * + * `props` omitted: nothing stated, nothing to compare. `props` stated: run + * through {@link validateEdgeProps} against `pair.viaEdgeKind`'s own schema — + * the same owner `validateAndPrepareEdgeCreate` calls for a fresh attach, so + * an invalid value is refused here exactly as it would be on create, never + * silently accepted because the caller happens not to write. (That refusal is + * a property of the caller's own input, not of the row, which is why the + * lock-free pre-check may reach it too.) A valid value canonically + * (`canonicalEqual`, key order aside) identical to the edge's live stored + * props is honored; a valid value that DIFFERS is not. + * + * A stated `validFrom` / `validTo` is compared bound by bound through + * {@link statedBoundMatchesStored} — the one stated-versus-stored comparison + * every window write uses. An omitted bound states nothing; `validFrom: null` + * states "no lower bound" and matches only a row that has none. + */ +function judgeSatisfiedAttachment( + registry: KindRegistry, + attachment: StatedCompositionAttachment, + pair: CompositionPair, + currentEdge: SatisfiedAttachmentEdge, +): SatisfiedAttachmentVerdict { + if (attachment.props !== undefined) { + const edgeType = requireDefined( + registry.getEdgeType(pair.viaEdgeKind), + `getEdgeType(${pair.viaEdgeKind}) is undefined for a resolved composition pair's own realizing edge kind`, + ); + const validatedProps = validateEdgeProps( + edgeType.schema, + attachment.props, + { kind: pair.viaEdgeKind, operation: "create" }, + ); + const storedProps = rowPropsToObject(currentEdge.props); + if (!canonicalEqual(validatedProps, storedProps)) { + return { + honored: false, + dimension: "props", + currentProps: storedProps, + requestedProps: validatedProps, + }; + } + } + + const { validFrom, validTo } = attachment.edgeWindow; + if ( + validFrom !== undefined && + !statedBoundMatchesStored(validFrom, currentEdge.valid_from) + ) { + return { + honored: false, + dimension: "window", + bound: "validFrom", + stated: validFrom, + stored: currentEdge.valid_from, + }; + } + if ( + validTo !== undefined && + !statedBoundMatchesStored(validTo, currentEdge.valid_to) + ) { + return { + honored: false, + dimension: "window", + bound: "validTo", + stated: validTo, + stored: currentEdge.valid_to, + }; + } + return { honored: true }; +} + +/** + * The refusal {@link decideCompositionIncumbent}'s satisfied arm owes — every + * surface's no-write return (`reparent`'s no-op and the get-or-create + * postcondition's idempotent hit both resolve there). That arm performs no + * edge write, so without this call a stated `props` or window would be + * neither applied nor refused — an accepted option silently dropped, the same + * shape `situation: "existing"` refuses one dimension over (a differing + * whole, or a differing realizing edge). + * + * Disagreeing `props` refuse with `CompositionExistenceError` + * (`situation: "props"`) naming both sides. A disagreeing window bound + * refuses with `ValidationError` under `partOf.`, the shape an + * in-place edge write gives a lower bound it cannot apply. Neither is + * rewritten here: this call resolves an attachment, and changing the + * realizing edge is `store.edges..update(...)`'s. + */ +function assertSatisfiedAttachmentHonored( + registry: KindRegistry, + partKind: string, + partId: string, + attachment: StatedCompositionAttachment, + pair: CompositionPair, + currentEdge: Pick & SatisfiedAttachmentEdge, +): void { + const verdict = judgeSatisfiedAttachment( + registry, + attachment, + pair, + currentEdge, + ); + if (verdict.honored) return; + if (verdict.dimension === "props") { + throw new CompositionExistenceError({ + partKind, + partId, + situation: "props", + edgeKind: currentEdge.kind, + edgeId: currentEdge.id, + currentProps: verdict.currentProps, + requestedProps: verdict.requestedProps, + }); + } + throw attachmentWindowConflict( + `${PART_OF_SURFACE.path}.${verdict.bound}`, + partKind, + partId, + verdict, + currentEdge, + ); +} + +/** + * The refusal of a stated `reparent` instant toward the whole the part + * already holds. The move is a no-op that writes no edge, so a stated `at` — + * the lower bound the new attachment would have — is honored only when it is + * the incumbent's stored `validFrom`; any other instant is refused rather + * than dropped, as {@link assertSatisfiedAttachmentHonored} refuses a + * differing `partOf.validFrom`. A defaulted instant states nothing. + */ +export function assertSatisfiedMoveInstantHonored( + partKind: string, + partId: string, + moveAt: CompositionMoveInstant, + currentEdge: Pick, +): void { + if (moveAt.source === "defaulted") return; + if (statedBoundMatchesStored(moveAt.at, currentEdge.valid_from)) return; + throw attachmentWindowConflict( + `${REPARENT_SURFACE.path}.at`, + partKind, + partId, + { stated: moveAt.at, stored: currentEdge.valid_from }, + currentEdge, + ); +} + +/** + * The one shape of a stated window bound an already-held attachment cannot + * apply: `ValidationError` under the stated option's own path, with + * `COMPOSITION_ATTACHMENT_WINDOW_CONFLICT` naming both instants. + */ +function attachmentWindowConflict( + path: string, + partKind: string, + partId: string, + bound: Readonly<{ stated: string | null; stored: string | undefined }>, + currentEdge: Pick, +): ValidationError { + const storedDescription = + bound.stored === undefined ? "no such bound" : `"${bound.stored}"`; + return new ValidationError( + `Unappliable ${path} for ${partKind} "${partId}": it already holds this whole via "${currentEdge.kind}" (edge ${currentEdge.id}) with a different window.`, + { + entityType: "node", + kind: partKind, + id: partId, + issues: [ + { + path, + code: COMPOSITION_ATTACHMENT_WINDOW_CONFLICT_CODE, + message: `Stated ${bound.stated === null ? "no lower bound" : `"${bound.stated}"`}, but the realizing edge stores ${storedDescription}.`, + }, + ], + }, + { + suggestion: `Omit \`${path}\` or restate the stored bound; an already-satisfied attachment only asserts placement. Change the window on the realizing edge with \`store.edges.${currentEdge.kind}.update(${JSON.stringify(currentEdge.id)}, ...)\`.`, + }, + ); +} + +/** + * What a fenced attachment does with an incumbent whole it finds under the + * per-graph write lock — the ONE dimension that separates "move this part" + * from "make sure this part holds this whole": + * + * - `"replace"` — `nodes..reparent(...)`: retire the incumbent + * attachment and write the requested one. Moving a part is what the caller + * asked for. + * - `"refuse"` — every get-or-create path: an incumbent that is NOT the + * requested attachment raises `CompositionExistenceError` + * (`situation: "existing"`). A lookup never moves a part as a side effect, + * so two racing callers that each ask for a different whole end with + * exactly one attachment and one refusal. + * + * Stated as a disposition the write plan carries rather than as two + * code paths, because the verdict is only sound once the incumbent has been + * re-read under the lock: a lock-free read can be stale by the time the + * frame writes, and acting on its verdict is exactly how a refusing caller + * used to perform a silent move. + */ +export type CompositionIncumbentDisposition = "replace" | "refuse"; + +/** + * One resolved attachment a write frame owes: the caller's stated + * `partOf` verbatim (needed to tell "no `props` stated" from `props: {}`), + * the resolved {@link CompositionCreateWork} the attach is built from, and + * what to do about an incumbent ({@link CompositionIncumbentDisposition}). + * + * Built BEFORE any row is read — `resolveCompositionAttachment`'s and + * `resolveCompositionCreate`'s refusals are synchronous and read-free — so a + * configuration defect of the call refuses without locking anything. + */ +export type CompositionAttachmentRequest = Readonly<{ + attachment: StatedCompositionAttachment; + work: CompositionCreateWork; + onIncumbent: CompositionIncumbentDisposition; +}>; + +/** + * THE resolution of one caller-stated `partOf` into the request a fenced + * attachment frame runs: `resolveCompositionCreate`'s read-free refusals (an + * undeclared pair, an unknown or ambiguous `via`, a required part with no + * `partOf`). `undefined` means this frame owes no attachment at all. + * + * Every surface that attaches a part against a row that ALREADY exists comes + * through here — `nodes..reparent(...)` with `"replace"`, every + * get-or-create leg with `"refuse"` — so no surface resolves a pair a caller + * above it already resolved, and this is the ONE place a + * {@link CompositionAttachmentRequest} is built, so no call site can assemble + * one whose `work` and `attachment` describe different wholes. + */ +export function resolveCompositionAttachmentRequest( + registry: KindRegistry, + part: Readonly<{ kind: string; id: string }>, + attachment: StatedCompositionAttachment, + onIncumbent: CompositionIncumbentDisposition, +): CompositionAttachmentRequest; +export function resolveCompositionAttachmentRequest( + registry: KindRegistry, + part: Readonly<{ kind: string; id: string }>, + attachment: StatedCompositionAttachment | undefined, + onIncumbent: CompositionIncumbentDisposition, +): CompositionAttachmentRequest | undefined; +export function resolveCompositionAttachmentRequest( + registry: KindRegistry, + part: Readonly<{ kind: string; id: string }>, + attachment: StatedCompositionAttachment | undefined, + onIncumbent: CompositionIncumbentDisposition, +): CompositionAttachmentRequest | undefined { + const work = compositionCreateWork(registry, part, attachment); + if (work === undefined) return undefined; + return { + attachment: requireDefined( + attachment, + "compositionCreateWork returned composition work for a call that stated no attachment", + ), + work, + onIncumbent, + }; +} + +/** + * Whether `current` IS the requested attachment: the same whole, held + * through the resolved pair's realizing edge. Compared against the RESOLVED + * `pair.viaEdgeKind` rather than against `attachment.via`, so an omitted + * `via` means "the one declared pair", never "any realizing edge will do". + * + * The whole/via half of {@link incumbentSatisfiesRequestedAttachment}, which + * is what both the fenced verdict ({@link decideCompositionIncumbent}) and the + * lock-free pre-check that lets an already-satisfied get-or-create stay + * read-only (`applyExistingPartOfPostcondition`, `node-operations.ts`) read — + * one spelling, so the cheap skip and the authoritative verdict can never + * disagree about what "already holds" means. + */ +function incumbentHoldsRequestedAttachment( + request: CompositionAttachmentRequest, + current: Readonly<{ + edge: Pick; + whole: CompositionNodeRef; + }>, +): boolean { + const { attachment, work } = request; + return ( + current.whole.kind === attachment.whole.kind && + current.whole.id === attachment.whole.id && + current.edge.kind === work.pair.viaEdgeKind + ); +} + +/** + * Whether this incumbent leaves the request with NOTHING to write: it is the + * requested attachment ({@link incumbentHoldsRequestedAttachment}) AND the + * realizing edge already carries the stated `props` and window + * ({@link judgeSatisfiedAttachment}) — the two conjuncts + * {@link decideCompositionIncumbent}'s satisfied arm is built from, in one + * predicate so the cheap skip and the authoritative verdict cannot disagree + * about what "nothing to do" means. + * + * Read twice, against the same row: once lock-free, to let an already-resolved + * get-or-create return without opening a write transaction at all + * (`applyExistingPartOfPostcondition`, `node-operations.ts` — including the + * idempotent ingest that restates the props every time, which would otherwise + * take the per-graph write fence for a no-op), and once under the fence, where + * the verdict is authoritative. Only the SKIP is ever taken from the lock-free + * read: anything else escalates to the fence, which is the one allowed to + * refuse or write. + * + * A `props` value that does not validate against the realizing edge's schema + * refuses from either read — that refusal reads the caller's input, never the + * row, so it cannot be stale. + */ +export function incumbentSatisfiesRequestedAttachment( + registry: KindRegistry, + request: CompositionAttachmentRequest, + current: Readonly<{ + edge: Pick & SatisfiedAttachmentEdge; + whole: CompositionNodeRef; + }>, +): boolean { + return ( + incumbentHoldsRequestedAttachment(request, current) && + judgeSatisfiedAttachment( + registry, + request.attachment, + request.work.pair, + current.edge, + ).honored + ); +} + +/** + * What one fenced attachment frame does, decided from the incumbent the + * frame re-read UNDER THE LOCK: + * + * - `"satisfied"` — the part already holds exactly this attachment. No write + * at all, which is what makes a repeated get-or-create (and a `reparent` + * to the whole the part already holds) idempotent rather than a refusal. + * A stated `props` or window is still honored here + * ({@link assertSatisfiedAttachmentHonored}): this arm writes no edge, so + * a differing value is refused rather than silently dropped. + * - `"attach"` — the part holds no live whole: write the attachment. + * - `"replace"` — a DIFFERENT incumbent under `onIncumbent: "replace"`: + * retire it, then write the requested attachment. + * + * A different incumbent under `onIncumbent: "refuse"` throws + * `CompositionExistenceError` (`situation: "existing"`) naming both sides — + * the held whole (and realizing edge, when only the edge differs) and the + * requested one. + * + * Pure and synchronous: the read that produced `current` is the caller's, so + * this decision can be reached only from inside the fence that read it. + */ +function decideCompositionIncumbent( + registry: KindRegistry, + partId: string, + request: CompositionAttachmentRequest, + current: + | Readonly<{ + edge: Pick & SatisfiedAttachmentEdge; + whole: CompositionNodeRef; + }> + | undefined, +): "satisfied" | "attach" | "replace" { + if (current === undefined) return "attach"; + + const { attachment, work, onIncumbent } = request; + const partKind = work.partKind; + if (incumbentHoldsRequestedAttachment(request, current)) { + assertSatisfiedAttachmentHonored( + registry, + partKind, + partId, + attachment, + work.pair, + current.edge, + ); + return "satisfied"; + } + + if (onIncumbent === "replace") return "replace"; + + const wholeMatches = + current.whole.kind === attachment.whole.kind && + current.whole.id === attachment.whole.id; + throw new CompositionExistenceError({ + partKind, + partId, + situation: "existing", + currentWhole: current.whole, + ...(wholeMatches ? { currentVia: current.edge.kind } : {}), + requestedWhole: attachment.whole, + requestedVia: work.pair.viaEdgeKind, + }); +} + +/** + * One fenced attachment's DECISION: the disposition + * {@link decideCompositionIncumbent} reached, plus the incumbent it was + * reached from, so the write half never re-reads the row the verdict was + * judged against. + * + * The decision itself travels, not a flag a caller re-derives it from: a + * frame that holds one of these cannot spell a second, drifting version of + * "what does this incumbent mean". + */ +export type FencedCompositionAttachment = Readonly<{ + request: CompositionAttachmentRequest; + disposition: "satisfied" | "attach" | "replace"; + /** The incumbent the disposition was judged against; absent when none. */ + incumbent?: Readonly<{ edge: EdgeRow; whole: CompositionNodeRef }>; +}>; + +/** + * THE single owner of "does this edge endpoint row exist and count as live" — + * shared by two questions asked at two different times against the two + * different rows one composition attachment touches: + * + * - `assertLiveEdgeEndpoints` (`edge-operations.ts`) calls this for BOTH + * endpoints inside the ordinary edge-create preparation every composition + * edge goes through (`prepareCompositionCreateEdge`, `node-operations.ts`) + * — the only place the PART endpoint is checked. The get-or-create + * resurrection leg skips that read, since its part row is still a + * tombstone until the property update later in the same frame restores it. + * - {@link decideCompositionAttachmentUnderFence} calls this for the WHOLE + * endpoint, at DECIDE time, so the resurrection leg still refuses a dead + * whole before its first statement — see that function's docblock. + * + * One spelling of the liveness verdict keeps the two calls from ever judging + * "is this row live" differently, the way a second copy of a decision drifts + * per this codebase's Contract Discipline rule. + */ +export function assertEndpointRowLive( + edgeKind: string, + endpoint: "from" | "to", + nodeKind: string, + nodeId: string, + row: NodeRow | undefined, +): void { + if (!isLiveNodeRowPresent(row)) { + throw new EndpointNotFoundError({ edgeKind, endpoint, nodeKind, nodeId }); + } +} + +/** + * THE refusal an attachment owes for its WHOLE endpoint: the row this + * attachment's whole resolves to must exist and be live, reported on the side + * of the realizing edge the whole actually occupies. + * + * One owner for the two callers that check the whole ALONE — a part's own + * liveness being established some other way: + * {@link decideCompositionAttachmentUnderFence}, whose part row may still be + * a tombstone the frame's later update restores, and a node create's + * preparation (`prepareCompositionEdgeForCreate` and its batch twin, + * `node-operations.ts`), whose part row this same frame is about to write. + * Without one + * spelling, each would re-derive the side from `pair.partSide` itself, and + * the two could report a dead whole on different endpoints. + */ +export function assertCompositionWholeEndpointLive( + work: Pick, + row: NodeRow | undefined, +): void { + assertEndpointRowLive( + work.pair.viaEdgeKind, + wholeSide(work.pair.partSide), + work.whole.kind, + work.whole.id, + row, + ); +} + +/** + * THE fenced DECIDE half of an attachment: re-read the incumbent on the + * frame's own transaction target — under the per-graph write lock the caller + * already holds — and judge it ({@link decideCompositionIncumbent}). + * + * Separated from the write half so a frame that owes OTHER statements can + * run every decision this one can reach before its first statement: the + * get-or-create `ifExists: "update"` / resurrection leg decides here, + * prepares the edge's own reads, then updates properties, then inserts. A + * refusal this function raises therefore precedes the property update, which + * is what keeps a caller that catches it inside an enclosing + * `store.transaction(...)` — where there is no nested frame to roll back — + * from committing an update whose attachment never applied. + * + * That is why this function also owns the WHOLE endpoint's liveness read: + * when the disposition is going to attach a new edge (`"attach"` or + * `"replace"`), a dead or missing whole is refused HERE, via + * {@link assertCompositionWholeEndpointLive}, before returning. The PART endpoint is + * deliberately NOT read here: on the resurrection leg the part row is still + * a tombstone until the update restores it, so a part-liveness read taken + * here would refuse every resurrection; the edge preparation reads it on the + * legs where it is live, and the restoring update is the proof on the leg + * where it is not. A `"satisfied"` disposition attaches nothing, so on a live + * part it owes no fresh liveness read. On the resurrection leg it does: the + * frame is about to restore the part under the attachment it already holds, + * and a part's tombstone can outlive its whole's with the edge between them + * still live, so the whole is read and a dead one refused exactly as on the + * attaching dispositions — the verdict every other restore entry reaches + * through {@link assertRestoredRequiredPartsAttached}. + * + * `target` is the frame's own transaction target, which is the only reason + * the verdict can be trusted: a verdict from a lock-free read is exactly what + * would let a refusing caller perform a silent move. `lock: GraphWriteLock` + * is compile-time evidence this read (like the incumbent re-read beside it) + * cannot precede the per-graph write lock — the same device + * `assertCompositionExistencePreserved` uses. + */ +export async function decideCompositionAttachmentUnderFence( + registry: KindRegistry, + target: GraphReadBackend, + graphId: string, + partId: string, + request: CompositionAttachmentRequest, + lock: GraphWriteLock, + frame: Readonly<{ partRowRestoredByUpdate: boolean }>, +): Promise { + void lock; + const incumbent = await findLiveCompositionAttachment( + registry, + target, + graphId, + request.work.partKind, + partId, + ); + const disposition = decideCompositionIncumbent( + registry, + partId, + request, + incumbent, + ); + if (disposition !== "satisfied" || frame.partRowRestoredByUpdate) { + const { whole } = request.work; + const wholeRow = await target.getNode(graphId, whole.kind, whole.id); + assertCompositionWholeEndpointLive(request.work, wholeRow); + } + return { + request, + disposition, + ...(incumbent === undefined ? {} : { incumbent }), + }; +} + +/** + * The live whole a composition part currently holds, if any — read to name + * it in `CompositionExistenceError`'s `situation: "existing"` message, and + * to decide whether a `getOrCreateByConstraint` postcondition is already + * satisfied. The whole-only projection of + * {@link findLiveCompositionAttachment}. + */ +export async function findLiveCompositionWhole( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + concreteKind: string, + concreteId: string, + excludeEdgeIds?: ReadonlySet, +): Promise { + const attachment = await findLiveCompositionAttachment( + registry, + backend, + graphId, + concreteKind, + concreteId, + excludeEdgeIds, + ); + return attachment?.whole; +} + +/** + * THE refusal a node RESURRECTION owes that states no attachment: a tombstoned + * part of a required-existence kind may only come back holding a live whole. + * + * The restore-side twin of {@link resolveCompositionCreate}'s bare-create + * refusal, for the entries that revive a row without accepting a `partOf` + * (`upsertById`, `bulkUpsertById`, `bulkReplaceById`, and an update that + * clears a tombstone). Deleting a part removes its own composition edges, so + * the usual answer is that no attachment survives and the restore is refused; + * the read keeps the verdict honest for a tombstone whose edge does survive + * (rows written outside the store's write path), judged through the same + * readers the `compositionExistence` audit uses + * ({@link findLiveCompositionAttachment}, {@link readLiveCompositionWholes}). + * + * Called before the frame's first statement, for every candidate at once, so + * a caller that catches the refusal inside an enclosing `store.transaction` + * keeps nothing of the batch. A candidate whose row is live is not being + * restored and is skipped; a kind that requires no whole costs no read. Every + * caller's frame holds the per-graph write lock for every kind that does + * (`nodeRestoreNeedsConstraintFence`, `constraints.ts`), and the reads are + * gated by {@link fenceCompositionExistenceDecision} on it: the edge and + * whole read here are exactly what a concurrent detach or whole delete + * changes. + */ +export async function assertRestoredRequiredPartsAttached( + ctx: Readonly<{ + graphId: string; + registry: KindRegistry; + lock: GraphWriteLock; + }>, + target: CompositionExistenceTarget, + parts: readonly CompositionNodeRef[], +): Promise { + for (const part of parts) { + if (ctx.registry.compositionExistence(part.kind) !== "required") continue; + if (compositionExistenceDeferred(target, part)) continue; + await fenceCompositionExistenceDecision(target, ctx.graphId, ctx.lock); + const row = await target.getNode(ctx.graphId, part.kind, part.id); + if (isLiveNodeRowPresent(row)) continue; + const attachment = await findLiveCompositionAttachment( + ctx.registry, + target, + ctx.graphId, + part.kind, + part.id, + ); + const liveWholes = + attachment === undefined ? + [] + : await readLiveCompositionWholes(target, ctx.graphId, [ + attachment.whole, + ]); + if (liveWholes.length > 0) continue; + throw new CompositionExistenceError({ + partKind: part.kind, + partId: part.id, + situation: "create", + }); + } +} + +/** + * Every concrete node kind the proposed/live registry declares a + * required-existence composition part — expanded through + * `expandSubClasses` so a SUBCLASS of a declared required part kind (which + * `resolveCompositionCreate` already refuses to create without a whole, via + * `isAssignableTo`) is scanned too. A pair's `partKind` names the kind the + * ontology was declared against; a live row of a subclass never declared + * directly is exactly as required-and-orphanable as one of the declared + * kind itself. + */ +export function requiredCompositionPartKinds( + registry: KindRegistry, +): readonly string[] { + const partKinds = new Set(); + for (const pair of requiredCompositionPairs(registry)) { + for (const concreteKind of pair.partKinds) partKinds.add(concreteKind); + } + return [...partKinds]; +} + +/** + * Every declared composition pair whose part is required, with the concrete + * part kinds it covers (the declared kind and its subclasses), in pair order. + * The one walk both {@link requiredCompositionPartKinds} and a reader that + * needs each pair's whole kind fold over. + */ +export function requiredCompositionPairs( + registry: KindRegistry, +): readonly Readonly<{ wholeKind: string; partKinds: readonly string[] }>[] { + return registry + .compositionRelation() + .pairs.filter( + (pair) => registry.compositionExistence(pair.partKind) === "required", + ) + .map((pair) => ({ + wholeKind: pair.wholeKind, + partKinds: registry.expandSubClasses(pair.partKind), + })); +} + +/** + * Whether the registry declares any required-existence composition part kind: + * a graph whose parts and their attaching composition edges must be written + * in ONE import transaction, since a part committed alone is an orphan. + */ +export function declaresRequiredCompositionParts( + registry: KindRegistry, +): boolean { + return requiredCompositionPartKinds(registry).length > 0; +} + +/** + * How many parts one attachment page resolves at a time — the `findNodesByKind` + * limit the audit pages on, and the chunk size every other consumer of + * {@link readCompositionAttachmentsForPage} (provenance's support computation) + * uses, so one page's candidate read stays one bounded statement per + * orientation on every caller. + */ +export const COMPOSITION_ATTACHMENT_PAGE_SIZE = 500; + +/** + * Every LIVE node of a required-existence part kind that currently has no + * live whole. Reused by `store.verifyConstraintFences()`'s + * `compositionExistence` family (graph-wide) and by + * `prepareSchemaTighteningPreflight`'s third composition check + * (delta-scoped to the part kinds a commit newly requires a whole for). + * + * A portable, non-pushdown scan — `findNodesByKind` paged, each row's verdict + * reached through {@link selectLiveCompositionAttachment}, the SAME predicate + * the write-path detach refusal reads — rather than a dedicated backend SQL + * audit member: a second, dialect-specific SQL implementation of "does this + * row have a live whole" would have to stay in step with + * `assertCompositionExistencePreserved`'s TypeScript one. + * + * The candidate edges of a whole PAGE of parts are read in one statement per + * orientation ({@link readPageAttachmentCandidateEdges}) instead of one read + * per row. A row the batched read finds no attachment for is confirmed + * through {@link findLiveCompositionWhole} before it is reported: no licensed + * rows is not evidence that none exist, and a wrong "unattached" here becomes + * a refusal at schema-commit time. Confirmation therefore costs one read per + * genuinely unattached row, never per attached one. + */ +/** + * One page of parts' candidate composition edges, keyed by part, read through + * {@link readCompositionEdgesAtRole} at the part endpoint. `undefined` when + * the backend omits the set-read port, which leaves the caller on its per-row + * reads; the population decision remains + * {@link compositionEdgeAttachesPart}'s alone. + */ +async function readPageAttachmentCandidateEdges( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + parts: readonly CompositionNodeRef[], +): Promise | undefined> { + const edgeKinds = new Set( + parts.flatMap((part) => [...registry.compositionEdgeKindsOver(part.kind)]), + ); + const rows = await readCompositionEdgesAtRole( + { graphId, registry }, + backend, + "part", + parts, + [...edgeKinds], + ); + if (rows === undefined) return undefined; + const byPart = new Map(); + for (const edge of rows) { + const partSide = registry.compositionPartSide(edge.kind); + if (partSide === undefined) continue; + const { part } = compositionRowEndpoints(partSide, edge); + const key = nodeRefKey(part); + const bucket = byPart.get(key) ?? []; + byPart.set(key, bucket); + bucket.push(edge); + } + return byPart; +} + +/** + * THE page-scoped answer to "which whole does each of these parts currently + * hang from", keyed by part. A part with no entry has no current attachment, + * CONFIRMED by its own read — the batched candidate read is an optimization, + * never the evidence for a negative (see + * {@link readPageAttachmentCandidateEdges}: an absent port answers + * `undefined`, and a wrong "unattached" becomes a refusal at schema-commit + * time). + * + * One owner, two consumers: `verifyConstraintFences`' `compositionExistence` + * family ({@link readCompositionUnattachedParts}) and provenance's support + * computation (`src/provenance/index.ts`), which would otherwise re-spell the + * batched-then-confirmed shape over its own fact rows. + */ +export async function readCompositionAttachmentsForPage( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + parts: readonly CompositionNodeRef[], +): Promise< + ReadonlyMap> +> { + const candidateEdges = await readPageAttachmentCandidateEdges( + registry, + backend, + graphId, + parts, + ); + const attachments = new Map< + string, + Readonly<{ edge: EdgeRow; whole: CompositionNodeRef }> + >(); + for (const row of parts) { + const partKey = nodeRefKey(row); + const batched = candidateEdges?.get(partKey); + const fromPage = + batched === undefined ? undefined : ( + selectLiveCompositionAttachment(registry, row.kind, row.id, batched) + ); + if (fromPage !== undefined) { + attachments.set(partKey, fromPage); + continue; + } + const confirmed = await findLiveCompositionAttachment( + registry, + backend, + graphId, + row.kind, + row.id, + ); + if (confirmed !== undefined) attachments.set(partKey, confirmed); + } + return attachments; +} + +/** + * The read surface a by-reference row read needs: the point read every backend + * has, plus the batch extra when the port carries it. Narrow on purpose — a + * write frame's transaction target and a read-only audit backend both satisfy + * it. + */ +type NodeRowByRefReader = Readonly> & + Readonly>>; + +/** + * THE by-reference row read: every `(kind, id)` these references name, + * deduplicated, read once per kind through the batch point read when the + * caller hands over the verdict that reaches `getNodes`, and through the + * per-id `getNode` the bundle declares as that extra's fallback when it does + * not. Keyed by {@link nodeRefKey}, with an explicit `undefined` for a + * reference that has no row at all, so a caller can tell "absent" from "not + * asked for". + * + * One owner, so no consumer spells the same dedupe, binding and fallback + * again: the node-create batch's attach loop + * (`prepareBatchCompositionCreateEdges`, `node-operations.ts`), which judges each + * whole row through {@link assertCompositionWholeEndpointLive} and carries it as + * the preparation's `"primedWhole"` evidence, {@link readLiveCompositionWholes} + * below, and `bulkReparent`'s lock-free part gate. The port is an optimization, + * never a requirement: the verdict a caller reaches must be identical either + * way. + */ +export async function readNodeRowsByRef( + port: NodeRowByRefReader, + graphId: string, + references: readonly CompositionNodeRef[], + batchPointRead?: BundleVerdictOf, +): Promise> { + const idsByKind = new Map>(); + for (const ref of references) { + const ids = idsByKind.get(ref.kind) ?? new Set(); + ids.add(ref.id); + idsByKind.set(ref.kind, ids); + } + const boundGetNodes = + batchPointRead === undefined ? undefined : ( + bindExtraIfReachable( + port, + batchPointRead.extras.getNodes, + BATCH_POINT_READ.id, + ) + ); + + const rowsByRef = new Map(); + for (const [kind, ids] of idsByKind) { + const orderedIds = [...ids]; + if (boundGetNodes === undefined) { + for (const id of orderedIds) { + rowsByRef.set( + nodeRefKey({ kind, id }), + await port.getNode(graphId, kind, id), + ); + } + continue; + } + const rows = await boundGetNodes.getNodes(graphId, kind, orderedIds); + const rowsById = new Map(rows.map((row) => [row.id, row])); + for (const id of orderedIds) { + rowsByRef.set(nodeRefKey({ kind, id }), rowsById.get(id)); + } + } + return rowsByRef; +} + +/** + * Which of these wholes are LIVE rows, as the whole-endpoint refusal judges + * liveness ({@link isLiveNodeRowPresent}: present and not tombstoned — a tombstone + * is the whole of the question, valid time is not). The filtering projection of + * {@link readNodeRowsByRef}, so it shares that read's dedupe, binding and + * fallback rather than repeating them. + * + * Separate from {@link selectLiveCompositionAttachment} on purpose: the write + * path's incumbent decision must keep seeing a tombstoned whole's attachment, + * while a consumer asking "is this part still held up" composes the two. + */ +export async function readLiveCompositionWholes( + backend: GraphReadBackend, + graphId: string, + wholes: readonly CompositionNodeRef[], + batchPointRead?: BundleVerdictOf, +): Promise { + const rowsByWhole = await readNodeRowsByRef( + backend, + graphId, + wholes, + batchPointRead, + ); + const live: CompositionNodeRef[] = []; + for (const row of rowsByWhole.values()) { + if (!isLiveNodeRowPresent(row)) continue; + live.push({ kind: row.kind, id: row.id }); + } + return live; +} + +/** + * One part and the LIVE whole it currently hangs from. `whole` is `undefined` + * when the part has no current composition edge OR the whole that edge names + * is not a live row. The second arm is what catches a part left hanging from + * a TOMBSTONED or absent whole — a state the delete cascade cannot produce + * but a direct backend write, a custom port, an import that skipped reference + * validation, or a belief-status close of a whole whose parts are optional + * can. + */ +export type CompositionPartStanding = Readonly<{ + part: CompositionNodeRef; + whole: CompositionNodeRef | undefined; +}>; + +/** + * THE page-scoped answer to "is each of these parts held up by a live whole", + * in input order: the current attachment + * ({@link readCompositionAttachmentsForPage}, so {@link compositionEdgeAttachesPart} + * decides which edge counts) composed with the whole's own liveness + * ({@link readLiveCompositionWholes}). + * + * One owner for the two consumers that must agree row for row: the + * `compositionExistence` audit ({@link readCompositionUnattachedParts}) and + * interchange import's end-of-import assertion, which refuses exactly the + * parts the audit would report. + */ +export async function readCompositionPartStandings( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + parts: readonly CompositionNodeRef[], + batchPointRead?: BundleVerdictOf, +): Promise { + const attachments = await readCompositionAttachmentsForPage( + registry, + backend, + graphId, + parts, + ); + const liveWholes = await readLiveCompositionWholes( + backend, + graphId, + [...attachments.values()].map((attachment) => attachment.whole), + batchPointRead, + ); + const liveWholeKeys = new Set(liveWholes.map((whole) => nodeRefKey(whole))); + return parts.map((part) => { + const whole = attachments.get(nodeRefKey(part))?.whole; + return { + part: { kind: part.kind, id: part.id }, + whole: + whole !== undefined && liveWholeKeys.has(nodeRefKey(whole)) ? + whole + : undefined, + }; + }); +} + +/** + * Every LIVE node of these part kinds that {@link readCompositionPartStandings} + * finds with no live whole, paged by id. + */ +export async function readCompositionUnattachedParts( + registry: KindRegistry, + backend: GraphReadBackend, + graphId: string, + partKinds: readonly string[], + batchPointRead?: BundleVerdictOf, +): Promise { + const unattached: CompositionNodeRef[] = []; + for (const partKind of partKinds) { + for await (const rows of pageNodesByKind( + backend, + { graphId, kind: partKind, excludeDeleted: true }, + COMPOSITION_ATTACHMENT_PAGE_SIZE, + )) { + const standings = await readCompositionPartStandings( + registry, + backend, + graphId, + rows, + batchPointRead, + ); + for (const standing of standings) { + if (standing.whole === undefined) unattached.push(standing.part); + } + } + } + return unattached; +} + +/** + * Every non-deleted row of these composition edge kinds whose window starts + * or ends later than `operationInstant` — the rows + * the no-future-windows rule ({@link assertCompositionWindowCurrent}) refuses + * to write, found where a write that rule never saw left them: a row written + * before its edge kind realized a pair, or one planted beneath the store. + * Ended rows are read too: a row whose `validFrom` is later than `now` can + * still carry a `validTo`. + * + * A portable scan judged by {@link compositionWindowFutureBound}, the write + * path's own comparison, rather than a SQL rendering of "later than now" that + * would have to stay in step with it. Reused by `store.verifyConstraintFences()` + * (every realizing edge kind) and by the schema-commit preflight (the edge + * kinds of the pairs a commit adds). + */ +export async function readCompositionFutureWindowEdges( + backend: GraphReadBackend, + graphId: string, + edgeKinds: readonly string[], + operationInstant: string, +): Promise[]> { + const scheduled: Readonly<{ kind: string; id: string }>[] = []; + for (const edgeKind of new Set(edgeKinds)) { + for await (const rows of pageEdgesByKind( + backend, + { + graphId, + kind: edgeKind, + excludeDeleted: true, + temporalMode: "includeEnded", + }, + COMPOSITION_ATTACHMENT_PAGE_SIZE, + )) { + for (const row of rows) { + const window = { validFrom: row.valid_from, validTo: row.valid_to }; + if ( + compositionWindowFutureBound(window, operationInstant) !== undefined + ) { + scheduled.push({ kind: row.kind, id: row.id }); + } + } + } + } + return scheduled; +} diff --git a/packages/typegraph/src/store/operations/edge-batch-validation.ts b/packages/typegraph/src/store/operations/edge-batch-validation.ts index f7ad752e6..f1c679442 100644 --- a/packages/typegraph/src/store/operations/edge-batch-validation.ts +++ b/packages/typegraph/src/store/operations/edge-batch-validation.ts @@ -4,7 +4,7 @@ * A batch decides every row before it writes any of them, so a probe that read * only the database would let two rows of one batch each see "no conflict" and * both land — the in-batch collision. This wrapper is the state that closes - * that: it overlays the reads a cardinality probe makes (`countEdgesFrom`, + * that: it overlays the reads a cardinality probe makes (`countEdgesAtEndpoint`, * `edgeExistsBetween`) with the rows the batch has already accepted, so row * k+1's probe sees rows 1..k and refuses per ROW rather than at the flush. * @@ -22,17 +22,39 @@ * per batch, by the caller — a claim against this wrapper would still reach the * real target (`deriveBackend` forwards every non-overlaid member) but * would be a second, unsorted, per-row claim in addition to the batch's. + * + * The pending counters fold over {@link edgeCardinalityAxisReferences} — the same + * fold the write-time probe and the real claim use — rather than re-spelling + * each cardinality's rules inline, so a target-only declaration accumulates + * in-batch pending state exactly as a source-only one always has. Both the + * write side (`registerPendingEdgeForCardinality`) and the read side + * (`countEdgesAtEndpointCached`) key a `from`/`to`-shaped axis by the exact + * tuple `countEdgesAtEndpoint` itself reads — `(graphId, edgeKind, endpoint, + * endpointKind, endpointId, activeOnly)` — so the two sides cannot drift + * without one comparison in one function catching it. + * + * The module's second overlay, {@link createRetiringEdgeValidationBackend}, + * is the same device pointed the other way: a frame that RETIRES a row and + * inserts its replacement prepares the replacement against a count the + * retiring row has already left. */ import { deriveBackend } from "../../backend/derive-backend"; import { + type CountEdgesAtEndpointParams, type EdgeRow, type GraphBackend, type InsertEdgeParams, type NodeRow, type NodeRowLock, } from "../../backend/types"; -import { type Cardinality } from "../../core/types"; import { encodeTupleKey } from "../../utils/tuple-key"; +import { + axisCountsRow, + edgeCardinalityAxisReferences, + type EdgeCardinalityDeclarations, + edgeCardinalitySpec, + membershipCountsRow, +} from "../claims/edge-claims"; import { lockNodeRow, lockNodeRows } from "./node-row-locks"; import { type WriteTarget } from "./write-session"; @@ -44,15 +66,6 @@ function buildEdgeEndpointCacheKey( return encodeTupleKey([graphId, kind, id]); } -function buildEdgeFromCacheKey( - graphId: string, - edgeKind: string, - fromKind: string, - fromId: string, -): string { - return encodeTupleKey([graphId, edgeKind, fromKind, fromId]); -} - function buildEdgeBetweenCacheKey( graphId: string, edgeKind: string, @@ -64,17 +77,31 @@ function buildEdgeBetweenCacheKey( return encodeTupleKey([graphId, edgeKind, fromKind, fromId, toKind, toId]); } -function buildCountEdgesFromCacheKey( - params: Parameters[0], +type EndpointCountScope = Pick< + CountEdgesAtEndpointParams, + "graphId" | "edgeKind" | "endpoint" | "endpointKind" | "endpointId" +>; + +function endpointCountTuple(scope: EndpointCountScope): readonly string[] { + return [ + scope.graphId, + scope.edgeKind, + scope.endpoint, + scope.endpointKind, + scope.endpointId, + ]; +} + +/** The key of one endpoint's count, before the active-only choice. */ +function buildEndpointKey(scope: EndpointCountScope): string { + return encodeTupleKey(endpointCountTuple(scope)); +} + +function buildCountEdgesAtEndpointCacheKey( + params: CountEdgesAtEndpointParams, ): string { const activeOnly = params.activeOnly === true ? "1" : "0"; - return encodeTupleKey([ - params.graphId, - params.edgeKind, - params.fromKind, - params.fromId, - activeOnly, - ]); + return encodeTupleKey([...endpointCountTuple(params), activeOnly]); } function incrementPendingCount(counts: Map, key: string): void { @@ -88,7 +115,7 @@ export function createEdgeBatchValidationBackend( backend: WriteTarget; registerPendingEdgeForCardinality: ( insertParams: InsertEdgeParams, - cardinality: Cardinality, + declarations: EdgeCardinalityDeclarations, ) => void; seedEndpointRow: ( graphId: string, @@ -103,7 +130,7 @@ export function createEdgeBatchValidationBackend( * issue singleton probes for those negative cases. */ seedCardinalityRows: ( - countRequests: readonly Parameters[0][], + countRequests: readonly CountEdgesAtEndpointParams[], uniqueRequests: readonly Parameters[0][], rows: readonly EdgeRow[], ) => void; @@ -112,11 +139,10 @@ export function createEdgeBatchValidationBackend( string, Awaited> >(); - const countEdgesFromCache = new Map(); + const countEdgesAtEndpointCache = new Map(); const edgeExistsCache = new Map(); - const pendingOneCounts = new Map(); - const pendingOneActiveCounts = new Map(); - const pendingUniquePairs = new Set(); + const pendingByTarget = new Map(); + const pendingUniqueTargets = new Set(); // Every cached endpoint row was read under the edge-endpoint lock — by the // batch's priming pass or by a miss below — so serving a later endpoint @@ -167,25 +193,16 @@ export function createEdgeBatchValidationBackend( endpointCache.set(cacheKey, row); } - async function countEdgesFromCached( - params: Parameters[0], + async function countEdgesAtEndpointCached( + params: CountEdgesAtEndpointParams, ): Promise { - const cacheKey = buildCountEdgesFromCacheKey(params); - let baseCount = countEdgesFromCache.get(cacheKey); + const cacheKey = buildCountEdgesAtEndpointCacheKey(params); + let baseCount = countEdgesAtEndpointCache.get(cacheKey); if (baseCount === undefined) { - baseCount = await backend.countEdgesFrom(params); - countEdgesFromCache.set(cacheKey, baseCount); + baseCount = await backend.countEdgesAtEndpoint(params); + countEdgesAtEndpointCache.set(cacheKey, baseCount); } - const pendingKey = buildEdgeFromCacheKey( - params.graphId, - params.edgeKind, - params.fromKind, - params.fromId, - ); - const pendingCount = - params.activeOnly === true ? - (pendingOneActiveCounts.get(pendingKey) ?? 0) - : (pendingOneCounts.get(pendingKey) ?? 0); + const pendingCount = pendingByTarget.get(cacheKey) ?? 0; return baseCount + pendingCount; } @@ -200,7 +217,7 @@ export function createEdgeBatchValidationBackend( params.toKind, params.toId, ); - if (pendingUniquePairs.has(cacheKey)) { + if (pendingUniqueTargets.has(cacheKey)) { return true; } if (edgeExistsCache.has(cacheKey)) { @@ -211,41 +228,58 @@ export function createEdgeBatchValidationBackend( return exists; } + /** + * Folds the row's declaration through {@link edgeCardinalityAxisReferences}, + * exactly as the write-time probe and the real claim do, and records ONE + * pending entry per applicable axis — a `fromAndTo`-shaped axis (source + * `unique`) into `pendingUniqueTargets`, a `from`/`to`-shaped one into + * `pendingByTarget` under the same key `countEdgesAtEndpointCached` reads. + * An axis that does not count this row (`axisCountsRow`: active-only, and + * the row states a `validTo`) records nothing, matching the real claim it + * would never take. + */ function registerPendingEdgeForCardinality( insertParams: InsertEdgeParams, - cardinality: Cardinality, + declarations: EdgeCardinalityDeclarations, ): void { - const fromCacheKey = buildEdgeFromCacheKey( - insertParams.graphId, - insertParams.kind, - insertParams.fromKind, - insertParams.fromId, - ); - if (cardinality === "one") { - incrementPendingCount(pendingOneCounts, fromCacheKey); - return; - } - if (cardinality === "oneActive") { - if (insertParams.validTo === undefined) { - incrementPendingCount(pendingOneActiveCounts, fromCacheKey); + for (const ref of edgeCardinalityAxisReferences(declarations)) { + if (!axisCountsRow(ref, insertParams)) continue; + const spec = edgeCardinalitySpec(ref); + if (spec.keyShape === "fromAndTo") { + pendingUniqueTargets.add( + buildEdgeBetweenCacheKey( + insertParams.graphId, + insertParams.kind, + insertParams.fromKind, + insertParams.fromId, + insertParams.toKind, + insertParams.toId, + ), + ); + continue; } - return; - } - if (cardinality === "unique") { - const uniqueCacheKey = buildEdgeBetweenCacheKey( - insertParams.graphId, - insertParams.kind, - insertParams.fromKind, - insertParams.fromId, - insertParams.toKind, - insertParams.toId, - ); - pendingUniquePairs.add(uniqueCacheKey); + const endpoint = spec.keyShape; + const { endpointKind, endpointId } = + endpoint === "from" ? + { + endpointKind: insertParams.fromKind, + endpointId: insertParams.fromId, + } + : { endpointKind: insertParams.toKind, endpointId: insertParams.toId }; + const key = buildCountEdgesAtEndpointCacheKey({ + graphId: insertParams.graphId, + edgeKind: insertParams.kind, + endpoint, + endpointKind, + endpointId, + activeOnly: spec.holderLiveness === "liveAndActive", + }); + incrementPendingCount(pendingByTarget, key); } } function seedCardinalityRows( - countRequests: readonly Parameters[0][], + countRequests: readonly CountEdgesAtEndpointParams[], uniqueRequests: readonly Parameters[0][], rows: readonly EdgeRow[], ): void { @@ -253,17 +287,22 @@ export function createEdgeBatchValidationBackend( const pairs = new Set(); for (const row of rows) { if (row.deleted_at !== undefined) continue; - const sourceKey = encodeTupleKey([ - row.graph_id, - row.kind, - row.from_kind, - row.from_id, - ]); - const previous = counts.get(sourceKey) ?? { all: 0, active: 0 }; - counts.set(sourceKey, { - all: previous.all + 1, - active: previous.active + (row.valid_to === undefined ? 1 : 0), - }); + const active = + membershipCountsRow("openRows", { validTo: row.valid_to }) ? 1 : 0; + for (const endpoint of ["from", "to"] as const) { + const endpointKey = buildEndpointKey({ + graphId: row.graph_id, + edgeKind: row.kind, + endpoint, + endpointKind: endpoint === "from" ? row.from_kind : row.to_kind, + endpointId: endpoint === "from" ? row.from_id : row.to_id, + }); + const previous = counts.get(endpointKey) ?? { all: 0, active: 0 }; + counts.set(endpointKey, { + all: previous.all + 1, + active: previous.active + active, + }); + } pairs.add( buildEdgeBetweenCacheKey( row.graph_id, @@ -276,18 +315,15 @@ export function createEdgeBatchValidationBackend( ); } for (const params of countRequests) { - const sourceKey = encodeTupleKey([ - params.graphId, - params.edgeKind, - params.fromKind, - params.fromId, - ]); - const sourceCounts = counts.get(sourceKey); + const endpointCounts = counts.get(buildEndpointKey(params)); const count = params.activeOnly === true ? - (sourceCounts?.active ?? 0) - : (sourceCounts?.all ?? 0); - countEdgesFromCache.set(buildCountEdgesFromCacheKey(params), count); + (endpointCounts?.active ?? 0) + : (endpointCounts?.all ?? 0); + countEdgesAtEndpointCache.set( + buildCountEdgesAtEndpointCacheKey(params), + count, + ); } for (const params of uniqueRequests) { const exists = pairs.has( @@ -316,7 +352,7 @@ export function createEdgeBatchValidationBackend( const validationBackend = deriveBackend(backend, { lockNodes: lockNodesCached, - countEdgesFrom: countEdgesFromCached, + countEdgesAtEndpoint: countEdgesAtEndpointCached, edgeExistsBetween: edgeExistsBetweenCached, } satisfies Partial); @@ -327,3 +363,197 @@ export function createEdgeBatchValidationBackend( seedCardinalityRows, }; } + +/** + * Whether `countEdgesAtEndpoint(params)` counts `row`: the row-level rendering + * of the exact predicate that read applies — same graph, edge kind, and + * endpoint column pair, not deleted, and open-ended when the read is + * `activeOnly`. Kept beside the cache key above, which mirrors the same + * tuple, so the two renderings of one read live in one place. + */ +function countEdgesAtEndpointIncludes( + params: CountEdgesAtEndpointParams, + row: Pick< + EdgeRow, + | "graph_id" + | "kind" + | "from_kind" + | "from_id" + | "to_kind" + | "to_id" + | "deleted_at" + | "valid_to" + >, +): boolean { + const [endpointKind, endpointId] = + params.endpoint === "from" ? + [row.from_kind, row.from_id] + : [row.to_kind, row.to_id]; + return ( + row.graph_id === params.graphId && + row.kind === params.edgeKind && + endpointKind === params.endpointKind && + endpointId === params.endpointId && + row.deleted_at === undefined && + membershipCountsRow(params.activeOnly === true ? "openRows" : "allRows", { + validTo: row.valid_to, + }) + ); +} + +/** + * A row a frame retires before its inserts land, and how: soft-deleted + * (`"delete"`, a `population: "one"` incumbent), or its window ended at + * `validTo` (`"endWindow"`, a `population: "oneActive"` incumbent). + */ +export type RetiringEdge = + | Readonly<{ row: EdgeRow; retire: "delete" }> + | Readonly<{ row: EdgeRow; retire: "endWindow"; validTo: string }>; + +/** + * Whether `countEdgesAtEndpoint(params)` stops counting `retiring.row` once + * its retire lands: it counts the row now, and does not count the row's + * retired state. A deleted row leaves every count; an ended row leaves only + * a count that admits open rows alone (`activeOnly`) and stays in every + * other — it is still a live row of the edge kind. + */ +function retireLeavesCount( + params: CountEdgesAtEndpointParams, + retiring: RetiringEdge, +): boolean { + if (!countEdgesAtEndpointIncludes(params, retiring.row)) return false; + if (retiring.retire === "delete") return true; + return !countEdgesAtEndpointIncludes(params, { + ...retiring.row, + valid_to: retiring.validTo, + }); +} + +/** + * The frame's write target as a replacement edge's preparation must read it: + * with `retiring` — the rows this same frame retires AFTER the preparation + * and BEFORE the inserts — already in their retired state in every endpoint + * count it holds. + * + * `reparent`'s replace arm (`prepareCompositionAttachmentMoves`, + * `node-operations.ts`) is the caller, with the incumbent of every move in + * its batch: preparing the replacement before the + * incumbent is retired is what keeps a refusal the preparation raises + * (cardinality, acyclicity, invalid edge props) from leaving the part + * detached when the caller catches it inside an enclosing transaction, and + * preparing it against the raw count would refuse every move whose incumbent + * realizes the same edge kind — the incumbent still counts against the + * part's own cardinality until it is retired. Subtracting exactly the rows + * whose retire takes them out of a count ({@link retireLeavesCount}) yields + * the count the inserts and their claims will see: a `population: "one"` + * incumbent is soft-deleted and leaves every count; a `population: + * "oneActive"` incumbent has its window ended, so it leaves an active-only + * count but still occupies a count of every live row — a whole-side `one` + * bound on the same realizing edge, say — exactly as the claim taken at the + * insert will find it. + * + * `edgeExistsBetween` is deliberately not overlaid: an incumbent that shared + * the replacement's `(kind, from, to)` would be the same attachment, which + * the decision reports as `"satisfied"` and never replaces. + */ +export function createRetiringEdgeValidationBackend( + target: WriteTarget, + retiring: readonly RetiringEdge[], +): WriteTarget { + if (retiring.length === 0) return target; + return deriveBackend(target, { + countEdgesAtEndpoint: async ( + params: CountEdgesAtEndpointParams, + ): Promise => { + const count = await target.countEdgesAtEndpoint(params); + const leaving = retiring.filter((edge) => + retireLeavesCount(params, edge), + ); + return count - leaving.length; + }, + } satisfies Partial); +} + +/** + * The frame's write target as a SET of edge row writes is judged against + * before any of them lands: a frame that prepares every write first and issues + * them afterwards registers each row's transition as soon as that row is + * prepared, so the next row's cardinality and composition probes count the + * population the earlier writes will leave, not the stored one. + * + * A registered transition takes the row's STORED state out of every endpoint + * count it was in and puts its state after the write into every count that + * state is in — through {@link countEdgesAtEndpointIncludes}, the row-level + * rendering of the read itself, so the overlay cannot count a row the read + * would not. A row the frame is about to INSERT registers with no stored + * state, and a row written twice keeps its stored state and only its latest + * one. `edgeExistsBetween` counts live rows, which an update can only add to + * (no update soft-deletes), so it answers the stored read or any live row a + * transition leaves on the pair. + * + * This generalizes {@link createRetiringEdgeValidationBackend} (a transition + * whose later state counts nowhere) and the pending inserts of + * {@link createEdgeBatchValidationBackend} (a transition with no stored state) + * to the writes that do both: a resurrection or window reopen enters a count, + * a window end leaves one. + */ +export function createEdgeTransitionValidationBackend( + target: WriteTarget, +): Readonly<{ + backend: WriteTarget; + registerEdgeTransition: (stored: EdgeRow | undefined, after: EdgeRow) => void; +}> { + const transitions = new Map< + string, + Readonly<{ stored: EdgeRow | undefined; after: EdgeRow }> + >(); + + function registerEdgeTransition( + stored: EdgeRow | undefined, + after: EdgeRow, + ): void { + const earlier = transitions.get(after.id); + transitions.set(after.id, { + stored: earlier === undefined ? stored : earlier.stored, + after, + }); + } + + const backend = deriveBackend(target, { + countEdgesAtEndpoint: async ( + params: CountEdgesAtEndpointParams, + ): Promise => { + const storedCount = await target.countEdgesAtEndpoint(params); + const pendingDelta = [...transitions.values()].reduce( + (delta, { stored, after }) => + delta - + (( + stored !== undefined && countEdgesAtEndpointIncludes(params, stored) + ) ? + 1 + : 0) + + (countEdgesAtEndpointIncludes(params, after) ? 1 : 0), + 0, + ); + return storedCount + pendingDelta; + }, + edgeExistsBetween: async (params): Promise => { + for (const { after } of transitions.values()) { + if ( + after.graph_id === params.graphId && + after.kind === params.edgeKind && + after.from_kind === params.fromKind && + after.from_id === params.fromId && + after.to_kind === params.toKind && + after.to_id === params.toId && + after.deleted_at === undefined + ) { + return true; + } + } + return target.edgeExistsBetween(params); + }, + } satisfies Partial); + + return { backend, registerEdgeTransition }; +} diff --git a/packages/typegraph/src/store/operations/edge-operations.ts b/packages/typegraph/src/store/operations/edge-operations.ts index d154422ae..b8f25bf5a 100644 --- a/packages/typegraph/src/store/operations/edge-operations.ts +++ b/packages/typegraph/src/store/operations/edge-operations.ts @@ -112,11 +112,7 @@ import { } from "../../backend/types"; import { validateEdgeEndpoints } from "../../constraints"; import { type GraphDef } from "../../core/define-graph"; -import { - type Cardinality, - type KindEntity, - type TemporalMode, -} from "../../core/types"; +import { type KindEntity, type TemporalMode } from "../../core/types"; import { CardinalityError, CompilerInvariantError, @@ -124,7 +120,6 @@ import { DatabaseOperationError, EdgeMatchIdentityConflictError, EdgeNotFoundError, - EndpointNotFoundError, KindNotFoundError, ValidationError, } from "../../errors"; @@ -137,35 +132,62 @@ import { compareCodePoints } from "../../utils/compare"; import { assertOrderedValidityWindow, assertWritableValidityWindow, + nowIso, preservesImmutableLowerBound, + resolveStampedValidityLowerBound, + resolveStatedValidityLowerBound, validateOptionalCanonicalIsoDate, validateStatedValidityLowerBound, } from "../../utils/date"; import { generateId } from "../../utils/id"; import { hasOwnKey, readOwnProperty } from "../../utils/object"; import { requireDefined } from "../../utils/presence"; -import { encodeTupleKey } from "../../utils/tuple-key"; -import { compareClaimTargets } from "../claims/axis"; +import { encodeTupleKey, nodeRefKey } from "../../utils/tuple-key"; +import { + type AcyclicityProbeContext, + acyclicityProbeContext as buildAcyclicityProbeContext, + acyclicRelationCountsRow, + acyclicRelationMembership, + assertEdgeRelationsAcyclic, + assertUnwrittenEdgeRelationsAcyclic, + edgeKindIsInAcyclicRelation, + type ProposedRelationEdge, +} from "../acyclicity"; +import { targetIdentity } from "../claims/axis"; +import { + compositionReentryClaim, + edgeInsertClaims, + edgeKindOwesAnyClaim, +} from "../claims/composition-claims"; import { - claimEdgeCardinality, - edgeCardinalityClaim, + activeOnlyAxisReferences, + claimEdgeCardinalities, + claimRefusalFor, + type EdgeCardinalityAxisRef, + edgeCardinalityAxisReferences, edgeCardinalityClaimMode, - edgeCardinalityClaimRefusal, + edgeCardinalityClaims, edgeCardinalityClaimTarget, + type EdgeCardinalityDeclarations, + type EdgeClaimSubject, + sortedByClaimTarget, } from "../claims/edge-claims"; import { shouldCoalesceUpsert, type UpsertDirtyCheck, } from "../collections/coalesce"; import { + type EdgeCreateBatchOptions, type EdgeUpsertUpdateBatchEntry, type UpsertUpdateEdgeInput, } from "../collections/edge-collection"; import { - checkCardinalityConstraint, + checkEdgeCardinalityConstraints, type ConstraintContext, type ConstraintFenceReason, - edgeWriteNeedsConstraintFence, + edgeDetachNeedsConstraintFence, + edgeKindCardinalityDeclarations, + edgeKindFenceReason, } from "../constraints"; import { classifyDurableEdgeBatchOutcomes } from "../durable-edge-batch"; import { getEdgeRowsByIds } from "../edge-fetch"; @@ -185,11 +207,13 @@ import { } from "../resolved-mutation-set"; import { type EdgeRow, rowToEdge } from "../row-mappers"; import { + type CompositionNodeRef, type CreateEdgeInput, type Edge, type GetOrCreateAction, type IfExistsMode, type OperationHookContext, + type OperationOutcomeFacts, } from "../types"; import { assertClearValidToSupported, @@ -211,7 +235,18 @@ import { canFuseSchemaFenceInFirstWrite, isAutocommitSingleStatementWrite, } from "./autocommit-single-statement"; -import { createEdgeBatchValidationBackend } from "./edge-batch-validation"; +import { + assertCompositionExistencePreserved, + assertCompositionWindowCurrent, + assertEndpointRowLive, + compositionWindowSubject, + edgeWriteEndsOpenWindow, + type ResolvedCompositionMoveInstant, +} from "./composition-create"; +import { + createEdgeBatchValidationBackend, + createEdgeTransitionValidationBackend, +} from "./edge-batch-validation"; import { assertEdgeIdentityMatches, type EdgeIdentityExpectation, @@ -236,7 +271,7 @@ import { assertsStoredWindowState, type EdgeUpdateFences, } from "./write-fences"; -import { edgeWritePlan } from "./write-plan"; +import { edgeBatchWritePlan, edgeWritePlan } from "./write-plan"; import { type EdgeInsertWork, type EdgeWriteSession, @@ -269,8 +304,8 @@ export type EdgeOperationContext = Readonly<{ revisionSchema: SqlSchema; registry: KindRegistry; /** - * The `claims` bundle's memoized, at-most-once verdict thunk (ruling B7 - * refinement 2) — threaded through to `createEdgeWriteContext` by + * The `claims` bundle's memoized, at-most-once verdict thunk — threaded + * through to `createEdgeWriteContext` by * `runWritePlan`'s session mint, and called at the write-session sites that * issue or release an edge-cardinality claim. */ @@ -297,6 +332,7 @@ export type EdgeOperationContext = Readonly<{ ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ) => Promise; }>; @@ -316,28 +352,41 @@ function getEdgeRegistration(graph: G, kind: string) { return registration; } -type EdgeCreatePrepared = Readonly<{ +/** + * One edge create after every read it owes has passed and before its insert + * runs: the row params and the declarations that decide its claims. Held + * across other statements by a frame that sequences its reads first (the + * get-or-create update leg, `node-operations.ts`) and consumed only by + * {@link edgeInsertWork}. + */ +export type EdgeCreatePrepared = Readonly<{ insertParams: InsertEdgeParams; - cardinality: Cardinality; + declarations: EdgeCardinalityDeclarations; }>; /** - * One prepared create as the session's insert unit: the row params and the - * cardinality claim the row owes. + * One prepared create as the session's insert unit: the row params and every + * claim the row owes — its declared cardinality axes, plus the composition + * claim when its edge kind realizes a `partOf`/`hasPart` pair. * - * ONE owner, shared by the single create and both batch shapes. The claim is a - * pure function of the cardinality this preparation resolved, so deciding it - * here keeps the decision beside the verdict it follows from; the session issues - * it, because a claim write is a backend member only the seam may spell. + * ONE owner, shared by the single create and both batch shapes. The claim set + * is a pure function of the declarations this preparation resolved (and the + * registry, for composition), so deciding it here keeps the decision beside + * the verdict it follows from; the session issues it, because a claim write + * is a backend member only the seam may spell. */ -function edgeInsertWork(prepared: EdgeCreatePrepared): EdgeInsertWork { - const claim = edgeCardinalityClaim( - prepared.cardinality, +export function edgeInsertWork( + ctx: EdgeOperationContext, + prepared: EdgeCreatePrepared, +): EdgeInsertWork { + const claims = edgeInsertClaims( + ctx.registry, + prepared.declarations, prepared.insertParams, ); return { params: prepared.insertParams, - claim, + claims, }; } @@ -382,7 +431,7 @@ function buildInsertEdgeParams( return insertParams; } -async function validateAndPrepareEdgeCreate( +export async function validateAndPrepareEdgeCreate( ctx: EdgeOperationContext, input: CreateEdgeInput, id: string, @@ -390,6 +439,19 @@ async function validateAndPrepareEdgeCreate( options?: Readonly<{ validateEndpoints?: boolean; validateCardinality?: boolean; + /** + * Whether this call owes the acyclicity probe when the edge kind + * declares `acyclic: true`. Defaults to `true`; callers that already + * covered it earlier in the same write frame (or that probe the whole + * prepared set once before inserting, as batch create does) pass `false` so the + * relation is not walked twice for one write. `lock` is required + * whenever the probe actually runs — see + * `assertEdgeRelationsAcyclic`'s isolation-freshness guard. + */ + validateAcyclicity?: boolean; + lock?: GraphWriteLock; + /** A `reparent`'s instant; see {@link assertCompositionEdgeWindowCurrent}. */ + moveInstant?: ResolvedCompositionMoveInstant | undefined; }>, ): Promise { const kind = input.kind; @@ -457,29 +519,67 @@ async function validateAndPrepareEdgeCreate( // for this layer to judge. Both create paths (single and batch) prepare // through here, so this is the only insert-side check needed. assertOrderedValidityWindow(`edge "${id}"`, validFrom, validTo); + assertCompositionEdgeWindowCurrent( + ctx, + { kind, id, fromKind, fromId: input.fromId, toKind, toId: input.toId }, + { validFrom, validTo }, + options?.moveInstant, + ); // Check cardinality constraints - const cardinality = registration.cardinality ?? "many"; + const declarations: EdgeCardinalityDeclarations = registration; const constraintContext: ConstraintContext = { graphId: ctx.graphId, registry: ctx.registry, backend, }; if (options?.validateCardinality ?? true) { - await checkCardinalityConstraint( + await checkEdgeCardinalityConstraints( constraintContext, - kind, - cardinality, - fromKind, - input.fromId, - toKind, - input.toId, - validTo, + edgeInsertClaims(ctx.registry, declarations, { + graphId: ctx.graphId, + id, + kind, + fromKind, + fromId: input.fromId, + toKind, + toId: input.toId, + ...(validTo === undefined ? {} : { validTo }), + }), + ); + } + + // Check acyclicity. Independent of `validateCardinality`: unlike + // cardinality, acyclicity has no database key that could back it + // (`CONSTRAINT_FENCE_BACKING.edgeAcyclicity === "lockOnly"`), so a + // guarded-claim or durable-identity create that skips the cardinality + // READ still owes this probe when the kind participates in an acyclic + // relation (`edgeAcyclic`, the one owner of this decision — not this + // registration's own `acyclic` key, which a composed member can lack + // while still belonging to the relation). + if ((options?.validateAcyclicity ?? true) && edgeAcyclic(ctx, kind)) { + const lock = requireDefined( + options?.lock, + "an acyclic edge create reached validateAndPrepareEdgeCreate with no write lock", + ); + await assertEdgeRelationsAcyclic( + acyclicityProbeContext(ctx, backend, lock, "edges.create"), + [ + { + edgeId: id, + edgeKind: kind, + fromKind, + fromId: input.fromId, + toKind, + toId: input.toId, + validTo, + }, + ], ); } return { - cardinality, + declarations, insertParams: buildInsertEdgeParams( ctx.graphId, id, @@ -555,44 +655,188 @@ async function assertLiveEdgeEndpoints( ); } -/** The single owner of an edge endpoint row's live/refusal verdict. */ -function assertEndpointRowLive( - edgeKind: string, - endpoint: "from" | "to", - nodeKind: string, - nodeId: string, - row: Awaited>, -): void { - if (!row || row.deleted_at) { - throw new EndpointNotFoundError({ - edgeKind, - endpoint, - nodeKind, - nodeId, - }); - } -} - // ============================================================ // Edge Operations // ============================================================ /** - * The cardinality an edge create must honor, resolved from the graph def. - * Also the input to {@link edgeWriteNeedsConstraintFence}, so "does this create - * probe anything" and "what does it probe" are read from one place. + * The cardinality declarations an edge create must honor — + * {@link file://../constraints.ts edgeKindCardinalityDeclarations} read off + * this module's context. The same declarations {@link edgeWriteFenceReason} + * folds and {@link edgeCardinalityAxisReferences} probes, so "does this create + * probe anything" and "what does it probe" are read from one place, source AND + * target alike. A kind this graph does not define answers the empty + * declaration. + */ +function edgeCardinalityDeclarations( + ctx: EdgeOperationContext, + kind: string, +): EdgeCardinalityDeclarations { + return edgeKindCardinalityDeclarations(ctx.graph, kind); +} + +/** + * Whether this edge kind participates in an acyclic relation. A kind this + * graph does not define answers `false`, matching + * {@link edgeCardinalityDeclarations}'s unknown-kind default. + */ +function edgeAcyclic( + ctx: EdgeOperationContext, + kind: string, +): boolean { + return edgeKindIsInAcyclicRelation(ctx.graph, ctx.registry, kind); +} + +/** + * Refuses a composition edge window that starts or ends later than now — + * `assertCompositionWindowCurrent`'s rule, applied to an edge write of any + * kind (a non-composition kind passes untouched). Read-free, so every create + * and update reaches it before the frame's first statement. * - * A kind this graph does not define answers `many`. Choosing the fence must not - * become the thing that REPORTS an unknown kind: the write path raises - * `KindNotFoundError` from inside the hooked transaction, where a caller's - * `onError` hook observes it, and this runs before that transaction opens. + * "Now" is one clock read, except on the two writes of a `reparent` / + * `bulkReparent` — the replacement's create and the incumbent's window end — + * which are judged at the move instant `resolveCompositionMoveInstant` + * decided (the {@link ResolvedCompositionMoveInstant} brand admits no other + * value). That instant was already judged by the move's own rules, and it can + * exceed the clock only as the `validFrom` of the stored window the move + * ends; a fresh read would refuse that clamp, and would refuse a defaulted + * instant outright if the clock stepped back between the read and the write. + */ +function assertCompositionEdgeWindowCurrent( + ctx: EdgeOperationContext, + edge: Readonly<{ + kind: string; + id: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + }>, + window: Readonly<{ + validFrom?: string | null | undefined; + validTo?: string | undefined; + }>, + moveInstant: ResolvedCompositionMoveInstant | undefined, +): void { + const subject = compositionWindowSubject(ctx.registry, edge); + if (subject === undefined) return; + assertCompositionWindowCurrent(subject, window, moveInstant ?? nowIso()); +} + +/** + * Whether clearing an ended row's `validTo` puts an edge of this kind back + * into the acyclic relation its writes are probed against: true exactly when + * that relation counts only open rows of the kind (a composition kind's + * ended rows are history). A static fact of the kind, so a write plan can + * state its fence dimension before it reads any row; the row-level verdict + * the probe acts on is {@link edgeUpdateReentersAcyclicRelation}. + */ +function windowReopenReentersAcyclicRelation( + ctx: EdgeOperationContext, + kind: string, +): boolean { + return ( + acyclicRelationMembership(ctx.graph, ctx.registry, kind) === "openRows" + ); +} + +/** + * Whether an edge update moves its row from outside its acyclic relation to + * inside it — a resurrection, or a reopened window of an `"openRows"` + * member — and so owes the acyclicity probe and fence a create owes. Judged + * through `acyclicRelationCountsRow` on the stored and the resulting state, + * so a resurrection that leaves the row ended re-admits nothing. + */ +function edgeUpdateReentersAcyclicRelation( + ctx: EdgeOperationContext, + existing: Pick, + effectiveValidTo: string | undefined, +): boolean { + const wasMember = + existing.deleted_at === undefined && + acyclicRelationCountsRow(ctx.graph, ctx.registry, { + edgeKind: existing.kind, + validTo: existing.valid_to, + }); + return ( + !wasMember && + acyclicRelationCountsRow(ctx.graph, ctx.registry, { + edgeKind: existing.kind, + validTo: effectiveValidTo, + }) + ); +} + +/** + * THE constraint-fence reason an edge write of this kind owes — + * {@link file://../constraints.ts edgeKindFenceReason} read off this + * module's context. `acyclic` is omitted by a create, which always re-admits + * its row to the acyclicity population; a resurrect or a window reopen states + * the dimension it actually decides. + */ +function edgeWriteFenceReason( + ctx: EdgeOperationContext, + kind: string, + acyclic: boolean, +): ConstraintFenceReason | undefined { + return edgeKindFenceReason(ctx.graph, ctx.registry, kind, acyclic); +} + +/** + * The detach probe ({@link file://../constraints.ts edgeDetachNeedsConstraintFence}) + * an edge update owes for the window end it states. A plan reads nothing, so + * whether that end closes a currently OPEN window (`edgeWriteEndsOpenWindow`) + * is unknown here: any stated end of a fenced kind pays, a restated one + * included. */ -function edgeCardinality( +function edgeWindowEndFenceReason( + ctx: EdgeOperationContext, + kind: string, + validTo: string | undefined, +): ConstraintFenceReason | undefined { + return validTo === undefined ? undefined : ( + edgeDetachNeedsConstraintFence(ctx.registry, kind) + ); +} + +/** {@link edgeWriteFenceReason} for a create of `kind`, which re-admits it to its acyclic relation. */ +function edgeCreateFenceReason( ctx: EdgeOperationContext, kind: string, -): Cardinality { - if (!hasOwnKey(ctx.graph.edges, kind)) return "many"; - return getEdgeRegistration(ctx.graph, kind).cardinality ?? "many"; +): ConstraintFenceReason | undefined { + return edgeWriteFenceReason(ctx, kind, edgeAcyclic(ctx, kind)); +} + +/** {@link edgeCreateFenceReason} for each distinct kind, in first-seen order. */ +function edgeCreateFenceReasons( + ctx: EdgeOperationContext, + kinds: readonly string[], +): readonly (ConstraintFenceReason | undefined)[] { + return [...new Set(kinds)].map((kind) => edgeCreateFenceReason(ctx, kind)); +} + +/** + * The `AcyclicityProbeContext` every acyclicity assertion in this module + * builds — three call sites share this exact shape (`edges.create`, + * `edges.bulkCreate`, `edges.resurrect`), reading `graph`/`schema` from `ctx` + * and deriving `dialect` from whichever write target the call is fenced + * against. + */ +function acyclicityProbeContext( + ctx: EdgeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + operation: string, +): AcyclicityProbeContext { + return buildAcyclicityProbeContext({ + graphId: ctx.graphId, + graph: ctx.graph, + registry: ctx.registry, + schema: ctx.revisionSchema, + target, + lock, + operation, + }); } /** @@ -731,6 +975,8 @@ async function executeEdgeCreateInternal( convergeOn !== undefined && registration.matchIdentity !== undefined; const autocommitBackend = isBundledRootAutocommitEligible(backend) ? backend : undefined; + const declarations = edgeCardinalityDeclarations(ctx, kind); + const fenceReason = edgeCreateFenceReason(ctx, kind); const candidate = hasOwnKey(ctx.graph.edges, kind) ? ({ @@ -740,7 +986,7 @@ async function executeEdgeCreateInternal( revisionTrackingEnabled: ctx.revisionTrackingEnabled, kindRegistered: true, convergesDynamically: convergeOn !== undefined && !durableConvergence, - cardinality: edgeCardinality(ctx, kind), + constrained: fenceReason !== undefined, } as const) : undefined; const schemaFenceInFirstWrite = @@ -752,7 +998,7 @@ async function executeEdgeCreateInternal( isAutocommitSingleStatementWrite({ kind: "edge", candidate }); const plan = edgeWritePlan( convergeOn === undefined || durableConvergence ? - edgeWriteNeedsConstraintFence(edgeCardinality(ctx, kind)) + fenceReason : "edgeMatchKeyConvergence", ); @@ -796,7 +1042,14 @@ async function executeEdgeCreateInternal( input, id, target, - { validateEndpoints: true, validateCardinality }, + { + validateEndpoints: true, + validateCardinality, + // Already probed by the always-run first call below, under the + // same lock this diagnostic reruns after; a second probe here + // would just repeat the same read. + validateAcyclicity: false, + }, ); if (transactionMode === "none") { // A noninteractive root has no safe plain-write fallback. Probe @@ -809,12 +1062,19 @@ async function executeEdgeCreateInternal( return validateAndPrepareEdgeCreate(ctx, input, id, target, { validateEndpoints: true, validateCardinality, + validateAcyclicity: false, }); }; - const declaredCardinality = edgeCardinality(ctx, kind); + const constrainedAxisCount = + edgeCardinalityAxisReferences(declarations).length; + // The guarded single-statement fast path carries exactly one claim + // (`buildInsertEdgeIfEndpointsLiveWithCardinalityClaim` reserves exactly + // one axis row) — a kind declaring BOTH axes always takes the portable + // claim-then-insert path, on every engine, per the fused command port's + // arity contract (`ManagedEdgeCreatePlan.cardinalityClaims`). const usesGuardedCardinalityClaim = - declaredCardinality !== "many" && + constrainedAxisCount === 1 && edgeCardinalityClaimMode(target, ctx.claimsVerdict()).kind === "guarded"; const usesFusedCardinalityInsert = usesGuardedCardinalityClaim; // A constrained convergence has to see an incumbent match before it @@ -831,15 +1091,28 @@ async function executeEdgeCreateInternal( // remove those two RTTs even though this create leg carries a convergence // guard. const canFuseEndpointCheck = - declaredCardinality === "many" || usesGuardedCardinalityClaim; + constrainedAxisCount === 0 || usesGuardedCardinalityClaim; const durableIdentityArbitratedCreate = registration.matchIdentity !== undefined; + // A durable-identity create takes its cardinality claim after the + // converge command has written the row, so nothing downstream can refuse + // it before the write: the probe has to run here. (A converging one + // probes after its incumbent lookup below instead, because a found match + // is not a create.) + const probesCardinalityBeforeWrite = + durableIdentityArbitratedCreate ? + convergeOn === undefined + : !usesGuardedCardinalityClaim && !delaysCardinalityProbe; let prepared = await validateAndPrepareEdgeCreate(ctx, input, id, target, { validateEndpoints: convergeOn === undefined && !canFuseEndpointCheck, - validateCardinality: - !durableIdentityArbitratedCreate && - !usesGuardedCardinalityClaim && - !delaysCardinalityProbe, + validateCardinality: probesCardinalityBeforeWrite, + // Always run here, unlike cardinality: this is the one call every + // create path reaches before any insert branch (fused, durable, or + // plain), and acyclicity has no database key that could enforce it + // the way a guarded cardinality claim or a durable-identity command + // does. Every later re-validate call in this frame passes `false`. + validateAcyclicity: true, + lock, }); // A converging create owns both the match-key read and the endpoint @@ -866,7 +1139,7 @@ async function executeEdgeCreateInternal( if ( durableIdentityArbitratedCreate && convergeOn !== undefined && - declaredCardinality !== "many" + constrainedAxisCount > 0 ) { // A get-or-create that already has its endpoint/match winner must // resolve that winner before the cardinality probe: cardinality is a @@ -886,11 +1159,12 @@ async function executeEdgeCreateInternal( prepared = await validateAndPrepareEdgeCreate(ctx, input, id, target, { validateEndpoints: !canFuseEndpointCheck, validateCardinality: true, + validateAcyclicity: false, }); } - const work = edgeInsertWork(prepared); + const work = edgeInsertWork(ctx, prepared); const durableMatchIdentity = work.params.matchIdentity; - if (work.claim === undefined || durableMatchIdentity !== undefined) { + if (work.claims.length === 0 || durableMatchIdentity !== undefined) { const command: EdgeConvergeCreateCommand = { kind: "edge.converge-create", plan: { @@ -927,10 +1201,16 @@ async function executeEdgeCreateInternal( if (result.outcome === "created") { // Durable identity and cardinality are separate authorities. The // converge command owns the former; retain the latter's claim row - // in the same transaction after the edge exists. If claiming - // refuses, the surrounding write frame rolls the command back. - if (durableMatchIdentity !== undefined && work.claim !== undefined) { - await claimEdgeCardinality(target, ctx.claimsVerdict(), work.claim); + // in the same transaction after the edge exists. The cardinality + // probe already ran before the command under this frame's graph + // fence, so a refusal here means a writer that bypassed the fence — + // the only case left to the surrounding frame's rollback. + if (durableMatchIdentity !== undefined && work.claims.length > 0) { + await claimEdgeCardinalities( + target, + ctx.claimsVerdict(), + work.claims, + ); } return createdEdgeResult(rowToEdge(result.row)); } @@ -1009,6 +1289,12 @@ async function executeEdgeCreateInternal( await validateAndPrepareEdgeCreate(ctx, input, id, target, { validateEndpoints: true, validateCardinality: false, + // This call exists only to raise the typed endpoint error + // before the already-decided identity conflict is thrown; no + // insert follows, so an acyclicity probe would be pure waste + // and could even mask the identity conflict with a cycle + // refusal. + validateAcyclicity: false, }); throw identityConflict; } @@ -1040,6 +1326,7 @@ async function executeEdgeCreateInternal( prepared = await validateAndPrepareEdgeCreate(ctx, input, id, target, { validateEndpoints: !canFuseEndpointCheck, validateCardinality: true, + validateAcyclicity: false, }); } } @@ -1052,7 +1339,7 @@ async function executeEdgeCreateInternal( // below, which preserves source-before-target typed refusals and handles // a concurrent endpoint revival before we report anything. if (canFuseEndpointCheck) { - const fusedWork = edgeInsertWork(prepared); + const fusedWork = edgeInsertWork(ctx, prepared); const fusedCommand: EdgeCreateCommand = { kind: "edge.create", plan: { @@ -1066,8 +1353,8 @@ async function executeEdgeCreateInternal( }, } : {}), - ...(usesFusedCardinalityInsert && fusedWork.claim !== undefined ? - { cardinalityClaim: fusedWork.claim } + ...(usesFusedCardinalityInsert && fusedWork.claims.length > 0 ? + { cardinalityClaims: fusedWork.claims } : {}), }, }; @@ -1099,7 +1386,7 @@ async function executeEdgeCreateInternal( // the probe above read a population no key fences, so the claim row is what // stops a concurrent writer that read the same population from also // committing, and a refusal there has written no edge row. - const work = edgeInsertWork(prepared); + const work = edgeInsertWork(ctx, prepared); const row = await withAlreadyExistsTranslation("edge", async () => { if (shouldReturnRow) return session.createEdge(work); await session.createEdgeNoReturn(work); @@ -1259,11 +1546,16 @@ async function prepareEdgeBatchCreates( input, id, validationBackend, + // A per-row probe here would see only stored state and miss a cycle + // two rows of this batch close through each other. The batch create + // callers below probe the whole prepared set once, as an overlay on + // the stored relation, before their insert. + { validateAcyclicity: false }, ); preparedCreates.push(prepared); registerPendingEdgeForCardinality( prepared.insertParams, - prepared.cardinality, + prepared.declarations, ); } @@ -1273,7 +1565,7 @@ async function prepareEdgeBatchCreates( // may still refuse, and every batch takes its claim row locks in // `compareClaimTargets` order rather than input order. const batchInsertWork = preparedCreates.map((prepared) => - edgeInsertWork(prepared), + edgeInsertWork(ctx, prepared), ); return { preparedCreates, batchInsertWork }; @@ -1298,35 +1590,36 @@ async function prepareAtomicEdgeBatchCreates( ): Promise { const preparedCreates: EdgeCreatePrepared[] = []; const claims: ClaimEdgeCardinalityParams[] = []; - const claimedTargets = new Set(); + const claimedTargets = new Map(); for (const input of inputs) { const prepared = await validateAndPrepareEdgeCreate( ctx, input, input.id ?? generateId(), backend, - { validateEndpoints: false, validateCardinality: false }, + { + validateEndpoints: false, + validateCardinality: false, + // Never reached for an acyclic kind: `resolveAtomicEdgeBatchExecutor` + // declines eligibility whenever any input's kind is acyclic, so this + // native-program preparation never sees one. Stated explicitly + // rather than left to the `true` default, which would need a lock + // this preparation does not have. + validateAcyclicity: false, + }, ); preparedCreates.push(prepared); - const claim = edgeInsertWork(prepared).claim; - if (claim === undefined) continue; - const target = edgeCardinalityClaimTarget(claim); - const targetKey = `${target.axis}\u0000${target.key}`; - if (claimedTargets.has(targetKey)) { - throw edgeCardinalityClaimRefusal(claim); + for (const claim of edgeInsertWork(ctx, prepared).claims) { + const targetKey = targetIdentity(edgeCardinalityClaimTarget(claim)); + const incumbentEdgeId = claimedTargets.get(targetKey); + if (incumbentEdgeId !== undefined) { + throw claimRefusalFor(claim, incumbentEdgeId); + } + claimedTargets.set(targetKey, claim.edgeId); + claims.push(claim); } - claimedTargets.add(targetKey); - claims.push(claim); } - return { - claims: claims.toSorted((left, right) => - compareClaimTargets( - edgeCardinalityClaimTarget(left), - edgeCardinalityClaimTarget(right), - ), - ), - preparedCreates, - }; + return { claims: sortedByClaimTarget(claims), preparedCreates }; } /** @@ -1347,7 +1640,7 @@ type EdgeEndpointInput = Readonly<{ const ATOMIC_EDGE_SCALAR_DIAGNOSTIC_WINDOW_SIZE = 32; function edgeEndpointRowKey(kind: string, id: string): string { - return encodeTupleKey([kind, id]); + return nodeRefKey({ kind, id }); } function collectEdgeEndpointIdsByKind( @@ -1482,7 +1775,7 @@ async function diagnoseAtomicEdgeBatchEndpointRefusal( async function assertAtomicEdgeBatchCardinality( ctx: EdgeOperationContext, - inputs: readonly CreateEdgeInput[], + preparedCreates: readonly EdgeCreatePrepared[], backend: WriteTarget, ): Promise { const constraintContext: ConstraintContext = { @@ -1491,21 +1784,15 @@ async function assertAtomicEdgeBatchCardinality( backend, }; for (const window of chunk( - inputs, + preparedCreates, ATOMIC_EDGE_SCALAR_DIAGNOSTIC_WINDOW_SIZE, )) { const errors = await Promise.all( - window.map(async (input) => { + window.map(async (prepared) => { try { - await checkCardinalityConstraint( + await checkEdgeCardinalityConstraints( constraintContext, - input.kind, - edgeCardinality(ctx, input.kind), - input.fromKind, - input.fromId, - input.toKind, - input.toId, - input.validTo, + edgeInsertWork(ctx, prepared).claims, ); return; } catch (error) { @@ -1577,7 +1864,11 @@ async function runAtomicEdgeBatchProgram( ); } if (error instanceof AtomicEdgeBatchCardinalityRefusalError) { - await assertAtomicEdgeBatchCardinality(ctx, inputs, backend); + await assertAtomicEdgeBatchCardinality( + ctx, + preparation.preparedCreates, + backend, + ); throw new DatabaseOperationError( "Atomic edge batch refused a cardinality claim, but no current " + "competing edge could be diagnosed.", @@ -1654,6 +1945,7 @@ export async function executeEdgeCreateNoReturnBatch( const atomicExecutor = resolveAtomicEdgeBatchExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, inputs, schemaVersion: ctx.schemaVersion, historyEnabled: ctx.historyEnabled, @@ -1678,9 +1970,14 @@ export async function executeEdgeCreateNoReturnBatch( await runWritePlan( ctx, - edgeWritePlan(batchFencesConstraintProbe(ctx, inputs)), + edgeBatchWritePlan( + edgeCreateFenceReasons( + ctx, + inputs.map((input) => input.kind), + ), + ), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { const { batchInsertWork } = await prepareEdgeBatchCreates( ctx, inputs, @@ -1694,6 +1991,15 @@ export async function executeEdgeCreateNoReturnBatch( durableWork.length !== batchInsertWork.length || target.insertEdgesDurableBatchReturning === undefined ) { + // The row-by-row legs below probe each row against stored state + // only, so the batch is probed whole first: a refusal must precede + // the first row written, not follow it. + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + ); for (const input of inputs) { await executeEdgeCreateInternal( ctx, @@ -1707,12 +2013,24 @@ export async function executeEdgeCreateNoReturnBatch( } return; } + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + ); const rows = await withAlreadyExistsTranslation("edge", () => requireDefined(session.createEdgesDurable)(batchInsertWork), ); assertDurableBatchRows(batchInsertWork, rows); return; } + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + ); await withAlreadyExistsTranslation("edge", () => session.createEdgesNoReturn(batchInsertWork), ); @@ -1733,19 +2051,28 @@ export async function executeEdgeCreateBatch( ctx: EdgeOperationContext, inputs: readonly CreateEdgeInput[], backend: GraphBackend | TransactionBackend, + options?: EdgeCreateBatchOptions, ): Promise { if (inputs.length === 0) { return []; } - const atomicExecutor = resolveAtomicEdgeBatchExecutor({ - backend, - graph: ctx.graph, - inputs, - schemaVersion: ctx.schemaVersion, - historyEnabled: ctx.historyEnabled, - revisionTrackingEnabled: ctx.revisionTrackingEnabled, - }); + const pairedUpdates = options?.pairedUpdates ?? []; + // The native program issues its inserts in the statement that decides + // them, leaving no point at which the paired updates could be decided + // first, so a batch that has any takes the portable frame. + const atomicExecutor = + pairedUpdates.length > 0 ? + undefined + : resolveAtomicEdgeBatchExecutor({ + backend, + graph: ctx.graph, + registry: ctx.registry, + inputs, + schemaVersion: ctx.schemaVersion, + historyEnabled: ctx.historyEnabled, + revisionTrackingEnabled: ctx.revisionTrackingEnabled, + }); if (atomicExecutor !== undefined) { const preparation = await prepareAtomicEdgeBatchCreates( ctx, @@ -1765,14 +2092,27 @@ export async function executeEdgeCreateBatch( return runWritePlan( ctx, - edgeWritePlan(batchFencesConstraintProbe(ctx, inputs)), + // The paired updates are decided under this frame's fence, so it fences + // for their kinds as a create of each would. + edgeBatchWritePlan( + edgeCreateFenceReasons(ctx, [ + ...inputs.map((input) => input.kind), + ...pairedUpdates.map((entry) => entry.input.identity.kind), + ]), + ), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { const { batchInsertWork } = await prepareEdgeBatchCreates( ctx, inputs, target, ); + if (pairedUpdates.length > 0) { + await prepareEdgeUpdateSet(ctx, pairedUpdates, target, lock, { + storedRows: storedRowsOfUpdateEntries(pairedUpdates), + pendingCreates: batchInsertWork.map((work) => work.params), + }); + } const durableWork = batchInsertWork.filter( (item) => item.params.matchIdentity !== undefined, @@ -1782,6 +2122,15 @@ export async function executeEdgeCreateBatch( durableWork.length !== batchInsertWork.length || target.insertEdgesDurableBatchReturning === undefined ) { + // See the non-returning fallback: probe the whole batch (and the + // updates paired with it) before its first row is written. + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + options?.pairedUpdates, + ); const fallbackRows: Edge[] = []; for (const input of inputs) { const result = await executeEdgeCreateInternal( @@ -1806,6 +2155,13 @@ export async function executeEdgeCreateBatch( } return fallbackRows; } + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + options?.pairedUpdates, + ); const rows = await withAlreadyExistsTranslation("edge", () => requireDefined(session.createEdgesDurable)(batchInsertWork), ); @@ -1813,6 +2169,13 @@ export async function executeEdgeCreateBatch( return rows.map((row) => rowToEdge(row)); } + await assertBatchEdgesRelationsAcyclic( + ctx, + target, + lock, + batchInsertWork, + options?.pairedUpdates, + ); const rows = await withAlreadyExistsTranslation("edge", () => session.createEdges(batchInsertWork), ); @@ -1823,21 +2186,166 @@ export async function executeEdgeCreateBatch( } /** - * A batch fences when ANY item in it does: the batch shares one transaction, so - * one constrained item makes the whole transaction a constrained write. A batch - * of purely `many` edges still takes no lock. + * One insert's row params as the acyclicity probe's proposed edge — the one + * spelling shared by every batch probe, whether its rows arrive as a + * batch's insert units ({@link proposedRelationEdgesFromInsertWork}) or as + * prepared creates ({@link assertPreparedEdgeCreatesAcyclic}). + */ +function proposedRelationEdgeFromInsertParams( + params: InsertEdgeParams, +): ProposedRelationEdge { + return { + edgeId: params.id, + edgeKind: params.kind, + fromKind: params.fromKind, + fromId: params.fromId, + toKind: params.toKind, + toId: params.toId, + validTo: params.validTo, + }; +} + +/** The proposed edges a batch create's acyclicity probe answers for. */ +function proposedRelationEdgesFromInsertWork( + batchInsertWork: readonly EdgeInsertWork[], +): readonly ProposedRelationEdge[] { + return batchInsertWork.map((work) => + proposedRelationEdgeFromInsertParams(work.params), + ); +} + +/** + * ONE acyclicity probe for a set of prepared edge creates, run BEFORE any of + * them is inserted, for a caller outside this module that issues its own + * inserts: the node batch create's composition preparation + * (`prepareBatchCompositionCreateEdges`, `node-operations.ts`), which prepares + * each item's composition edge with `validateAcyclicity: false` and reaches + * this once for the whole batch, ahead of the batch's first statement. + * + * Probes the prepared set as an overlay on the stored relation + * (`assertUnwrittenEdgeRelationsAcyclic`), so a cycle closed entirely by the + * batch's own rows is refused before anything is written. The probe drops + * rows whose kind is in no acyclic relation and issues no statement for an + * empty remainder, so a batch of composition edges is probed in exactly one + * walk per relation, and a graph that declares no acyclic relation pays + * nothing. */ -function batchFencesConstraintProbe( +export async function assertPreparedEdgeCreatesAcyclic( ctx: EdgeOperationContext, - inputs: readonly CreateEdgeInput[], -): ConstraintFenceReason | undefined { - for (const input of inputs) { - const reason = edgeWriteNeedsConstraintFence( - edgeCardinality(ctx, input.kind), + target: WriteTarget, + lock: GraphWriteLock, + operation: string, + prepared: readonly EdgeCreatePrepared[], + retiringEdgeIds: readonly string[] = [], +): Promise { + await assertUnwrittenEdgeRelationsAcyclic( + acyclicityProbeContext(ctx, target, lock, operation), + prepared.map((create) => + proposedRelationEdgeFromInsertParams(create.insertParams), + ), + retiringEdgeIds, + ); +} + +/** + * The edges a set of upsert updates re-admits to an acyclic relation, in the + * state each update leaves them: a resurrection of a tombstoned row, and a + * reopened window of a kind whose relation counts only open rows + * ({@link edgeUpdateReentersAcyclicRelation}). A plain update never changes + * the relation's membership, so it proposes nothing. + * + * Judged from the row each entry carries. A resurrection always carries it + * (it is the first occurrence of its id in the batch); a reopen that repeats + * an id carries none, and its own write's probe (`prepareEdgeUpdate`), which + * sees every earlier row of the batch, remains the check for it. + */ +function proposedRelationEdgesFromReentries( + ctx: EdgeOperationContext, + entries: readonly EdgeUpsertUpdateBatchEntry[], +): readonly ProposedRelationEdge[] { + return entries.flatMap((entry) => { + const existing = + entry.clearDeleted ? + requireDefined( + entry.existing, + "a resurrecting edge update reached the acyclicity probe without its stored row", + ) + : entry.existing; + // A plain update of a tombstone is refused by its own write; it + // re-admits nothing to probe. + if ( + existing === undefined || + (!entry.clearDeleted && existing.deleted_at !== undefined) + ) { + return []; + } + const { effectiveValidTo } = resolveEdgeUpdateWindow( + entry.input, + existing.valid_to, + entry.clearDeleted, ); - if (reason !== undefined) return reason; - } - return undefined; + if (!edgeUpdateReentersAcyclicRelation(ctx, existing, effectiveValidTo)) { + return []; + } + return [ + { + edgeId: existing.id, + edgeKind: existing.kind, + fromKind: existing.from_kind, + fromId: existing.from_id, + toKind: existing.to_kind, + toId: existing.to_id, + validTo: effectiveValidTo, + }, + ]; + }); +} + +/** + * The batch acyclicity probe: one combined recursive walk over the stored + * relation overlaid with every row this batch is about to insert, and with + * the resurrections of `pairedUpdates` the caller writes right after it. + * + * Run BEFORE the insert, so a refusal precedes the batch's first write: a + * caller that catches the `EdgeAcyclicityError` inside an enclosing + * transaction and commits has written none of the refused rows. The overlay + * is what makes an in-batch cycle (`a→b` and `b→a` in one `bulkCreate`) + * visible without the rows being stored. + */ +async function assertBatchEdgesRelationsAcyclic( + ctx: EdgeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + batchInsertWork: readonly EdgeInsertWork[], + pairedUpdates: readonly EdgeUpsertUpdateBatchEntry[] = [], +): Promise { + await assertUnwrittenEdgeRelationsAcyclic( + acyclicityProbeContext(ctx, target, lock, "edges.bulkCreate"), + [ + ...proposedRelationEdgesFromInsertWork(batchInsertWork), + ...proposedRelationEdgesFromReentries(ctx, pairedUpdates), + ], + ); +} + +/** + * The acyclicity probe for a set of re-admitting updates (resurrections and + * reopened windows) a caller writes one row at a time: the whole set as one + * overlay, run before the first of them is written. Each row's own probe + * (`performEdgeUpdate`) sees only stored state, so on its own a refusal at + * the Nth re-admission would follow N-1 stored ones, and two re-admissions + * closing a cycle through each other would pass both probes. + */ +async function assertResurrectionsAcyclic( + ctx: EdgeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + entries: readonly EdgeUpsertUpdateBatchEntry[], +): Promise { + await assertUnwrittenEdgeRelationsAcyclic( + acyclicityProbeContext(ctx, target, lock, "edges.resurrect"), + proposedRelationEdgesFromReentries(ctx, entries), + ); } /** @@ -1919,23 +2427,94 @@ export function edgeUpsertDirtyCheck( }; } +/** + * The window one edge update states, and the end it leaves on its row: the + * lower bound the write applies (only a resurrection, or an in-place update + * that refuses rather than preserves a differing bound, applies one), the + * stated `validTo`, and the row's `validTo` after the write. A resurrection + * that names a lower bound rewrites both bounds, so its end is exactly the + * stated one; every other update keeps the stored end unless it states or + * clears one. + * + * The one owner of that arithmetic: the update's own preparation and the + * batch acyclicity overlay ({@link proposedRelationEdgesFromReentries}) read + * the same resulting end, so the overlay probes the row the update writes. + */ +function resolveEdgeUpdateWindow( + input: UpsertUpdateEdgeInput, + existingValidTo: string | undefined, + clearDeleted: boolean, +): Readonly<{ + appliedValidFrom: string | null | undefined; + validTo: string | undefined; + effectiveValidTo: string | undefined; +}> { + const statedValidFrom = validateStatedValidityLowerBound( + input.validFrom, + "validFrom", + ); + const preservesLiveLowerBound = + !clearDeleted && preservesImmutableLowerBound(input.onImmutableLowerBound); + const appliedValidFrom = + preservesLiveLowerBound ? undefined : statedValidFrom; + const validTo = validateOptionalCanonicalIsoDate(input.validTo, "validTo"); + const effectiveValidTo = + clearDeleted && appliedValidFrom !== undefined ? + validTo + : validityEndAfterMutation( + input.clearValidTo === true ? { clearValidTo: true } + : validTo === undefined ? {} + : { validTo }, + existingValidTo, + ); + return { appliedValidFrom, validTo, effectiveValidTo }; +} + /** * Shared edge-update body: re-reads the edge inside the transaction, merges * and validates props, and writes. A plain update requires a live edge; a * resurrecting upsert (`clearDeleted`) may target a tombstoned one. */ -async function performEdgeUpdate( +type EdgeUpdateOptions = Readonly<{ + clearDeleted?: boolean; + matchOn?: readonly string[]; + matchProps?: Record; + /** See {@link assertCompositionExistencePreserved}'s `reattachedPart`. */ + reattachedPart?: CompositionNodeRef; + /** A `reparent`'s window end; see {@link assertCompositionEdgeWindowCurrent}. */ + moveInstant?: ResolvedCompositionMoveInstant; +}>; + +/** + * One edge update with every verdict reached and no statement issued. + * + * `rowAfter` is the row as the write leaves it — the merged props, the + * effective window and liveness the verdicts above were reached for — which a + * frame preparing several writes judges the next one against + * ({@link prepareEdgeUpdateSet}). + */ +type EdgeUpdatePrepared = Readonly<{ + work: EdgeUpdateWork; + fences: EdgeUpdateFences; + rowAfter: BackendEdgeRow; +}>; + +/** + * The READ half of {@link performEdgeUpdate}: every refusal the update can + * reach before its statement — the row's presence and identity, the merged + * props, the validity window, re-entry cardinality and acyclicity, and the + * composition-existence rule. Callable on its own by a frame that must reach + * an update's verdict before OTHER statements of its own + * ({@link assertCompositionEdgeWindowEndable}). + */ +async function prepareEdgeUpdate( ctx: EdgeOperationContext, input: UpsertUpdateEdgeInput, - session: EdgeWriteSession, target: WriteTarget, - options?: Readonly<{ - clearDeleted?: boolean; - matchOn?: readonly string[]; - matchProps?: Record; - }>, + options?: EdgeUpdateOptions, resolvedExisting?: BackendEdgeRow, -): Promise { + lock?: GraphWriteLock, +): Promise { const id = input.id; assertValidityEndMutation(input, { @@ -1960,26 +2539,26 @@ async function performEdgeUpdate( const { validatedProps } = resolveEdgeUpdateProps(ctx, existing, input.props); - const statedValidFrom = validateStatedValidityLowerBound( - input.validFrom, - "validFrom", + const { appliedValidFrom, validTo, effectiveValidTo } = + resolveEdgeUpdateWindow( + input, + existing.valid_to, + options?.clearDeleted === true, + ); + assertCompositionEdgeWindowCurrent( + ctx, + { + kind: existing.kind, + id, + fromKind: existing.from_kind, + fromId: existing.from_id, + toKind: existing.to_kind, + toId: existing.to_id, + }, + { validFrom: appliedValidFrom, validTo }, + options?.moveInstant, ); - const preservesLiveLowerBound = - options?.clearDeleted !== true && - preservesImmutableLowerBound(input.onImmutableLowerBound); - const appliedValidFrom = - preservesLiveLowerBound ? undefined : statedValidFrom; - const validTo = validateOptionalCanonicalIsoDate(input.validTo, "validTo"); - const effectiveValidTo = - options?.clearDeleted === true && appliedValidFrom !== undefined ? - validTo - : validityEndAfterMutation( - input.clearValidTo === true ? { clearValidTo: true } - : validTo === undefined ? {} - : { validTo }, - existing.valid_to, - ); - const cardinality = edgeCardinality(ctx, input.identity.kind); + const declarations = edgeCardinalityDeclarations(ctx, input.identity.kind); const reentersLivePopulation = options?.clearDeleted === true && existing.deleted_at !== undefined; if (options?.clearDeleted === true) { @@ -2001,38 +2580,45 @@ async function performEdgeUpdate( target, ); } - // `let` earns its place: the claim is decided inside the re-entry branch and - // consumed by the work record built after it, and there is no expression form - // that keeps the branch's two other statements (probe, then decide) together. - let reentryClaim: ClaimEdgeCardinalityParams | undefined; + // `let` earns its place: the claims are decided inside the re-entry branch + // and consumed by the work record built after it, and there is no + // expression form that keeps the branch's two other statements (probe, + // then decide) together. + let reentryClaims: readonly ClaimEdgeCardinalityParams[] = []; + // Any declared axis that counts only the ACTIVE population reopens on a + // cleared `validTo` even without a delete transition — the axis's claim + // row becomes takeable the moment the row ends, exactly as + // `claimsWhenBornEnded` governs at create time. const reentersActivePopulation = - cardinality === "oneActive" && + activeOnlyAxisReferences(declarations).length > 0 && effectiveValidTo === undefined && (existing.deleted_at !== undefined || existing.valid_to !== undefined); if (reentersLivePopulation || reentersActivePopulation) { - await checkCardinalityConstraint( - { - graphId: ctx.graphId, - registry: ctx.registry, - backend: target, - }, - input.identity.kind, - cardinality, - existing.from_kind, - existing.from_id, - existing.to_kind, - existing.to_id, - effectiveValidTo, - ); - // Re-entry re-admits this edge to the population its cardinality - // constrains, so it claims the axis exactly as a create does — BEFORE the - // update that re-admits it, because the probe above read a population no key - // fences. Both legs claim: a resurrect (`clearDeleted`) and a reopened - // `oneActive` window (#469) put the same row back into the same counted - // population, and a fence that covered only the first would leave the second - // unfenced. Decided here, ISSUED by the step that owns the row write, so the - // pair cannot be separated. - reentryClaim = edgeCardinalityClaim(cardinality, { + // A resurrection (`reentersLivePopulation`) vacated EVERY declared axis — + // `deleted_at` excludes this row from every counted population, active-only + // or not — so it re-probes and re-claims the full declaration, same as a + // create. A pure window reopen with no delete transition + // (`reentersActivePopulation` alone) is narrower: a non-active-only axis + // (`one`/`unique`, `holderLiveness: "live"`) never released its claim while + // this row stayed live and undeleted, so it must be EXCLUDED here, not + // re-probed — the probe below has no edge id to exclude the proposed + // holder by (unlike the SQL takeover statement's + // `competingLiveEdgePredicate`, which excludes it), so probing an axis + // this row already holds would count the row against itself and refuse a + // reopen nothing else contends for. + const reentryAxisReferences: readonly EdgeCardinalityAxisRef[] = + reentersLivePopulation ? + edgeCardinalityAxisReferences(declarations) + : activeOnlyAxisReferences(declarations); + // Re-entry re-admits this edge to every population {@link + // reentryAxisReferences} above decided it left, so it claims exactly + // those axes — and probes exactly those claims, BEFORE the update that + // re-admits it. Both legs claim: a resurrect (`clearDeleted`) and a + // reopened `oneActive`-shaped window (#469) put the same row back into + // the same counted population, and a fence that covered only the first + // would leave the second unfenced. Decided here, ISSUED by the step that + // owns the row write, so the pair cannot be separated. + const reentrySubject: EdgeClaimSubject = { graphId: ctx.graphId, id, kind: input.identity.kind, @@ -2041,7 +2627,70 @@ async function performEdgeUpdate( toKind: existing.to_kind, toId: existing.to_id, ...(effectiveValidTo === undefined ? {} : { validTo: effectiveValidTo }), - }); + }; + // Composition re-enters this row's part reservation under the + // SAME gate the ordinary axes above already applied: a resurrect + // re-takes it unconditionally, a bare active-only window reopen only + // when composition's own population is itself `oneActive` — see + // `compositionReentryClaim`'s docblock for why that mirrors + // `reentryAxisReferences` rather than `edgeInsertClaims`'s unconditional + // create-time fold. + const compositionEntry = compositionReentryClaim( + ctx.registry, + reentrySubject, + reentersLivePopulation, + ); + const reentryOrdinaryClaims = edgeCardinalityClaims( + reentryAxisReferences, + reentrySubject, + ); + reentryClaims = sortedByClaimTarget( + compositionEntry === undefined ? + reentryOrdinaryClaims + : [...reentryOrdinaryClaims, compositionEntry], + ); + await checkEdgeCardinalityConstraints( + { + graphId: ctx.graphId, + registry: ctx.registry, + backend: target, + }, + reentryClaims, + ); + } + // An acyclic relation re-admits this row when the update moves it from + // outside the relation to inside it: a resurrection that leaves it a + // member, or — for a relation that counts only open rows, the composition + // union's `oneActive` kinds — a reopened window. A reopen of a kind whose + // relation counts every row never left it, so it owes nothing here. + const reentersAcyclicRelation = edgeUpdateReentersAcyclicRelation( + ctx, + existing, + effectiveValidTo, + ); + if (reentersAcyclicRelation) { + await assertEdgeRelationsAcyclic( + acyclicityProbeContext( + ctx, + target, + requireDefined( + lock, + "an edge update re-admitting a row to an acyclic relation reached performEdgeUpdate with no write lock", + ), + options?.clearDeleted === true ? "edges.resurrect" : "edges.update", + ), + [ + { + edgeId: id, + edgeKind: existing.kind, + fromKind: existing.from_kind, + fromId: existing.from_id, + toKind: existing.to_kind, + toId: existing.to_id, + validTo: effectiveValidTo, + }, + ], + ); } // The row's stored lower bound is the effective one on EVERY edge update, // in-place or resurrecting: an edge RETAINS `valid_from` unless the @@ -2100,7 +2749,10 @@ async function performEdgeUpdate( const fences: EdgeUpdateFences = { validityLowerBound: windowVerdict.storedLowerBoundFence, validityUpperBound: - reentersActivePopulation && existing.valid_to !== undefined ? + ( + (reentersActivePopulation || reentersAcyclicRelation) && + existing.valid_to !== undefined + ) ? { expectedValidTo: existing.valid_to } : {}, edgeIdentity: input.identity, @@ -2111,20 +2763,83 @@ async function performEdgeUpdate( const work: EdgeUpdateWork = { id, props: validatedProps, - ...(appliedValidFrom !== undefined && { validFrom: appliedValidFrom }), - // `validTo` and `clearValidTo` are mutually exclusive in the params, so the - // work states exactly one of them. - ...(input.clearValidTo === true ? { clearValidTo: true as const } - : validTo === undefined ? {} - : { validTo }), - ...(options?.clearDeleted === true && { clearDeleted: true }), - ...(reentryClaim === undefined ? {} : { claim: reentryClaim }), + ...(appliedValidFrom !== undefined && { validFrom: appliedValidFrom }), + // `validTo` and `clearValidTo` are mutually exclusive in the params, so the + // work states exactly one of them. + ...(input.clearValidTo === true ? { clearValidTo: true as const } + : validTo === undefined ? {} + : { validTo }), + ...(options?.clearDeleted === true && { clearDeleted: true }), + claims: reentryClaims, + }; + + if (edgeWriteEndsOpenWindow(existing, work.validTo)) { + await assertCompositionExistencePreserved( + { + graphId: ctx.graphId, + registry: ctx.registry, + lock: requireDefined( + lock, + "a composition-existence-checked edge window-end reached performEdgeUpdate with no write lock", + ), + ...(options?.reattachedPart === undefined ? + {} + : { reattachedPart: options.reattachedPart }), + }, + existing, + target, + ); + } + + // A resurrection that names a lower bound restates the window; every other + // update keeps the stored bound (see `buildUpdateEdge`). + const rowAfter: BackendEdgeRow = { + ...existing, + props: validatedProps, + valid_from: + options?.clearDeleted === true && appliedValidFrom !== undefined ? + resolveStatedValidityLowerBound(appliedValidFrom) + : existing.valid_from, + valid_to: effectiveValidTo, + deleted_at: + options?.clearDeleted === true ? undefined : existing.deleted_at, }; + return { work, fences, rowAfter }; +} + +async function performEdgeUpdate( + ctx: EdgeOperationContext, + input: UpsertUpdateEdgeInput, + session: EdgeWriteSession, + target: WriteTarget, + options?: EdgeUpdateOptions, + resolvedExisting?: BackendEdgeRow, + lock?: GraphWriteLock, +): Promise { + const prepared = await prepareEdgeUpdate( + ctx, + input, + target, + options, + resolvedExisting, + lock, + ); + return writePreparedEdgeUpdate(ctx, input, prepared, session, target); +} + +/** The WRITE half of {@link performEdgeUpdate}: one prepared update's statement. */ +async function writePreparedEdgeUpdate( + ctx: EdgeOperationContext, + input: UpsertUpdateEdgeInput, + { work, fences }: EdgeUpdatePrepared, + session: EdgeWriteSession, + target: WriteTarget, +): Promise { const row = await withUnmatchedEdgeUpdateRefusal( ctx.graphId, target, - id, + input.id, input.identity, // "Did this write assert any window state?" is one predicate with one // owner, consulted here and by the fence appliers that carry it, rather @@ -2165,12 +2880,9 @@ async function performEdgeUpdateConverging( input: UpsertUpdateEdgeInput, session: EdgeWriteSession, target: WriteTarget, - options?: Readonly<{ - clearDeleted?: boolean; - matchOn?: readonly string[]; - matchProps?: Record; - }>, + options?: EdgeUpdateOptions, resolvedExisting?: BackendEdgeRow, + lock?: GraphWriteLock, ): Promise { for (let attempt = 1; attempt <= EDGE_UPDATE_ATTEMPTS; attempt += 1) { try { @@ -2181,15 +2893,12 @@ async function performEdgeUpdateConverging( target, options, attempt === 1 ? resolvedExisting : undefined, + lock, ); } catch (error) { if (!(error instanceof EdgeUpdateTargetMoved)) throw error; if (attempt === EDGE_UPDATE_ATTEMPTS) { - throw new DatabaseOperationError( - `Edge update for "${input.id}" could not be applied to a stable row after ${EDGE_UPDATE_ATTEMPTS} attempts: the row was replaced between each read and its write. A concurrent writer is replacing this edge faster than it can be read; serialize the writers, or retry.`, - { operation: "update", entity: "edge" }, - { cause: error }, - ); + throw unstableEdgeUpdateTargetError(input.id, error); } } } @@ -2197,6 +2906,290 @@ async function performEdgeUpdateConverging( throw new EdgeNotFoundError(input.identity.kind, input.id); } +/** The refusal an update whose row kept moving under it ends with. */ +function unstableEdgeUpdateTargetError( + id: string, + cause: EdgeUpdateTargetMoved, +): DatabaseOperationError { + return new DatabaseOperationError( + `Edge update for "${id}" could not be applied to a stable row after ${EDGE_UPDATE_ATTEMPTS} attempts: the row was replaced between each read and its write. A concurrent writer is replacing this edge faster than it can be read; serialize the writers, or retry.`, + { operation: "update", entity: "edge" }, + { cause }, + ); +} + +/** The update options one batch entry carries into the shared update body. */ +function edgeUpdateBatchEntryOptions( + entry: EdgeUpsertUpdateBatchEntry, +): EdgeUpdateOptions | undefined { + return entry.clearDeleted ? { clearDeleted: true } : undefined; +} + +/** + * One entry of a prepared update set: its prepared write, and whether it was + * prepared against the row as STORED. An entry prepared against a row an + * earlier write of the same frame leaves (a repeated id, or a row the frame + * inserts first) was judged on that projection, so its write re-prepares + * against the row actually there by then rather than carrying fences read off + * the projection. + */ +type EdgeUpdateSetEntry = Readonly<{ + prepared: EdgeUpdatePrepared; + preparedAgainstStoredRow: boolean; +}>; + +/** + * The stored rows a set of update entries already carries — each read inside + * the transaction that writes it, by the caller that resolved the entry. + */ +function storedRowsOfUpdateEntries( + entries: readonly EdgeUpsertUpdateBatchEntry[], +): ReadonlyMap { + return new Map( + entries.flatMap((entry) => + entry.existing === undefined ? [] : [[entry.input.id, entry.existing]], + ), + ); +} + +/** + * The row an insert this frame is about to issue will leave, as the update + * set's projections read it. The lower bound is the one the insert stamps + * ({@link resolveStampedValidityLowerBound}), taken at this frame's clock — an + * instant the insert's own stamp can only follow. + */ +function edgeRowOfPendingInsert(params: InsertEdgeParams): BackendEdgeRow { + const instant = nowIso(); + return { + graph_id: params.graphId, + id: params.id, + kind: params.kind, + from_kind: params.fromKind, + from_id: params.fromId, + to_kind: params.toKind, + to_id: params.toId, + props: params.props, + ...(params.matchIdentity !== undefined && { + match_identity_name: params.matchIdentity.name, + match_identity_key: params.matchIdentity.key, + }), + valid_from: resolveStampedValidityLowerBound( + params.validFrom, + params.validTo, + instant, + ), + valid_to: params.validTo, + created_at: instant, + updated_at: instant, + deleted_at: undefined, + }; +} + +/** + * The READ half of a SET of edge updates a frame writes one row at a time: + * every entry prepared ({@link prepareEdgeUpdate}) before the first is + * written, so a refusal any entry reaches precedes the frame's first + * statement. A caller that catches it inside an enclosing + * `store.transaction(...)`, where there is no nested frame to roll back, is + * left with none of the set's writes. + * + * The set is judged as the state it produces, in write order. Each entry is + * prepared against the row the earlier writes leave: its stored row, the + * `rowAfter` of the last earlier entry for the same id, or the row one of + * `pendingCreates` (inserts this frame issues BEFORE the updates) will write. + * Its cardinality and composition probes read counts that already reflect + * every earlier entry and every pending insert + * ({@link createEdgeTransitionValidationBackend}). Acyclicity stays the set's + * own probe over every resurrection ({@link assertResurrectionsAcyclic}, or + * the create batch's `pairedUpdates`): an entry's own probe reads stored + * state only. + * + * `storedRows` are rows read inside this frame's transaction; an entry whose + * id has none is read from `target`. + */ +async function prepareEdgeUpdateSet( + ctx: EdgeOperationContext, + entries: readonly EdgeUpsertUpdateBatchEntry[], + target: WriteTarget, + lock: GraphWriteLock, + sources: Readonly<{ + storedRows?: ReadonlyMap; + pendingCreates?: readonly InsertEdgeParams[]; + }> = {}, +): Promise { + const { backend: validationTarget, registerEdgeTransition } = + createEdgeTransitionValidationBackend(target); + const projected = new Map(); + for (const params of sources.pendingCreates ?? []) { + const row = edgeRowOfPendingInsert(params); + registerEdgeTransition(undefined, row); + projected.set(row.id, row); + } + + const prepared: EdgeUpdateSetEntry[] = []; + for (const entry of entries) { + const id = entry.input.id; + const projection = projected.get(id); + const stored = + projection === undefined ? + (sources.storedRows?.get(id) ?? (await target.getEdge(ctx.graphId, id))) + : undefined; + const update = await prepareEdgeUpdate( + ctx, + entry.input, + validationTarget, + edgeUpdateBatchEntryOptions(entry), + projection ?? stored, + lock, + ); + registerEdgeTransition(stored, update.rowAfter); + projected.set(id, update.rowAfter); + prepared.push({ + prepared: update, + preparedAgainstStoredRow: projection === undefined, + }); + } + return prepared; +} + +/** + * Writes a set of edge updates, every one of them prepared before the first + * is written ({@link prepareEdgeUpdateSet}), and CONVERGES as + * {@link performEdgeUpdateConverging} does for one: when a row's asserted + * window stopped matching, the entries not yet written are prepared again — + * all of them, before the next write — against the rows actually there, and + * the set carries on from the moved entry. Each entry gets the one retry the + * single-row path gives it. + */ +async function writeEdgeUpdateSet( + ctx: EdgeOperationContext, + entries: readonly EdgeUpsertUpdateBatchEntry[], + session: EdgeWriteSession, + target: WriteTarget, + lock: GraphWriteLock, + storedRows: ReadonlyMap | undefined, +): Promise { + const edges: Edge[] = []; + const movedEntries = new Set(); + // `let` earns its place: once a row moved, the rows read before the first + // write no longer describe the entries still to be written. + let rows = storedRows; + while (edges.length < entries.length) { + const written = edges.length; + const remaining = entries.slice(written); + const prepared = await prepareEdgeUpdateSet(ctx, remaining, target, lock, { + ...(rows === undefined ? {} : { storedRows: rows }), + }); + try { + for (const [offset, entry] of remaining.entries()) { + const { prepared: update, preparedAgainstStoredRow } = requireDefined( + prepared[offset], + ); + edges.push( + preparedAgainstStoredRow ? + await writePreparedEdgeUpdate( + ctx, + entry.input, + update, + session, + target, + ) + : await performEdgeUpdate( + ctx, + entry.input, + session, + target, + edgeUpdateBatchEntryOptions(entry), + undefined, + lock, + ), + ); + } + } catch (error) { + if (!(error instanceof EdgeUpdateTargetMoved)) throw error; + const movedIndex = edges.length; + if (movedEntries.has(movedIndex)) { + throw unstableEdgeUpdateTargetError( + requireDefined(entries[movedIndex]).input.id, + error, + ); + } + movedEntries.add(movedIndex); + rows = undefined; + } + } + return edges; +} + +/** + * Ends one composition edge's open validity window, inside a frame that is + * about to attach the same part to a new whole — `reparent`'s retire for a + * `population: "oneActive"` pair (`executeNodeReparent`, + * `node-operations.ts`). + * + * Runs the ORDINARY edge-update body, so the window end takes exactly the + * props merge, validity-window verdict, identity fence, claim re-entry, and + * capture an explicit `store.edges..update(id, {}, { validTo })` would + * take. `reattachedPart` is the reparent's own evidence that this part is + * not being detached at all (see `assertCompositionExistencePreserved`), so + * the required-existence refusal is APPLIED with the frame's real end state + * rather than bypassed. The window ends at `moveInstant`, which the window + * rule also judges it at ({@link assertCompositionEdgeWindowCurrent}). + */ +export async function endCompositionEdgeWindow( + ctx: EdgeOperationContext, + edge: BackendEdgeRow, + reattachedPart: CompositionNodeRef, + moveInstant: ResolvedCompositionMoveInstant, + session: EdgeWriteSession, + target: WriteTarget, + lock: GraphWriteLock, +): Promise { + await performEdgeUpdateConverging( + ctx, + compositionEdgeWindowEndInput(edge, moveInstant), + session, + target, + { reattachedPart, moveInstant }, + edge, + lock, + ); +} + +/** The update {@link endCompositionEdgeWindow} issues, spelled once. */ +function compositionEdgeWindowEndInput( + edge: BackendEdgeRow, + validTo: string, +): UpsertUpdateEdgeInput { + return { id: edge.id, identity: { kind: edge.kind }, props: {}, validTo }; +} + +/** + * The READ half of {@link endCompositionEdgeWindow}: every refusal that + * window end can reach (the stored props against the edge schema, a move + * instant that precedes the window it ends, the required-existence rule), + * with no statement issued. A frame retiring SEVERAL incumbents calls this + * for each before its first write, so a refusal on a later one leaves the + * earlier ones where they were. + */ +export async function assertCompositionEdgeWindowEndable( + ctx: EdgeOperationContext, + edge: BackendEdgeRow, + reattachedPart: CompositionNodeRef, + moveInstant: ResolvedCompositionMoveInstant, + target: WriteTarget, + lock: GraphWriteLock, +): Promise { + await prepareEdgeUpdate( + ctx, + compositionEdgeWindowEndInput(edge, moveInstant), + target, + { reattachedPart, moveInstant }, + edge, + lock, + ); +} + function resolveAtomicEdgeUpdateExecutor( ctx: EdgeOperationContext, entries: readonly EdgeUpsertUpdateBatchEntry[], @@ -2220,6 +3213,7 @@ function resolveAtomicEdgeUpdateExecutor( return resolveAtomicEdgeResolvedUpdateBatchExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, schemaVersion: ctx.schemaVersion, historyEnabled: ctx.historyEnabled, revisionTrackingEnabled: ctx.revisionTrackingEnabled, @@ -2293,19 +3287,36 @@ export async function executeEdgeUpdate( ctx, opContext, // An in-place props update on a live edge re-derives no constraint verdict. - // Clearing an `oneActive` edge's end DOES: it re-admits the row to the - // counted active population. - edgeWritePlan( + // Clearing an end DOES when it re-admits the row to a counted population: + // an active-only cardinality axis's, on either side, or an acyclic + // relation that counts only open rows (a composition kind's). Stating an + // end does when it can separate a required part from its whole. + edgeBatchWritePlan([ ( input.clearValidTo === true && - edgeCardinality(ctx, gate.kind) === "oneActive" + (activeOnlyAxisReferences(edgeCardinalityDeclarations(ctx, gate.kind)) + .length > 0 || + windowReopenReentersAcyclicRelation(ctx, gate.kind)) ) ? - edgeWriteNeedsConstraintFence("oneActive") + edgeWriteFenceReason( + ctx, + gate.kind, + windowReopenReentersAcyclicRelation(ctx, gate.kind), + ) : undefined, - ), + edgeWindowEndFenceReason(ctx, gate.kind, input.validTo), + ]), backend, - (session, target) => - performEdgeUpdateConverging(ctx, input, session, target), + (session, target, _overlaidSession, lock) => + performEdgeUpdateConverging( + ctx, + input, + session, + target, + undefined, + undefined, + lock, + ), { didWrite: writeResultAlwaysChanges }, ); } @@ -2345,15 +3356,24 @@ async function executeEdgeUpsertUpdateWithOutcome( // are immutable, so cardinality cannot change under it. A coalescing upsert // converges on a match key no database key backs; a resurrect or a cleared // end re-admits the row to the counted population its cardinality - // constrains. - edgeWritePlan( + // constrains. A stated end can separate a required part from its whole. + edgeBatchWritePlan([ options?.coalesceUnchanged === true ? "edgeMatchKeyConvergence" : input.clearValidTo === true || options?.clearDeleted === true ? - edgeWriteNeedsConstraintFence(edgeCardinality(ctx, input.identity.kind)) + // A resurrection re-admits the row to its acyclic relation; a cleared + // end does so only for a relation that counts open rows alone. + edgeWriteFenceReason( + ctx, + input.identity.kind, + options?.clearDeleted === true ? + edgeAcyclic(ctx, input.identity.kind) + : windowReopenReentersAcyclicRelation(ctx, input.identity.kind), + ) : undefined, - ), + edgeWindowEndFenceReason(ctx, input.identity.kind, input.validTo), + ]), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { if (options?.coalesceUnchanged === true && !options.clearDeleted) { const existing = options.matchOn !== undefined && options.matchProps !== undefined ? @@ -2373,15 +3393,7 @@ async function executeEdgeUpsertUpdateWithOutcome( ) { assertEdgeMatchKey(existing, options.matchOn, options.matchProps); } - const runDirtyCheck = () => - edgeUpsertDirtyCheck( - ctx, - existing.kind, - existing.id, - rowPropsToObject(existing.props), - input.props, - ); - if (shouldCoalesceUpsert(existing, input, runDirtyCheck)) { + if (edgeUpsertCoalesces(ctx, existing, input)) { return { edge: rowToEdge(existing), wrote: false }; } } @@ -2393,6 +3405,8 @@ async function executeEdgeUpsertUpdateWithOutcome( session, target, options, + undefined, + lock, ); return { edge, wrote: true }; }, @@ -2402,6 +3416,28 @@ async function executeEdgeUpsertUpdateWithOutcome( ); } +/** + * Whether a coalescing upsert of `input` over the live row `existing` writes + * nothing: the props it would persist equal the stored ones and it states no + * window change. Read by the upsert itself and by a batch that decides, before + * its first write, which of its upserts will write at all. + */ +function edgeUpsertCoalesces( + ctx: EdgeOperationContext, + existing: BackendEdgeRow, + input: UpsertUpdateEdgeInput, +): boolean { + return shouldCoalesceUpsert(existing, input, () => + edgeUpsertDirtyCheck( + ctx, + existing.kind, + existing.id, + rowPropsToObject(existing.props), + input.props, + ), + ); +} + /** * Executes an edge update for upsert — bypasses the soft-delete check * and optionally clears `deleted_at`. @@ -2447,6 +3483,7 @@ export async function executeEdgeResolvedMutationSet( const executor = resolveAtomicEdgeResolvedMutationSetExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, schemaVersion: ctx.schemaVersion, historyEnabled: ctx.historyEnabled, revisionTrackingEnabled: ctx.revisionTrackingEnabled, @@ -2582,17 +3619,32 @@ export async function executeEdgeUpsertUpdateBatch( const needsConstraintFence = entries.some( (entry) => entry.clearDeleted || entry.input.clearValidTo === true, ); + const needsAcyclicityFence = entries.some((entry) => + entry.clearDeleted ? + edgeAcyclic(ctx, entry.input.identity.kind) + : entry.input.clearValidTo === true && + windowReopenReentersAcyclicRelation(ctx, entry.input.identity.kind), + ); return runWritePlan( ctx, - edgeWritePlan( + edgeBatchWritePlan([ needsConstraintFence ? - edgeWriteNeedsConstraintFence( - edgeCardinality(ctx, first.input.identity.kind), + edgeWriteFenceReason( + ctx, + first.input.identity.kind, + needsAcyclicityFence, ) : undefined, - ), + ...entries.map((entry) => + edgeWindowEndFenceReason( + ctx, + entry.input.identity.kind, + entry.input.validTo, + ), + ), + ]), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { const resolvedRows = ( target.capabilities.execution.interactiveTransactions && @@ -2602,20 +3654,15 @@ export async function executeEdgeUpsertUpdateBatch( ...distinctIds, ]) : undefined; - const edges: Edge[] = []; - for (const entry of entries) { - edges.push( - await performEdgeUpdateConverging( - ctx, - entry.input, - session, - target, - entry.clearDeleted ? { clearDeleted: true } : undefined, - resolvedRows?.get(entry.input.id), - ), - ); - } - return edges; + await assertResurrectionsAcyclic(ctx, target, lock, entries); + return writeEdgeUpdateSet( + ctx, + entries, + session, + target, + lock, + resolvedRows, + ); }, { didWrite: writeResultAlwaysChanges }, ); @@ -2718,6 +3765,35 @@ async function executeAtomicEdgeResolvedUpdates( ); } +/** + * The row a single edge delete's detach verdict + * (`assertCompositionExistencePreserved`) is judged from. + * + * The gate row was read before the frame took the fence, so for a kind whose + * detach decides required-part existence + * ({@link file://../constraints.ts edgeDetachNeedsConstraintFence}) it can be + * stale by the time the fence is held: a concurrent writer may have reopened + * an ended window and restored the part in between. That row is re-read on the + * frame's own target. Every other kind's delete decides nothing from the row, + * and keeps the gate. A row gone, or now holding another kind under the id, + * answers `undefined`: the delete then matches no row of `expectedKind` and + * detaches nothing. + */ +async function readEdgeDetachVerdictRow( + ctx: EdgeOperationContext, + target: WriteTarget, + gate: BackendEdgeRow, + expectedKind: string, +): Promise { + if ( + edgeDetachNeedsConstraintFence(ctx.registry, expectedKind) === undefined + ) { + return gate; + } + const current = await target.getEdge(ctx.graphId, gate.id); + return current?.kind === expectedKind ? current : undefined; +} + /** * Executes an edge delete operation. */ @@ -2746,6 +3822,7 @@ export async function executeEdgeDelete( const atomicExecutor = resolveAtomicEdgeDeleteBatchExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, expectedKind, ids: [id], schemaVersion: ctx.schemaVersion, @@ -2772,17 +3849,28 @@ export async function executeEdgeDelete( return runHookedWritePlan( ctx, opContext, - // A soft delete decides nothing a concurrent write could invalidate. - edgeWritePlan(undefined), + // A soft delete decides nothing a concurrent write could invalidate — + // except whether it separates a live required part from its whole. + edgeWritePlan(edgeDetachNeedsConstraintFence(ctx.registry, expectedKind)), backend, - async (session) => { - // No in-transaction re-read: the statement carries the expected kind and - // `deleted_at IS NULL`, so it is its own recheck. A concurrent writer that - // tombstones this edge, or hard-deletes it and recreates the id under - // another kind, leaves the DELETE matching zero rows — the same no-op the - // re-read produced, one round trip cheaper and without the window between - // a lock-free `getEdge` and a `(graph_id, id)`-keyed write that PostgreSQL - // READ COMMITTED left open. + async (session, target, _overlaidSession, lock) => { + // The DELETE itself carries no in-transaction re-read: a concurrent + // writer that tombstones this edge, or hard-deletes it and recreates + // the id under another kind, leaves it matching zero rows. Only the + // detach verdict reads the row again, and only where it decides. + const judged = await readEdgeDetachVerdictRow( + ctx, + target, + gate, + expectedKind, + ); + if (judged !== undefined) { + await assertCompositionExistencePreserved( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + judged, + target, + ); + } await session.retireEdge({ id, kind: expectedKind }); }, ); @@ -2804,6 +3892,7 @@ export async function executeEdgeDeleteBatch( const atomicExecutor = resolveAtomicEdgeDeleteBatchExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, expectedKind, ids, schemaVersion: ctx.schemaVersion, @@ -2823,9 +3912,9 @@ export async function executeEdgeDeleteBatch( await runWritePlan( ctx, - edgeWritePlan(undefined), + edgeWritePlan(edgeDetachNeedsConstraintFence(ctx.registry, expectedKind)), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { const rowsById = await getEdgeRowsByIds( target, ctx.batchPointRead, @@ -2846,6 +3935,12 @@ export async function executeEdgeDeleteBatch( if (current.deleted_at) continue; if (scheduledIds.has(id)) continue; scheduledIds.add(id); + // Per member — see `executeEdgeDelete`'s identical check. + await assertCompositionExistencePreserved( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + current, + target, + ); retirements.push({ id, kind: expectedKind }); } // Resolution and execution share the same transaction target. The batch @@ -2938,9 +4033,23 @@ export async function executeEdgeHardDelete( return runHookedWritePlan( ctx, opContext, - edgeWritePlan(undefined), + edgeWritePlan(edgeDetachNeedsConstraintFence(ctx.registry, expectedKind)), backend, - async (session) => { + async (session, target, _overlaidSession, lock) => { + // See executeEdgeDelete's identical check. + const judged = await readEdgeDetachVerdictRow( + ctx, + target, + gate, + expectedKind, + ); + if (judged !== undefined) { + await assertCompositionExistencePreserved( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + judged, + target, + ); + } // No in-transaction re-read: see executeEdgeDelete. The DELETE carries the // expected kind, so an id concurrently re-pointed at another kind's edge // matches zero rows instead of destroying that other edge. @@ -2952,7 +4061,11 @@ export async function executeEdgeHardDelete( // row only keeps the relation from growing by one row per hard-deleted // constrained edge. An unconstrained kind holds no claim and pays no // statement for one, the same rule its create follows. - holdsCardinalityClaim: edgeCardinality(ctx, expectedKind) !== "many", + holdsCardinalityClaim: edgeKindOwesAnyClaim( + ctx.registry, + edgeCardinalityDeclarations(ctx, expectedKind), + expectedKind, + ), }); }, ); @@ -3682,6 +4795,7 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( const atomicConvergenceExecutor = resolveAtomicEdgeConvergenceExecutor({ backend, graph: ctx.graph, + registry: ctx.registry, schemaVersion: ctx.schemaVersion, historyEnabled: ctx.historyEnabled, revisionTrackingEnabled: ctx.revisionTrackingEnabled, @@ -4064,14 +5178,44 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( if (found !== undefined) return found; } + // The upsert-update a fetched match owes: the one spelling shared by the + // create batch's paired probe and the update legs below. + function updateEntryFor(entry: FetchEntry): EdgeUpsertUpdateBatchEntry { + return { + input: { + id: entry.row.id, + identity: { + kind, + fromKind: entry.fromKind, + fromId: entry.fromId, + toKind: entry.toKind, + toId: entry.toId, + }, + props: entry.validatedProps, + ...(entry.validFrom !== undefined && { + validFrom: entry.validFrom, + }), + ...(entry.validTo !== undefined && { validTo: entry.validTo }), + ...(entry.clearValidTo === true && { + clearValidTo: true as const, + }), + ...(entry.onImmutableLowerBound !== undefined && { + onImmutableLowerBound: entry.onImmutableLowerBound, + }), + }, + clearDeleted: entry.isDeleted, + existing: entry.row, + }; + } + // The partition that decides create-vs-fetch is re-derived from `target` // INSIDE the fenced transaction, so the batch's lookup and its writes commit // under one per-graph mutual exclusion — the bulk analogue of the single-item // path's `convergeOn` guard, and the reason the bulk path needs no in-loop // race handling of its own. - function runBatch(): Promise< - Readonly<{ results: Result[]; writes: number }> - > { + function runBatch( + progress: BulkGetOrCreateProgress, + ): Promise> { return runWritePlan( ctx, // A bulk getOrCreate always converges on a match key no database key @@ -4079,7 +5223,8 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( // fences unconditionally. edgeWritePlan("edgeMatchKeyConvergence"), backend, - async (session, target, _overlaidSession, lock) => { + async (session, target, _overlaidSession, lock, transactionMode) => { + progress.transactionMode = transactionMode; // ## The one widening the migration cannot remove // // The nested legs are whole managed writes of their own: each opens its @@ -4098,6 +5243,41 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( const results: Result[] = Array.from({ length: items.length }); let writes = 0; + // The update legs of step 5, decided here because step 4 hands them + // to the create batch, which decides them before its first insert. + // The batch helper owns the same update/resurrection semantics as the + // single entry path, including validity-window checks and cardinality + // fencing. It can also select the native resolved-update program for a + // live, no-window batch. Coalescing is intentionally kept on the + // outcome path: the batch helper has no per-entry dirty-check + // candidate, so routing that shape through it would turn a found + // replay into an update. + const updateEntries = toFetch.map((entry) => updateEntryFor(entry)); + const batchEntryIndexes = + ifExists === "update" ? + toFetch.map((_entry, index) => index) + : toFetch.flatMap((entry, index) => (entry.isDeleted ? [index] : [])); + const canBatchUpdates = + !ctx.coalesceUnchangedUpsertsEnabled && batchEntryIndexes.length > 0; + // Every update leg that will write: a coalescing leg whose upsert + // changes nothing writes nothing, and owes no verdict. + const owedUpdates = + canBatchUpdates ? + batchEntryIndexes.map((index) => + requireDefined(updateEntries[index]), + ) + : toFetch.flatMap((entry, index) => { + const update = requireDefined(updateEntries[index]); + if (entry.isDeleted) return [update]; + if (ifExists !== "update") return []; + return ( + ctx.coalesceUnchangedUpsertsEnabled && + edgeUpsertCoalesces(ctx, entry.row, update.input) + ) ? + [] + : [update]; + }); + // Step 4: Execute creates in batch if (toCreate.length > 0) { // The batch insertion path deliberately remains one multi-row write, @@ -4117,6 +5297,7 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( ctx, createInputs, rawTarget, + { pairedUpdates: owedUpdates }, ); for (const [batchIndex, entry] of toCreate.entries()) { results[entry.index] = { @@ -4125,51 +5306,13 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( }; } writes += toCreate.length; + progress.wrote = true; } - // Step 5: Handle existing edges (update/skip/resurrect). The batch - // helper owns the same update/resurrection semantics as the single - // entry path, including validity-window checks and cardinality - // fencing. It can also select the native resolved-update program for - // a live, no-window batch. Coalescing is intentionally kept on the - // outcome path below: the batch helper has no per-entry dirty-check - // candidate, so routing that shape through it would turn a found - // replay into an update. - const updateEntries = toFetch.map( - (entry) => - ({ - input: { - id: entry.row.id, - identity: { - kind, - fromKind: entry.fromKind, - fromId: entry.fromId, - toKind: entry.toKind, - toId: entry.toId, - }, - props: entry.validatedProps, - ...(entry.validFrom !== undefined && { - validFrom: entry.validFrom, - }), - ...(entry.validTo !== undefined && { validTo: entry.validTo }), - ...(entry.clearValidTo === true && { - clearValidTo: true as const, - }), - ...(entry.onImmutableLowerBound !== undefined && { - onImmutableLowerBound: entry.onImmutableLowerBound, - }), - }, - clearDeleted: entry.isDeleted, - existing: entry.row, - }) satisfies EdgeUpsertUpdateBatchEntry, - ); - - const batchEntryIndexes = - ifExists === "update" ? - toFetch.map((_entry, index) => index) - : toFetch.flatMap((entry, index) => (entry.isDeleted ? [index] : [])); - const canBatchUpdates = - !ctx.coalesceUnchangedUpsertsEnabled && batchEntryIndexes.length > 0; + // Step 5: Handle existing edges (update/skip/resurrect). The update + // half writes row by row, so once it starts a refusal can follow a + // row it wrote. + if (owedUpdates.length > 0) progress.wrote = true; if (canBatchUpdates) { const updated = await executeEdgeUpsertUpdateBatch( ctx, @@ -4205,6 +5348,24 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( } } } else { + // These legs write one row at a time, each in a write plan of its + // own that reaches only its own verdicts before its statement, and + // probes acyclicity against stored state only: decide them as one + // set before the first (the create batch already did, when step 4 + // ran), and probe their resurrections and reopens as one set. + if (toCreate.length === 0 && owedUpdates.length > 1) { + await prepareEdgeUpdateSet(ctx, owedUpdates, target, lock, { + storedRows: storedRowsOfUpdateEntries(owedUpdates), + }); + } + await assertResurrectionsAcyclic( + ctx, + target, + lock, + updateEntries.filter( + (entry) => entry.clearDeleted || ifExists === "update", + ), + ); for (const [entryIndex, entry] of toFetch.entries()) { const input = requireDefined(updateEntries[entryIndex]).input; if (entry.isDeleted) { @@ -4267,8 +5428,10 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( ); } - async function runBatchResults(): Promise { - const outcome = await runBatch(); + async function runBatchResults( + progress: BulkGetOrCreateProgress, + ): Promise { + const outcome = await runBatch(progress); return outcome.results; } @@ -4278,10 +5441,32 @@ export async function executeEdgeBulkGetOrCreateByEndpoints( // the paths the fence cannot cover (a non-transactional backend, or a // competitor that is not a `getOrCreateByEndpoints`) — one retry, matching // the single-item path rather than inventing a second policy. + // + // The retry re-runs the whole batch, so it runs only where nothing the + // failed attempt wrote survives it: the frame opened its own transaction + // (the refusal rolled it back), or the attempt wrote nothing. Inside a + // caller's transaction, or on a backend without one, an attempt refused + // after a leg wrote keeps that write, and re-running would apply it twice. + const progress: BulkGetOrCreateProgress = { + transactionMode: undefined, + wrote: false, + }; try { - return await runBatchResults(); + return await runBatchResults(progress); } catch (error) { if (!(error instanceof CardinalityError)) throw error; - return runBatchResults(); + if (progress.wrote && progress.transactionMode !== "opened") throw error; + return runBatchResults({ transactionMode: undefined, wrote: false }); } } + +/** + * What one attempt of a bulk `getOrCreateByEndpoints` has done so far, read by + * its convergence retry: the transaction boundary its frame ran in, and + * whether a write of the attempt may have landed — the create batch returned + * (it writes all of its rows or none), or the row-by-row update half began. + */ +interface BulkGetOrCreateProgress { + transactionMode: WriteTransactionMode | undefined; + wrote: boolean; +} diff --git a/packages/typegraph/src/store/operations/edge-write-pipeline.ts b/packages/typegraph/src/store/operations/edge-write-pipeline.ts index ff2e256e7..360ca711d 100644 --- a/packages/typegraph/src/store/operations/edge-write-pipeline.ts +++ b/packages/typegraph/src/store/operations/edge-write-pipeline.ts @@ -31,7 +31,7 @@ import { type TransactionBackend, type UpdateEdgeParams, } from "../../backend/types"; -import { claimEdgeCardinality, purgeEdgeClaims } from "../claims/edge-claims"; +import { claimEdgeCardinalities, purgeEdgeClaims } from "../claims/edge-claims"; import { type GraphWriteLock } from "../recorded-capture/clock"; import { type WriteParamsDraft } from "./write-fences"; @@ -84,15 +84,16 @@ export type EdgeUpdateWork = Readonly<{ validFrom?: string | null; clearDeleted?: boolean; /** - * The cardinality claim this update owes, present only when the write - * RE-ADMITS the row to the population its cardinality constrains — a - * resurrection, or a reopened `oneActive` window. Built by the caller (a pure - * function of the verdict it reached) and ISSUED here, at its PRE-INSERT - * placement: the probe that authorised the write read a population no key - * fences, so the claim row is what refuses a peer that read the same - * population, and a claim issued after the row it fences is not a fence. + * Every cardinality claim this update owes, empty unless the write + * RE-ADMITS the row to a population its declaration constrains — a + * resurrection, or a reopened `oneActive`-shaped window, on either axis. + * Built by the caller (a pure function of the verdict it reached) and + * ISSUED here, at its PRE-INSERT placement: the probe that authorised the + * write read a population no key fences, so the claim rows are what refuse + * a peer that read the same population, and a claim issued after the row + * it fences is not a fence. */ - claim?: ClaimEdgeCardinalityParams; + claims: readonly ClaimEdgeCardinalityParams[]; }> & // The window END is the SAME discriminated pair `UpdateEdgeParams` declares, // not a re-spelling with two independent optionals: "state an end" and "clear @@ -121,10 +122,8 @@ export async function applyEdgeUpdate( args: EdgeUpdateWork & WriteParamsDraft, backend: Backend, ): Promise { - const { claim, ...rowWork } = args; - if (claim !== undefined) { - await claimEdgeCardinality(backend, ctx.claimsVerdict(), claim); - } + const { claims, ...rowWork } = args; + await claimEdgeCardinalities(backend, ctx.claimsVerdict(), claims); // Every key of the work record and of the fence draft is a field of // `UpdateEdgeParams`, and both are built with the same "present only when // stated" discipline the call site used to apply field by field, so the diff --git a/packages/typegraph/src/store/operations/index.ts b/packages/typegraph/src/store/operations/index.ts index 390584ce0..fc31aaa5a 100644 --- a/packages/typegraph/src/store/operations/index.ts +++ b/packages/typegraph/src/store/operations/index.ts @@ -37,6 +37,7 @@ export { executeEdgeUpsertUpdateBatch, } from "./edge-operations"; export { + applyIdentityWindowEnd, executeNodeBulkFindByConstraint, executeNodeBulkFindByIndex, executeNodeBulkGetOrCreateByConstraint, @@ -46,15 +47,20 @@ export { executeNodeCreateNoReturnBatch, executeNodeDelete, executeNodeDeleteBatch, + executeNodeDeletePlan, executeNodeFindByConstraint, executeNodeGetOrCreateByConstraint, executeNodeHardDelete, + executeNodeReparent, + executeNodeReparentBatch, executeNodeReplacementBatch, executeNodeResolvedMutationSet, + executeNodeRevive, executeNodeSetUpdate, executeNodeUpdate, executeNodeUpsertUpdate, executeNodeUpsertUpdateBatch, + type IdentityWindowEndContext, type NodeOperationContext, nodeUpsertDirtyCheck, prepareNodeReplacement, @@ -63,7 +69,6 @@ export { runWritePlan } from "./write-executor"; export { edgeWritePlan, mixedWritePlan, - nodeBatchWritePlan, nodeWritePlan, type RowWorkKind, type WritePlan, diff --git a/packages/typegraph/src/store/operations/node-delete-expectation.ts b/packages/typegraph/src/store/operations/node-delete-expectation.ts new file mode 100644 index 000000000..2665637af --- /dev/null +++ b/packages/typegraph/src/store/operations/node-delete-expectation.ts @@ -0,0 +1,226 @@ +/** + * The optimistic guard on a node delete, `delete(id, { expect })`: reading the + * stated options, and the one comparison between the plan a caller confirmed + * and the plan the delete computes for itself. + */ +import { z } from "zod"; + +import { + NodeDeletePlanMismatchError, + ValidationError, + type ValidationIssue, +} from "../../errors"; +import { zodIssuesToValidationIssues } from "../../errors/validation"; +import { compareStrings } from "../../utils/compare"; +import { encodeTupleKey, nodeRefKey } from "../../utils/tuple-key"; +import { type CompositionCascadedPart, type NodeDeletePlan } from "../types"; + +type DeletedNode = Readonly<{ kind: string; id: string }>; + +const NODE_REF_SCHEMA = z.strictObject({ kind: z.string(), id: z.string() }); + +const NODE_DELETE_OPTIONS_SCHEMA = z.strictObject({ + expect: z.strictObject({ + cascadedParts: z.array( + z.strictObject({ + kind: z.string(), + id: z.string(), + whole: NODE_REF_SCHEMA, + viaEdgeKind: z.string(), + }), + ), + removedEdgeIds: z.array(z.string()), + }), +}); + +/** + * The `expect` plan `delete(id, options)` must honor, or `undefined` when the + * caller passed no options. Refuses with `ValidationError` anything that is + * not exactly a `NodeDeleteOptions`: an unknown key, a missing or + * `undefined` `expect`, a plan of the wrong shape, or a plan naming one part + * or edge id twice, which no `planDelete` answer does. + */ +export function readNodeDeleteOptions( + options: unknown, + node: DeletedNode, +): NodeDeletePlan | undefined { + if (options === undefined) return undefined; + const parsed = NODE_DELETE_OPTIONS_SCHEMA.safeParse(options); + const issues = + parsed.success ? + duplicateEntryIssues(parsed.data.expect) + : zodIssuesToValidationIssues(parsed.error); + if (!parsed.success || issues.length > 0) { + throw new ValidationError( + `Invalid delete options for ${node.kind}/${node.id}: ${issues.map((issue) => issue.path || "(root)").join(", ")}`, + { + entityType: "node", + kind: node.kind, + id: node.id, + operation: "delete", + issues, + }, + { + suggestion: + "Pass `{ expect: plan }` with the plan planDelete(id) returned, or omit the options for an unguarded delete.", + }, + ); + } + return parsed.data.expect; +} + +/** + * Refuses an options argument passed to a node delete that takes none + * (`hardDelete`, `bulkDelete`). Their types accept no options, so this only + * reaches an untyped caller, who would otherwise get an unguarded delete for + * an `expect` the method silently dropped. + */ +export function refuseNodeDeleteArguments( + extraArguments: readonly unknown[], + target: Readonly<{ kind: string; id?: string }>, + method: "hardDelete" | "bulkDelete", +): void { + if (extraArguments.every((argument) => argument === undefined)) return; + throw new ValidationError( + `${target.kind}.${method}() takes no options.`, + { + entityType: "node", + kind: target.kind, + ...(target.id === undefined ? {} : { id: target.id }), + operation: method === "hardDelete" ? "hardDelete" : "delete", + issues: [ + { + path: "options", + message: `${method}() accepts no options; only delete(id, { expect }) is guarded by a plan.`, + }, + ], + }, + { + suggestion: `Call ${method}() without options, or use delete(id, { expect: plan }) for a guarded soft delete.`, + }, + ); +} + +function duplicateEntryIssues(plan: NodeDeletePlan): ValidationIssue[] { + const seenParts = new Set(); + const seenEdgeIds = new Set(); + const issues: ValidationIssue[] = []; + for (const [index, part] of plan.cascadedParts.entries()) { + const key = nodeRefKey(part); + if (seenParts.has(key)) { + issues.push({ + path: `expect.cascadedParts.${index}`, + message: `Part ${part.kind}/${part.id} is listed more than once.`, + }); + } + seenParts.add(key); + } + for (const [index, edgeId] of plan.removedEdgeIds.entries()) { + if (seenEdgeIds.has(edgeId)) { + issues.push({ + path: `expect.removedEdgeIds.${index}`, + message: `Edge id "${edgeId}" is listed more than once.`, + }); + } + seenEdgeIds.add(edgeId); + } + return issues; +} + +/** + * How the plan a delete computed differs from the plan it was told to + * expect. `added*` entries are in the delete's plan only, `dropped*` entries + * in the expected plan only. + */ +type NodeDeletePlanDiff = Readonly<{ + addedParts: readonly CompositionCascadedPart[]; + droppedParts: readonly CompositionCascadedPart[]; + addedEdgeIds: readonly string[]; + droppedEdgeIds: readonly string[]; +}>; + +/** + * THE comparison of two delete plans, order-insensitive: a part matches only + * the same `(kind, id, whole, viaEdgeKind)`, so a reparented part reads as + * dropped under its old whole and added under its new one, and an edge + * matches by id. `undefined` when the plans are the same. + */ +function diffNodeDeletePlans( + expected: NodeDeletePlan, + actual: NodeDeletePlan, +): NodeDeletePlanDiff | undefined { + const diff: NodeDeletePlanDiff = { + addedParts: partsAbsentFrom(actual.cascadedParts, expected.cascadedParts), + droppedParts: partsAbsentFrom(expected.cascadedParts, actual.cascadedParts), + addedEdgeIds: edgeIdsAbsentFrom( + actual.removedEdgeIds, + expected.removedEdgeIds, + ), + droppedEdgeIds: edgeIdsAbsentFrom( + expected.removedEdgeIds, + actual.removedEdgeIds, + ), + }; + const unchanged = + diff.addedParts.length === 0 && + diff.droppedParts.length === 0 && + diff.addedEdgeIds.length === 0 && + diff.droppedEdgeIds.length === 0; + return unchanged ? undefined : diff; +} + +/** + * Refuses a guarded delete whose own plan is not `expected`: `actual` is + * `undefined` when the node is absent or already deleted. + * + * @throws NodeDeletePlanMismatchError + */ +export function assertNodeDeletePlanExpected( + node: DeletedNode, + expected: NodeDeletePlan, + actual: NodeDeletePlan | undefined, +): void { + if (actual === undefined) { + throw new NodeDeletePlanMismatchError({ + kind: node.kind, + id: node.id, + situation: "notLive", + }); + } + const diff = diffNodeDeletePlans(expected, actual); + if (diff === undefined) return; + throw new NodeDeletePlanMismatchError({ + kind: node.kind, + id: node.id, + situation: "changed", + ...diff, + }); +} + +function cascadedPartKey(part: CompositionCascadedPart): string { + return encodeTupleKey([ + part.kind, + part.id, + part.whole.kind, + part.whole.id, + part.viaEdgeKind, + ]); +} + +function partsAbsentFrom( + parts: readonly CompositionCascadedPart[], + other: readonly CompositionCascadedPart[], +): readonly CompositionCascadedPart[] { + const otherKeys = new Set(other.map((part) => cascadedPartKey(part))); + return parts.filter((part) => !otherKeys.has(cascadedPartKey(part))); +} + +function edgeIdsAbsentFrom( + edgeIds: readonly string[], + other: readonly string[], +): readonly string[] { + const otherIds = new Set(other); + return edgeIds + .filter((edgeId) => !otherIds.has(edgeId)) + .toSorted((left, right) => compareStrings(left, right)); +} diff --git a/packages/typegraph/src/store/operations/node-operations.ts b/packages/typegraph/src/store/operations/node-operations.ts index 275ef6148..f22cc1c7b 100644 --- a/packages/typegraph/src/store/operations/node-operations.ts +++ b/packages/typegraph/src/store/operations/node-operations.ts @@ -86,10 +86,14 @@ import { type GraphBackend, type InsertNodeParams, isLiveNodeRow, + isLiveNodeRowPresent, + isTombstonedNodeRow, + type LiveNodeRow, type NodeInsertProjection, type NodePropertyExpectation, type NodeRow as BackendNodeRow, rowPropsToObject, + type TombstonedNodeRow, type TransactionBackend, type UniqueRow, } from "../../backend/types"; @@ -116,7 +120,7 @@ import { ValidationError, } from "../../errors"; import { validateNodeProps } from "../../errors/validation"; -import { refKey } from "../../identity/service"; +import { type IdentityFoldPlan } from "../../identity/service"; import { type IdentityTarget } from "../../identity/sql-target"; import { compileIndexWhere, @@ -139,6 +143,7 @@ import { asCompiledRowsSql } from "../../query/sql-intent"; import { type KindRegistry } from "../../registry/kind-registry"; import { canonicalEqual } from "../../schema/canonical"; import { chunk } from "../../utils/array"; +import { compareCodePoints, compareStrings } from "../../utils/compare"; import { assertOrderedValidityWindow, assertWritableValidityWindow, @@ -151,15 +156,18 @@ import { import { generateId } from "../../utils/id"; import { createDataKeyedBag, hasOwnKey } from "../../utils/object"; import { requireDefined } from "../../utils/presence"; -import { encodeTupleKey } from "../../utils/tuple-key"; +import { encodeTupleKey, nodeRefKey } from "../../utils/tuple-key"; +import { acyclicRelationCountsRow } from "../acyclicity"; import { type ClaimOwner, uniquenessProbeKinds } from "../claims/axis"; import { + assertNodeClaimTransitionsAvailable, checkUniquenessConstraints, createUniquenessContext, groupNodeUniquenessProbes, isUniquenessClaimEntry, nodeClaimEntries, type NodeClaimItem, + type NodeClaimTransition, type NodeCreateClaimPlan, planNodeCreateClaims, probeUniqueKey, @@ -171,6 +179,7 @@ import { } from "../claims/resolved-node-claims"; import { type UpsertDirtyCheck } from "../collections/coalesce"; import { + type NodeCreateBatchOptions, type NodeSetUpdateRequest, type NodeUpsertUpdateBatchEntry, type UpsertUpdateNodeInput, @@ -179,6 +188,9 @@ import { checkDisjointnessConstraint, type ConstraintContext, type ConstraintFenceReason, + edgeWriteNeedsConstraintFence, + nodeDeleteNeedsConstraintFence, + nodeRestoreNeedsConstraintFence, nodeWriteNeedsConstraintFence, } from "../constraints"; import { @@ -193,28 +205,42 @@ import { resolveNodeFulltextProjection, } from "../fulltext-sync"; import { getNodeRowsByIds } from "../node-fetch"; -import { type GraphWriteLock } from "../recorded-capture/clock"; +import { collectRecordedIdentityTransitionNotes } from "../recorded-capture"; +import { + type GraphWriteLock, + uncapturedGraphWriteLock, +} from "../recorded-capture/clock"; import { appliedResolvedMutationSet, type ResolvedMutationSetAttempt, ResolvedMutationSetMoved, unsupportedResolvedMutationSet, } from "../resolved-mutation-set"; -import { type NodeRow, rowToNode } from "../row-mappers"; +import { type NodeRow, rowToEdge, rowToNode } from "../row-mappers"; import { type BulkOperationHookContext, compareAndSetAbsent, + type CompositionAttachment, + type CompositionCascadedPart, + type CompositionNodeRef, type CreateNodeInput, + type Edge, type GetOrCreateAction, + type IfExistsMode, type Node, type NodeBulkFindByIndexOptions, + type NodeDeletePlan, type NodeGetOrCreateByConstraintOptions, + type NodeReparentOptions, + type NodeReparentResult, type OperationHookContext, + type OperationOutcomeFacts, type UpdateNodeInput, } from "../types"; import { assertClearValidToSupported, assertValidityEndMutation, + validityEndAfterMutation, } from "../validity-end"; import { createAlreadyExistsError, @@ -233,11 +259,62 @@ import { canFuseSchemaFenceInFirstWrite, isAutocommitSingleStatementWrite, } from "./autocommit-single-statement"; -import { lockNodeRow, lockNodeRowsById } from "./node-row-locks"; -import { type NodeInsertSyncItem } from "./node-write-pipeline"; +import { + cascadedPartReferences, + type CascadeMemberRowReader, + type CompositionCascadePlan, + planCompositionCascade, + readCascadeMemberRows, + requireCompositionPairVia, +} from "./composition-cascade"; +import { + assertCompositionExistencePreserved, + assertCompositionWholeEndpointLive, + assertRestoredRequiredPartsAttached, + assertSatisfiedMoveInstantHonored, + buildCompositionCreateEdgeInput, + type CompositionAttachmentRequest, + type CompositionCreateWork, + type CompositionIncumbentDisposition, + type CompositionMoveInstant, + decideCompositionAttachmentUnderFence, + type FencedCompositionAttachment, + findLiveCompositionAttachment, + incumbentSatisfiesRequestedAttachment, + readCompositionAttachment, + readNodeRowsByRef, + readReparentOptions, + resolveCompositionAttachmentRequest, + resolveCompositionCreate, + resolveCompositionMoveInstant, + type ResolvedCompositionMoveInstant, +} from "./composition-create"; +import { + createEdgeBatchValidationBackend, + createRetiringEdgeValidationBackend, + type RetiringEdge, +} from "./edge-batch-validation"; +import { + assertCompositionEdgeWindowEndable, + assertPreparedEdgeCreatesAcyclic, + type EdgeCreatePrepared, + edgeInsertWork, + endCompositionEdgeWindow, + validateAndPrepareEdgeCreate, +} from "./edge-operations"; +import { assertNodeDeletePlanExpected } from "./node-delete-expectation"; +import { lockNodeRow, lockNodeRows, lockNodeRowsById } from "./node-row-locks"; +import { + judgeNodeDeleteBehavior, + nodeClaimTransition, + type NodeDeleteMode, + type NodeDeletePolicy, + type NodeDeleteRequest, + nodeDeleteRequiresPortablePath, + type NodeInsertSyncItem, +} from "./node-write-pipeline"; import { atomicResolvedUpdateAttemptBudget, - booleanWriteResultChanges, type HookedWritePlanContext, type OverlaidSessionMint, runAtomicProgramWithHooks, @@ -245,13 +322,20 @@ import { runHookedWritePlan, runWritePlan, writeResultAlwaysChanges, + type WriteRowWork, } from "./write-executor"; import { type NodeUpdateFences } from "./write-fences"; -import { nodeBatchWritePlan, nodeWritePlan } from "./write-plan"; +import { + mixedBatchWritePlan, + mixedWritePlan, + nodeBatchWritePlan, + nodeWritePlan, +} from "./write-plan"; import { type NodeCreateWork, type NodeWriteSession, unfencedTarget, + type WriteSession, type WriteTarget, } from "./write-session"; import { @@ -259,6 +343,9 @@ import { hasLeasedSchemaFence, lockSchemaVersionForStoreWrite, memoizeLeasedSchemaFence, + refuseReadOnlyTransactionWrite, + resolveWriteTransactionMode, + runOwnedMutationSetTransaction, type WriteTransactionMode, } from "./write-transaction"; @@ -269,6 +356,12 @@ import { export type NodeOperationContext = Readonly<{ graph: G; graphId: string; + /** + * The context belongs to `store.transaction(fn, { accessMode: "read_only" })`. + * Operations that would take a write lock there refuse up front instead + * (`refuseReadOnlyTransactionWrite`). + */ + readOnlyTransaction: boolean; schemaVersion: number | undefined; historyEnabled: boolean; revisionTrackingEnabled: boolean; @@ -276,8 +369,8 @@ export type NodeOperationContext = Readonly<{ revisionSchema: SqlSchema; registry: KindRegistry; /** - * The `claims` bundle's memoized, at-most-once verdict thunk (ruling B7 - * refinement 2) — threaded through to `createNodeWriteContext` by + * The `claims` bundle's memoized, at-most-once verdict thunk — threaded + * through to `createNodeWriteContext` by * `runWritePlan`'s session mint, and called at the write-session sites that * issue or release a claim. */ @@ -298,7 +391,24 @@ export type NodeOperationContext = Readonly<{ ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ) => Promise; + /** + * Reports the composition parts one node delete's cascade removed to every + * receipt that covers this operation (`TransactionReceipt.cascadedParts`): + * the enclosing transaction's, plus each `tx.measure(...)` scope the write + * was actually issued through. Present only inside a receipt-tracked + * transaction — a top-level delete has no receipt to record into, which is + * why its absence is the off switch rather than a wiring bug. + * + * Which receipts those are is decided where the collections are built + * (`store.ts`), not here: a scoped context's collections are bound to an + * operation context carrying the scope's recorder alongside the outer ones, + * so attribution is structural, exactly as the write COUNTERS' is. The SAME + * refs the delete's `onOperationEnd` context carries, from the same cascade + * plan, so the hook and every receipt agree. + */ + recordCascadedParts?: (parts: readonly CompositionCascadedPart[]) => void; createBulkOperationContext: ( operation: "compareAndSet" | "updateWhere", kind: string, @@ -316,19 +426,41 @@ export type NodeOperationContext = Readonly<{ */ identity?: Readonly<{ lock: (target: IdentityTarget) => Promise; - foldCreated: ( + /** + * The read half of folding created or restored rows into identity: + * refuses, writing nothing, what folding `references` would refuse, so a + * frame that creates or restores rows reaches that verdict before its + * first statement. Returns the fold it decided, for {@link applyFold}. + */ + assertFoldable: ( target: IdentityTarget, references: readonly Readonly<{ kind: string; id: string }>[], + ) => Promise; + /** + * The write half: folds what `assertFoldable` decided earlier in the + * same frame, once its rows are written, without reading the verdict + * again. + */ + applyFold: ( + target: IdentityTarget, + plan: IdentityFoldPlan, + cause: "fold" | "restore", ) => Promise; detachDeleted: ( target: IdentityTarget, ref: Readonly<{ kind: string; id: string }>, mode: "soft" | "hard", ) => Promise; + /** + * Moves `ref`'s identity view along with its own window end: refuses a + * `validTo` that would strand identity assertion history and notes the + * membership boundary the move creates. `undefined` is a CLEARED end + * (`clearValidTo`), which strands nothing but still moves the boundary. + */ requireValidityEndCompatible: ( target: IdentityTarget, ref: Readonly<{ kind: string; id: string }>, - validTo: string, + validTo: string | undefined, ) => Promise; }>; }>; @@ -410,9 +542,10 @@ function nodeFencesConstraintProbe( /** * The per-item constraint probes a batch write plan folds. * - * "A batch fences when ANY item does" is owned by {@link nodeBatchWritePlan}; - * this only supplies the per-item classifications it folds, so the rule has one - * spelling instead of one here and one in the plan builder. + * "A batch fences when ANY item does" is `foldBatchConstraintProbe`'s rule + * (`write-plan.ts`), which `mixedBatchWritePlan` applies to this function's + * output — this only supplies the per-item classifications, so the fold + * itself has one spelling rather than one here and one in the plan builder. */ function nodeBatchConstraintProbes( ctx: Pick, "graph" | "registry">, @@ -424,6 +557,461 @@ function nodeBatchConstraintProbes( ); } +/** + * The constraint probes a set of node updates owes: for an entry that clears + * a tombstone, the restore probe + * ({@link file://../constraints.ts nodeRestoreNeedsConstraintFence}), then + * each entry's own update probe — so every restore entry, single or batched, + * fences its required-part verdict through one fold. The restore probe comes + * first so a plan that owes it names it, and takes the fence in the deciding + * form up front; the verdict's own gate + * (`fenceCompositionExistenceDecision`) holds whichever reason is named. + */ +function nodeUpdateConstraintProbes( + ctx: Pick, "graph" | "registry">, + entries: readonly Readonly<{ + input: Readonly<{ kind: string }>; + clearDeleted: boolean; + }>[], +): readonly (ConstraintFenceReason | undefined)[] { + return [ + ...entries.map((entry) => + entry.clearDeleted ? + nodeRestoreNeedsConstraintFence(ctx.registry, entry.input.kind) + : undefined, + ), + ...entries.map((entry) => + nodeFencesConstraintProbe(ctx, entry.input.kind, "update"), + ), + ]; +} + +/** + * WHICH constraint makes a node DELETE a constrained write — the graph-def + * lookup only. The classification itself lives with the constraints + * ({@link file://../constraints.ts nodeDeleteNeedsConstraintFence}); this + * mirrors {@link nodeFencesConstraintProbe}'s split from + * `nodeWriteNeedsConstraintFence` above, so a new constraint kind teaches + * one function, not every write path that calls it. + * + * A kind this graph does not define answers `undefined`: choosing the fence + * must not become the thing that reports an unknown kind. + */ +function nodeDeleteConstraintProbe( + ctx: Pick, "graph" | "registry">, + kind: string, +): ConstraintFenceReason | undefined { + if (!hasOwnKey(ctx.graph.nodes, kind)) return undefined; + return nodeDeleteNeedsConstraintFence(ctx.registry, kind); +} + +/** + * Folds a composition cascade's consumed edge ids into a delete policy, for + * the ROOT node's own delete-behavior enforcement — the cascade already + * excludes these from every MEMBER's own restrict count (via the policy + * `cascadeMemberDeletePolicy` builds for them); this is what excludes them from + * the root's, so a whole declared `onDelete: "restrict"` with only + * composition edges to its (now-deleted) parts still deletes. + */ +function withCascadeConsumedEdges( + policy: NodeDeletePolicy | undefined, + consumedEdgeIds: ReadonlySet, +): NodeDeletePolicy | undefined { + if (consumedEdgeIds.size === 0) return policy; + const merged = new Set(policy?.consumedEdgeIds); + for (const edgeId of consumedEdgeIds) merged.add(edgeId); + return { + enforceDeleteBehavior: policy?.enforceDeleteBehavior ?? true, + consumedEdgeIds: merged, + ...(policy?.cascadeComposition === undefined ? + {} + : { cascadeComposition: policy.cascadeComposition }), + }; +} + +/** + * ONE node row's delete inside an already-open write frame: the registration + * lookup, the soft path's live pre-image, the `retireNode`/`purgeNode` choice, + * and the identity detach. Every in-frame node delete — both top-level + * deletes, the batch, and each cascade member — runs through this, so what one + * node's delete owes has a single owner. + * + * `existing` is the live row a top-level soft delete read before planning its + * cascade. A cascade member supplies none: its row is read here, at the + * moment of its own delete. Either way the row is evidence the node was live, + * not the pre-image its uniqueness release is computed from — that is read + * after the tombstone, by `applyNodeSoftDelete`. Returns whether a row was + * written: `false` only on the soft path, for a row that is already gone — + * which a caller supplying `existing` has already ruled out. + */ +async function deleteNodeRowInFrame( + ctx: NodeOperationContext, + session: NodeWriteSession, + target: WriteTarget, + mode: NodeDeleteMode, + kind: string, + id: string, + policy: NodeDeletePolicy | undefined, + existing?: LiveNodeRow, +): Promise { + const registration = getNodeRegistration(ctx.graph, kind); + if (mode === "soft") { + const preflight = existing ?? (await target.getNode(ctx.graphId, kind, id)); + if (!isLiveNodeRowPresent(preflight)) return false; + await session.retireNode( + { + existing: preflight, + schema: registration.type.schema, + uniqueConstraints: registration.unique ?? [], + onDelete: registration.onDelete, + }, + policy, + ); + } else { + await session.purgeNode( + { + kind, + id, + schema: registration.type.schema, + onDelete: registration.onDelete, + }, + policy, + ); + } + await ctx.identity?.detachDeleted(target, { kind, id }, mode); + return true; +} + +/** + * ONE tombstoned node row's revival inside an already-open write frame — the + * inverse of {@link deleteNodeRowInFrame}'s soft path: the stored props come + * back as they were (uniqueness claims re-taken, embeddings and fulltext + * re-synced) and the node re-enters identity as a restore, its fold decided + * before the row is revived. + */ +async function reviveNodeRowInFrame( + ctx: NodeOperationContext, + session: NodeWriteSession, + target: WriteTarget, + existing: TombstonedNodeRow, +): Promise { + const registration = getNodeRegistration(ctx.graph, existing.kind); + const foldRevived = await prepareNodeFold(ctx, target, [ + { kind: existing.kind, id: existing.id }, + ]); + await session.reviveNode({ + existing, + schema: registration.type.schema, + uniqueConstraints: registration.unique ?? [], + }); + await foldRevived("restore"); +} + +/** + * The edges and nodes the deletes a frame has ALREADY planned will have + * removed by the time a later delete of the same frame runs. Threaded through + * {@link planCascadingNodeDelete} so each delete is judged against the graph + * its own turn will find, not the one the frame started from. + */ +type PlannedDeleteEffects = Readonly<{ + removedEdgeIds: Set; + deletedNodeKeys: Set; +}>; + +function noPlannedDeleteEffects(): PlannedDeleteEffects { + return { removedEdgeIds: new Set(), deletedNodeKeys: new Set() }; +} + +/** + * The READ half of one whole delete: plans the parts closure under `lock` + * (`planCompositionCascade`) and reaches every delete-behavior refusal the + * cascade and the root's own delete can raise — before the frame's first + * statement. + * + * The cascade deletes members leaf-first and each member's `onDelete: + * "restrict"` verdict used to be reached only as that member was deleted, so + * a refusal arrived after the parts beneath it were already gone; a caller + * catching it inside an enclosing `store.transaction(...)` (no nested frame + * to roll back) kept a half-applied cascade. Every member, then the root, is + * judged here in deletion order through the delete pipeline's own owner + * ({@link judgeNodeDeleteBehavior}), with the edges each earlier delete + * removes folded into the next one's policy — the verdict each delete reaches + * in sequence, without the writes. + * + * `judgeRoot: false` is the delete whose root statement is the frame's first + * write when it has no parts: that statement's own enforcement already + * precedes everything, and judging it here would only repeat its read. A + * cascade with a consumed edge to remove writes first, so its root is judged + * here whatever the caller says ({@link cascadeWritesBeforeRoot}). + * + * `lockMembers` is every delete that goes on to write: it reads the members' + * liveness through their delete locks ({@link lockCascadeMembers}), so a part + * deleted concurrently drops out of the plan before the first member is + * judged rather than staying in it. `planDelete`'s preview writes nothing and + * takes none. + * + * An empty plan when `policy?.cascadeComposition` is `false` (merge apply's + * request: its plan already carries the part deletions) or when the kind + * declares no composition parts. + */ +async function planCascadingNodeDelete( + ctx: NodeOperationContext, + root: Readonly<{ kind: string; id: string }>, + target: WriteTarget, + lock: GraphWriteLock, + policy: NodeDeletePolicy | undefined, + effects: PlannedDeleteEffects, + options: Readonly<{ judgeRoot: boolean; lockMembers: boolean }>, +): Promise { + const plan: CompositionCascadePlan = withoutPlannedDeleteEffects( + policy?.cascadeComposition === false ? + { members: [], consumedEdgeIds: new Set() } + : await planCompositionCascade( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + root.kind, + root.id, + target, + options.lockMembers ? + lockCascadeMembers(ctx, target) + : readCascadeMemberRows(ctx, target), + ), + effects, + ); + + const judge = async ( + node: Readonly<{ kind: string; id: string }>, + nodePolicy: NodeDeletePolicy | undefined, + ): Promise => { + const verdict = await judgeNodeDeleteBehavior( + ctx, + { + kind: node.kind, + id: node.id, + onDelete: getNodeRegistration(ctx.graph, node.kind).onDelete, + }, + target, + withCascadeConsumedEdges(nodePolicy, effects.removedEdgeIds), + ); + for (const edgeId of verdict.removedEdgeIds) { + effects.removedEdgeIds.add(edgeId); + } + effects.deletedNodeKeys.add(nodeRefKey(node)); + }; + + const memberPolicy = cascadeMemberDeletePolicy(plan); + for (const member of plan.members) await judge(member, memberPolicy); + if (options.judgeRoot || cascadeWritesBeforeRoot(plan)) { + await judge(root, withCascadeConsumedEdges(policy, plan.consumedEdgeIds)); + } + for (const edgeId of plan.consumedEdgeIds) effects.removedEdgeIds.add(edgeId); + return plan; +} + +/** + * The cascade's member reader for a delete that writes: it reads every + * discovered member's row through the delete lock a direct delete of that + * member takes (`node-row-locks.ts`), before any member's connected edges are + * read. Liveness is judged from the locked row, so a part deleted + * concurrently is either committed before the read (and drops out of the + * plan) or waits for this frame; and an edge write that holds a member as a + * locked endpoint commits first, so the member's delete sees and removes its + * edge instead of leaving it pointing at a deleted part. + * + * One statement per member kind, kinds in code-point order like every other + * multi-kind lock read, and each kind's rows in id order inside `lockNodes`; + * the per-graph write lock the cascade holds already orders it against every + * other cascade. Where writers serialize there is nothing to wait for, so the + * members are read without a lock clause. + */ +function lockCascadeMembers( + ctx: NodeOperationContext, + target: WriteTarget, +): CascadeMemberRowReader { + if (serializesWriters(resolveWriteFencePlan(target))) { + return readCascadeMemberRows(ctx, target); + } + return async (members) => { + const memberIdsByKind = new Map(); + for (const member of members) { + const ids = memberIdsByKind.get(member.kind) ?? []; + ids.push(member.id); + memberIdsByKind.set(member.kind, ids); + } + const lockOrder = [...memberIdsByKind].toSorted(([left], [right]) => + compareCodePoints(left, right), + ); + const lockedRows = new Map(); + for (const [kind, ids] of lockOrder) { + for (const row of await lockNodeRows( + target, + ctx.graphId, + kind, + ids, + "delete", + )) { + lockedRows.set(nodeRefKey(row), row); + } + } + return members.map((member) => lockedRows.get(nodeRefKey(member))); + }; +} + +/** + * Whether the cascade issues a statement ahead of the root's own delete: a + * live member to delete, or a consumed edge to remove. A member the walk found + * already dead still has its edge in `plan.consumedEdgeIds`, and that edge's + * removal is a write like any other, so the root's refusal must precede it. + */ +function cascadeWritesBeforeRoot(plan: CompositionCascadePlan): boolean { + return plan.members.length > 0 || plan.consumedEdgeIds.size > 0; +} + +/** + * `plan` with what the frame's EARLIER deletes already take out of it. + * + * Every plan of a batch is read before any delete is applied, so a plan reads + * the graph as the frame started it: a whole named after one of its own + * parts (reflexive composition, `bulkDelete([child, root])`) still finds that + * part — and the part's descendants — live, though the earlier item's cascade + * deletes them first. Applying that plan unfiltered would delete them a + * second time through a pre-image that is no longer live and report them in + * `cascadedParts` twice. A member an earlier item already deletes, and an + * edge an earlier item already removes, belongs to that earlier item alone. + */ +function withoutPlannedDeleteEffects( + plan: CompositionCascadePlan, + effects: PlannedDeleteEffects, +): CompositionCascadePlan { + return { + members: plan.members.filter( + (member) => !effects.deletedNodeKeys.has(nodeRefKey(member)), + ), + consumedEdgeIds: new Set( + [...plan.consumedEdgeIds].filter( + (edgeId) => !effects.removedEdgeIds.has(edgeId), + ), + ), + }; +} + +/** The policy every member of one cascade is deleted (and judged) under. */ +function cascadeMemberDeletePolicy( + plan: CompositionCascadePlan, +): NodeDeletePolicy { + return { + enforceDeleteBehavior: true, + consumedEdgeIds: plan.consumedEdgeIds, + cascadeComposition: false, + }; +} + +/** + * The WRITE half of one whole delete's cascade, for a plan + * {@link planCascadingNodeDelete} already judged: deletes each part + * LEAF-FIRST through its own node-delete pipeline — `session.retireNode` / + * `session.purgeNode`, exactly as a direct delete of that part would run, so + * its own non-composition edges, uniqueness/claim release, embedding and + * fulltext projections, and identity cascade all apply — then explicitly + * deletes every composition edge the cascade consumed, since each member's + * own delete-behavior enforcement was told to skip them (they would + * otherwise survive: a `consumedEdgeIds` edge is excluded from BOTH the + * restrict count and the cascade/disconnect removal of the delete that + * consumed it). + * + * The caller folds `plan.consumedEdgeIds` into the ROOT's own policy + * ({@link withCascadeConsumedEdges}) for the root delete that follows. + * Returns the members it deleted, in deletion order — what `cascadedParts` + * reports. + */ +async function applyCompositionCascade( + ctx: NodeOperationContext, + plan: CompositionCascadePlan, + target: WriteTarget, + mode: NodeDeleteMode, + session: NodeWriteSession, +): Promise { + const memberPolicy = cascadeMemberDeletePolicy(plan); + const deletedMembers: CompositionCascadePlan["members"][number][] = []; + for (const member of plan.members) { + // Each member's delete reads its own row now rather than writing against + // one the plan read: a member gone by then is skipped, and not reported. + // What the delete releases is decided later still, from the row as + // tombstoned (`applyNodeSoftDelete`), because an ordinary update takes no + // per-graph lock and can commit at any point before that. + const deleted = await deleteNodeRowInFrame( + ctx, + session, + target, + mode, + member.kind, + member.id, + memberPolicy, + ); + if (deleted) deletedMembers.push(member); + } + // Runs even when no member is live. A member the plan found already dead is + // absent from `plan.members` while its edge is still in + // `plan.consumedEdgeIds`, and the root's own delete is told to skip every + // consumed edge, so nothing else would remove it. + // + // The explicit cleanup that guarantees no composition edge row survives + // its endpoints, even when a member's own onDelete is `restrict`: every + // consumed edge was deliberately excluded from each endpoint's own + // cascade/disconnect removal above. + // + // Sorted for the same reason `cascadeDeletionOrder` sorts the members: the + // set's iteration order is the order the walk happened to read the rows, and + // two cascades whose closures overlap must take their EDGE row locks in one + // agreed order too, not only their node row locks. + await session.deleteCompositionEdges( + [...plan.consumedEdgeIds].toSorted((left, right) => + compareStrings(left, right), + ), + mode, + ); + return cascadedPartReferences({ ...plan, members: deletedMembers }); +} + +/** + * The write half of one planned node delete: its composition cascade, + * leaf-first, then the root's own row, told to skip every edge the cascade + * consumed. Returns the parts the cascade deleted, in deletion order. + * + * The cascade (connected edges, uniques, embeddings, fulltext, node) is not + * individually atomic, so every caller runs it inside one write frame. Under + * recorded-time capture that also collapses it into a single recorded commit + * instant instead of one instant per sub-write. + */ +async function deleteRootWithCascade( + ctx: NodeOperationContext, + session: NodeWriteSession, + target: WriteTarget, + mode: NodeDeleteMode, + root: Readonly<{ kind: string; id: string }>, + policy: NodeDeletePolicy | undefined, + cascadePlan: CompositionCascadePlan, + preflight?: LiveNodeRow, +): Promise { + const cascadedParts = await applyCompositionCascade( + ctx, + cascadePlan, + target, + mode, + session, + ); + await deleteNodeRowInFrame( + ctx, + session, + target, + mode, + root.kind, + root.id, + withCascadeConsumedEdges(policy, cascadePlan.consumedEdgeIds), + preflight, + ); + return cascadedParts; +} + /** * The executor context this module's writes run under: the operation context * plus HOW it acquires the identity lock. @@ -1128,6 +1716,45 @@ function foldReferences( .map((prepared) => ({ kind: prepared.kind, id: prepared.id })); } +/** + * The identity fold a write frame decided, written by calling it once the + * frame's rows land. The cause is the row's: `"restore"` for a tombstone + * brought back, `"fold"` for an insert. + */ +type PreparedNodeFold = (cause: "fold" | "restore") => Promise; + +/** + * Decides, writing nothing, the identity fold a write frame owes the node rows + * it inserts or brings back, before the frame's first write: a fold the + * identity rules refuse is raised here, so a refusal caught inside a caller's + * transaction leaves none of the frame's rows behind. The returned function + * writes the fold as decided, with no second read, once the rows are written: + * a row's own write changes neither its live same-id peers nor any class. + */ +async function prepareNodeFold( + ctx: NodeOperationContext, + target: WriteTarget, + references: readonly Readonly<{ kind: string; id: string }>[], +): Promise { + const identity = ctx.identity; + if (identity === undefined) return () => Promise.resolve(); + const plan = await identity.assertFoldable(target, references); + return (cause) => identity.applyFold(target, plan, cause); +} + +/** + * The fold a create frame owes its rows ({@link prepareNodeFold}), judged + * against the identity state as stored: a batch is one kind and two of its + * rows never share an id, so no row's fold depends on another's. + */ +function prepareNodeCreatesFold( + ctx: NodeOperationContext, + target: WriteTarget, + preparedCreates: readonly NodeCreatePrepared[], +): Promise { + return prepareNodeFold(ctx, target, foldReferences(preparedCreates)); +} + // ============================================================ // Shared Update Pipeline // @@ -1214,6 +1841,24 @@ type NodeUpdateExecutionOptions = Readonly<{ replacementProps?: Record; }>; +/** + * One node update with every verdict its own preparation reaches and no + * statement issued: the row's presence, the merged props, the window verdict + * and the fence it hands the statement. The uniqueness transition is not + * judged here — the write plans it ({@link nodeClaimTransition}, the same + * owner `claimTransition` is read from) — so a frame preparing several + * updates judges their transitions together. + * + * `rowAfter` is the row as the write leaves it, which a frame preparing + * several writes judges the next one against ({@link prepareNodeUpdateSet}). + */ +type NodeUpdatePrepared = Readonly<{ + work: Parameters[0]; + fences: NodeUpdateFences; + claimTransition: NodeClaimTransition; + rowAfter: BackendNodeRow; +}>; + async function performNodeUpdate( ctx: NodeOperationContext, input: UpsertUpdateNodeInput, @@ -1222,6 +1867,32 @@ async function performNodeUpdate( options?: NodeUpdateExecutionOptions, resolvedExisting?: BackendNodeRow, ): Promise { + const prepared = await prepareNodeUpdate( + ctx, + input, + target, + options, + resolvedExisting, + ); + return writePreparedNodeUpdate(session, prepared); +} + +/** The WRITE half of {@link performNodeUpdate}: one prepared update's statement. */ +async function writePreparedNodeUpdate( + session: NodeWriteSession, + { work, fences }: NodeUpdatePrepared, +): Promise { + return rowToNode(await session.reviseNode(work, fences)); +} + +/** The READ half of {@link performNodeUpdate}; see {@link NodeUpdatePrepared}. */ +async function prepareNodeUpdate( + ctx: NodeOperationContext, + input: UpsertUpdateNodeInput, + target: WriteTarget, + options?: NodeUpdateExecutionOptions, + resolvedExisting?: BackendNodeRow, +): Promise { const { kind, id } = input; assertValidityEndMutation(input, { entityType: "node", kind, id }); @@ -1322,6 +1993,33 @@ async function performNodeUpdate( const fences: NodeUpdateFences = { validityLowerBound: windowVerdict.storedLowerBoundFence, }; + const claimTransition = nodeClaimTransition( + existing, + validatedProps, + shared.uniqueConstraints, + ); + const validToAfter = + resurrectionInstant === undefined ? + validityEndAfterMutation( + input.clearValidTo === true ? { clearValidTo: true } + : validTo === undefined ? {} + : { validTo }, + existing.valid_to, + ) + : validTo; + // The row this write leaves: a resurrection rewrites the whole window and + // brings the row back; an in-place update keeps its stored lower bound. + const rowAfter: BackendNodeRow = { + ...existing, + props: validatedProps, + valid_from: + resurrectionInstant === undefined ? + existing.valid_from + : resurrectionBound, + valid_to: validToAfter, + deleted_at: + resurrectionInstant === undefined ? existing.deleted_at : undefined, + }; // A resurrecting upsert (clearDeleted) may target a tombstoned row; a plain // update must prove the row live — see NodeUpdateTarget. @@ -1348,23 +2046,34 @@ async function performNodeUpdate( validFrom !== undefined && { validFrom } // eslint-disable-next-line unicorn/no-null -- `validFrom: null` means "store NULL"; omitting the key means "decide for me", and this path has already decided. See UpdateNodeParams.validFrom. : { validFrom: resurrectionBound ?? null }; - const row = await session.reviseNode( - { ...shared, existing, clearDeleted: true, ...resurrectionLowerBound }, + return { + work: { + ...shared, + existing, + clearDeleted: true, + ...resurrectionLowerBound, + }, fences, - ); - return rowToNode(row); + claimTransition, + rowAfter, + }; } if (!isLiveNodeRow(existing)) throw new NodeNotFoundError(kind, id); - const row = await session.reviseNode( + return { // An in-place update must not rewrite the stored bound, so it keeps the // conditional spread: `buildUpdateNode` ignores `validFrom` off the // resurrection leg, and stating one here would claim a rewrite that no // statement performs. - { ...shared, existing, ...(validFrom !== undefined && { validFrom }) }, + work: { + ...shared, + existing, + ...(validFrom !== undefined && { validFrom }), + }, fences, - ); - return rowToNode(row); + claimTransition, + rowAfter, + }; } /** @@ -1446,39 +2155,281 @@ async function performNodeUpdateWithResurrectionRecovery( throw new NodeNotFoundError(input.kind, input.id); } -/** - * The error a caller sees when a node update exhausts its attempts, or fails - * with something that is not the zero-row sentinel. - * - * A non-sentinel error passes through untouched. The sentinel does not: it says - * "the statement matched nothing", which after {@link NODE_UPDATE_ATTEMPTS} - * rounds means a peer is replacing this row faster than this writer can read - * it. That is a contention fact, and it is reported as one rather than as a - * missing node — the node is present, it just is not staying still. - */ -function nodeUpdateRaceError( - input: UpsertUpdateNodeInput, - error: unknown, -): unknown { - if (!isNodeUpdateNoRowError(error)) return error; - return new DatabaseOperationError( - `Node update for ${input.kind} "${input.id}" could not be applied to a stable row after ${NODE_UPDATE_ATTEMPTS} attempts: the row was removed and recreated between each read and its write. A concurrent writer is replacing this node faster than it can be read; serialize the writers, or retry.`, - { - operation: "update", - entity: "node", - attempted: [{ kind: input.kind, id: input.id }], - }, - { cause: error }, - ); +/** The update options one batch entry carries into the shared update body. */ +function nodeUpdateBatchEntryOptions( + entry: NodeUpsertUpdateBatchEntry, +): NodeUpdateExecutionOptions | undefined { + return entry.clearDeleted || entry.replacementProps !== undefined ? + { + ...(entry.clearDeleted ? { clearDeleted: true } : {}), + ...(entry.replacementProps === undefined ? + {} + : { replacementProps: entry.replacementProps }), + } + : undefined; } -// ============================================================ -// Shared Batch Preparation -// -// Both returning and non-returning batch creates share the same -// validate-and-register loop. This extracts it. -// ============================================================ - +/** + * One entry of a prepared node update set: its prepared write, and whether it + * was prepared against the row as STORED — see {@link EdgeUpdateSetEntry}'s + * edge counterpart (`edge-operations.ts`) for why a write re-prepares an entry + * judged on a projection. + */ +type NodeUpdateSetEntry = Readonly<{ + prepared: NodeUpdatePrepared; + preparedAgainstStoredRow: boolean; +}>; + +/** + * The row a create this frame is about to issue will leave, as the update + * set's projections read it. The lower bound is the one the insert stamps + * ({@link resolveStampedValidityLowerBound}), taken at this frame's clock — an + * instant the insert's own stamp can only follow. + */ +function nodeRowOfPendingCreate(prepared: NodeCreatePrepared): BackendNodeRow { + const instant = nowIso(); + const params = prepared.insertParams; + return { + graph_id: params.graphId, + kind: params.kind, + id: params.id, + props: params.props, + version: 1, + valid_from: resolveStampedValidityLowerBound( + params.validFrom, + params.validTo, + instant, + ), + valid_to: params.validTo, + created_at: instant, + updated_at: instant, + deleted_at: undefined, + }; +} + +/** + * The READ half of a SET of node updates a frame writes one row at a time: + * every refusal any entry can reach by reading, reached before the first is + * written, so a caller that catches it inside an enclosing + * `store.transaction(...)` is left with none of the set's writes. + * + * The set is judged as the state it produces, in write order — the rows of + * `pendingCreates` (creates this frame writes BEFORE the updates) first: + * + * - each entry is prepared ({@link prepareNodeUpdate}: presence, merged + * props, window verdict) against the row the earlier writes leave — its + * stored row, the `rowAfter` of the last earlier entry for the same node, or + * a pending create's row; + * - every uniqueness transition is probed in order, a later row seeing the + * keys an earlier row or a pending create gives up or takes + * ({@link assertNodeClaimTransitionsAvailable}); + * - a restoring entry may not bring back a row whose id a disjoint kind now + * holds ({@link checkDisjointnessConstraint}), nor a required part with no + * live whole ({@link assertRestoredRequiredPartsAttached}), nor one its + * identity fold would refuse (the identity hook `assertFoldable`); + * - an entry that moves its window end is judged by the identity hook the + * write runs ({@link applyIdentityWindowEnd}), its note withdrawn — the write + * notes it again when it lands. + * + * `storedRows` are rows read inside this frame's transaction; an entry whose + * node has none is read from `target`. + */ +async function prepareNodeUpdateSet( + ctx: NodeOperationContext, + entries: readonly NodeUpsertUpdateBatchEntry[], + target: WriteTarget, + lock: GraphWriteLock, + sources: Readonly<{ + storedRows?: ReadonlyMap; + pendingCreates?: readonly NodeCreatePrepared[]; + }> = {}, +): Promise { + const projected = new Map(); + const transitions: NodeClaimTransition[] = []; + for (const created of sources.pendingCreates ?? []) { + projected.set(nodeRefKey(created), nodeRowOfPendingCreate(created)); + transitions.push({ + kind: created.kind, + id: created.id, + heldProps: undefined, + props: created.validatedProps, + constraints: created.uniqueConstraints, + }); + } + + const restored: CompositionNodeRef[] = []; + const prepared: NodeUpdateSetEntry[] = []; + for (const entry of entries) { + const ref = { kind: entry.input.kind, id: entry.input.id }; + const projection = projected.get(nodeRefKey(ref)); + const stored = + projection === undefined ? + (sources.storedRows?.get(ref.id) ?? + (await target.getNode(ctx.graphId, ref.kind, ref.id))) + : undefined; + const update = await prepareNodeUpdate( + ctx, + entry.input, + target, + nodeUpdateBatchEntryOptions(entry), + projection ?? stored, + ); + if (entry.clearDeleted) { + restored.push(ref); + if ((projection ?? stored)?.deleted_at !== undefined) { + await checkDisjointnessConstraint( + { graphId: ctx.graphId, registry: ctx.registry, backend: target }, + ref.kind, + ref.id, + ); + } + } + if (nodeWriteMovesWindowEnd(entry.input)) { + const withdrawWindowEndNote = + await collectRecordedIdentityTransitionNotes(target, () => + applyIdentityWindowEnd(ctx, target, entry.input), + ); + withdrawWindowEndNote(); + } + transitions.push(update.claimTransition); + projected.set(nodeRefKey(ref), update.rowAfter); + prepared.push({ + prepared: update, + preparedAgainstStoredRow: projection === undefined, + }); + } + + await assertRestoredRequiredPartsAttached( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + target, + restored, + ); + await ctx.identity?.assertFoldable(target, restored); + await assertNodeClaimTransitionsAvailable( + createUniquenessContext( + ctx.graphId, + ctx.registry, + target, + ctx.uniqueSidecarBatch, + ), + transitions, + ); + return prepared; +} + +/** + * Writes a set of node updates, every one of them prepared before the first + * is written ({@link prepareNodeUpdateSet}), and CONVERGES as + * {@link performNodeUpdateWithResurrectionRecovery} does for one: when an + * entry's predicated UPDATE matched nothing and its row is still live, the + * entries not yet written are prepared again — all of them, before the next + * write — against the rows actually there, the moved entry as an ordinary + * update (only a first attempt may resurrect), and the set carries on from + * it. Each entry gets the one retry the single-row path gives it. + */ +async function writeNodeUpdateSet( + ctx: NodeOperationContext, + entries: readonly NodeUpsertUpdateBatchEntry[], + session: NodeWriteSession, + target: WriteTarget, + lock: GraphWriteLock, + storedRows: ReadonlyMap | undefined, +): Promise { + const nodes: Node[] = []; + const retriedEntries = new Set(); + // `let` earns its place: once a row moved, the rows read before the first + // write no longer describe the entries still to be written. + let rows = storedRows; + while (nodes.length < entries.length) { + const written = nodes.length; + const remaining = entries + .slice(written) + .map((entry, offset) => + retriedEntries.has(written + offset) ? + { ...entry, clearDeleted: false } + : entry, + ); + const prepared = await prepareNodeUpdateSet(ctx, remaining, target, lock, { + ...(rows === undefined ? {} : { storedRows: rows }), + }); + try { + for (const [offset, entry] of remaining.entries()) { + const { prepared: update, preparedAgainstStoredRow } = requireDefined( + prepared[offset], + ); + // Decided again here, against the folds the set's earlier restores + // wrote, and before this row's own write. + const foldRestored = + requireDefined(entries[written + offset]).clearDeleted ? + await prepareNodeFold(ctx, target, [ + { kind: entry.input.kind, id: entry.input.id }, + ]) + : undefined; + nodes.push( + await writeNodeRowMovingWindowEnd(ctx, target, entry.input, () => + preparedAgainstStoredRow ? + writePreparedNodeUpdate(session, update) + : performNodeUpdate( + ctx, + entry.input, + session, + target, + nodeUpdateBatchEntryOptions(entry), + ), + ), + ); + await foldRestored?.("restore"); + } + } catch (error) { + if (!isNodeUpdateNoRowError(error)) throw error; + const movedIndex = nodes.length; + const moved = requireDefined(entries[movedIndex]).input; + if (retriedEntries.has(movedIndex)) + throw nodeUpdateRaceError(moved, error); + const current = await target.getNode(ctx.graphId, moved.kind, moved.id); + if (current === undefined || current.deleted_at !== undefined) { + throw new NodeNotFoundError(moved.kind, moved.id); + } + retriedEntries.add(movedIndex); + rows = undefined; + } + } + return nodes; +} + +/** + * The error a caller sees when a node update exhausts its attempts, or fails + * with something that is not the zero-row sentinel. + * + * A non-sentinel error passes through untouched. The sentinel does not: it says + * "the statement matched nothing", which after {@link NODE_UPDATE_ATTEMPTS} + * rounds means a peer is replacing this row faster than this writer can read + * it. That is a contention fact, and it is reported as one rather than as a + * missing node — the node is present, it just is not staying still. + */ +function nodeUpdateRaceError( + input: UpsertUpdateNodeInput, + error: unknown, +): unknown { + if (!isNodeUpdateNoRowError(error)) return error; + return new DatabaseOperationError( + `Node update for ${input.kind} "${input.id}" could not be applied to a stable row after ${NODE_UPDATE_ATTEMPTS} attempts: the row was removed and recreated between each read and its write. A concurrent writer is replacing this node faster than it can be read; serialize the writers, or retry.`, + { + operation: "update", + entity: "node", + attempted: [{ kind: input.kind, id: input.id }], + }, + { cause: error }, + ); +} + +// ============================================================ +// Shared Batch Preparation +// +// Both returning and non-returning batch creates share the same +// validate-and-register loop. This extracts it. +// ============================================================ + /** * Primes the batch validation caches with batched reads: one `getNodes` * per kind for existence probes and one `checkUniqueBatch` per @@ -1933,7 +2884,7 @@ async function diagnoseAtomicNodeIdentity( prepared.kind, prepared.id, ); - if (existing !== undefined && isLiveNodeRow(existing)) + if (isLiveNodeRowPresent(existing)) throw createAlreadyExistsError("node", prepared.kind, prepared.id); }, ); @@ -1956,13 +2907,17 @@ async function diagnoseAtomicNodeIdentity( const rows = await boundGetNodes.getNodes(ctx.graphId, kind, idsWindow); for (const row of rows) { if (isLiveNodeRow(row)) - occupiedReferences.add(refKey({ kind: row.kind, id: row.id })); + occupiedReferences.add(nodeRefKey({ kind: row.kind, id: row.id })); } } }, ); return preparedCreates.map((prepared) => - occupiedReferences.has(refKey({ kind: prepared.kind, id: prepared.id })) ? + ( + occupiedReferences.has( + nodeRefKey({ kind: prepared.kind, id: prepared.id }), + ) + ) ? { kind: "refusal", error: createAlreadyExistsError("node", prepared.kind, prepared.id), @@ -2019,7 +2974,7 @@ async function diagnoseAtomicNodeDisjointness( ...group.ids, ]); for (const row of rows) { - rowsByReference.set(refKey({ kind: row.kind, id: row.id }), row); + rowsByReference.set(nodeRefKey({ kind: row.kind, id: row.id }), row); } }, ); @@ -2027,9 +2982,9 @@ async function diagnoseAtomicNodeDisjointness( return preparedCreates.map((prepared, index) => { for (const conflictingKind of requireDefined(disjointKindsByInput[index])) { const row = rowsByReference.get( - refKey({ kind: conflictingKind, id: prepared.id }), + nodeRefKey({ kind: conflictingKind, id: prepared.id }), ); - if (row === undefined || !isLiveNodeRow(row)) continue; + if (!isLiveNodeRowPresent(row)) continue; const error = checkDisjointness( prepared.id, prepared.kind, @@ -2219,7 +3174,7 @@ function restoreAtomicNodeBatchRows( { expectedGraphId: graphId, actualGraphId: row.graph_id }, ); } - const key = refKey({ kind: row.kind, id: row.id }); + const key = nodeRefKey({ kind: row.kind, id: row.id }); if (rowsByReference.has(key)) { throw new CompilerInvariantError( "Atomic node batch returned duplicate node references.", @@ -2231,7 +3186,7 @@ function restoreAtomicNodeBatchRows( return preparedCreates.map((prepared) => { const row = rowsByReference.get( - refKey({ kind: prepared.kind, id: prepared.id }), + nodeRefKey({ kind: prepared.kind, id: prepared.id }), ); if (row === undefined) { throw new CompilerInvariantError( @@ -2596,99 +3551,1857 @@ async function resolveClaimedNode( // Node Create Operations // ============================================================ -async function executeNodeCreateInternal( +/** + * What a composition edge's preparation already HOLDS about its two endpoint + * rows, and therefore what it still owes — stated as the evidence rather than + * as a "skip the reads" flag, so each arm is bound to the proof that earned + * it: + * + * - `"read"` — nothing is known; the ordinary edge-create preparation reads + * both endpoint rows in their public order (`assertLiveEdgeEndpoints`, + * `edge-operations.ts`). Every single-row create path. + * - `"primedWhole"` — the WHOLE's row was already read by the caller (one + * `getNodes` per kind for a whole batch) and travels here to be judged by + * {@link assertCompositionWholeEndpointLive}; the PART is a row this same + * frame writes after the preparation, so there is nothing to read yet and + * its insert is the liveness proof. Every node create + * ({@link prepareCompositionEdgeForCreate}, + * {@link prepareBatchCompositionCreateEdges}). + * - `"wholeInFrame"` — the whole is ALSO a row this frame writes (another + * item of the same batch, or the part itself), so neither endpoint has a row + * to read: both are proven by the frame's own inserts. + * - `"restoredByUpdate"` — the part row is a tombstone at read time and a + * later statement of this same frame restores it (the get-or-create + * resurrection leg); the whole was already refused-or-passed at decide time + * by `decideCompositionAttachmentUnderFence`, through the same + * {@link assertCompositionWholeEndpointLive} owner. + */ +type CompositionEndpointEvidence = + | Readonly<{ source: "read" }> + | Readonly<{ source: "primedWhole"; wholeRow: BackendNodeRow | undefined }> + | Readonly<{ source: "wholeInFrame" }> + | Readonly<{ source: "restoredByUpdate" }>; + +/** + * The READ half of a composition edge's insert: every refusal the edge can + * reach before its insert, with no statement issued. It runs the ordinary + * edge-create validation (`validateAndPrepareEdgeCreate`, `edge-operations.ts`) + * — the identical work a caller's own `store.edges..create(...)` would + * run — against this frame's `target`, so the edge lands in the SAME + * transaction as the row work that owes it. + * {@link insertPreparedCompositionEdge} is the one statement that follows; + * `edgeInsertClaims` (`composition-claims.ts`) remains the sole owner of the + * claim it carries. + * + * An attachment whose window would leave the edge born NOT attaching its part + * never reaches here: `resolveCompositionCreate` / + * `resolveCompositionAttachmentRequest` refuse it read-free, before the + * frame's first statement. + * + * `endpoints` says what this call still owes on the endpoint rows, and + * `validateAcyclicity` whether it owes the relation walk — see + * {@link CompositionEndpointEvidence} and + * {@link prepareBatchCompositionCreateEdges}. + */ +async function prepareCompositionCreateEdge( ctx: NodeOperationContext, - input: CreateNodeInput, + target: WriteTarget, + lock: GraphWriteLock, + work: CompositionCreateWork, + partId: string, + temporal: Readonly<{ validFrom?: string | null; validTo?: string }>, + options: Readonly<{ + endpoints: CompositionEndpointEvidence; + validateAcyclicity: boolean; + /** A `reparent`'s instant; see `assertCompositionEdgeWindowCurrent`. */ + moveInstant?: ResolvedCompositionMoveInstant | undefined; + }>, +): Promise { + if (options.endpoints.source === "primedWhole") { + assertCompositionWholeEndpointLive(work, options.endpoints.wholeRow); + } + const edgeInput = buildCompositionCreateEdgeInput(work, partId, temporal); + return validateAndPrepareEdgeCreate(ctx, edgeInput, generateId(), target, { + validateEndpoints: options.endpoints.source === "read", + validateCardinality: true, + validateAcyclicity: options.validateAcyclicity, + lock, + moveInstant: options.moveInstant, + }); +} + +/** + * The WRITE half of {@link prepareCompositionCreateEdge}: the one insert + * statement, carrying the claims the prepared declarations decide. + */ +async function insertPreparedCompositionEdge( + ctx: NodeOperationContext, + session: WriteSession, + prepared: EdgeCreatePrepared, +): Promise { + await session.createEdgeNoReturn(edgeInsertWork(ctx, prepared)); +} + +/** + * The two batch create paths' (`executeNodeCreateNoReturnBatch`, + * `executeNodeCreateBatch`) shared per-input composition resolution: computed + * from the ORIGINAL `inputs` (an item's `id` may be `undefined`, and must + * reach `draftNodeCreate`'s `idProvided` check unresolved — pre-filling it + * here would make every generated id look caller-supplied to the batch + * preparation that follows), synchronous and read-free so the two refusal + * arms throw before any row is touched. `undefined` entries (no composition + * work) are kept, so the result stays index-aligned with `inputs`. + */ +function resolveBatchCompositionWorks( + ctx: NodeOperationContext, + inputs: readonly CreateNodeInput[], backend: GraphBackend | TransactionBackend, - options?: Readonly<{ returnRow?: boolean }> & NodeCreateInternalOptions, -): Promise { - const kind = input.kind; - const id = input.id ?? generateId(); - const opContext = ctx.createOperationContext("create", "node", kind, id); - const shouldReturnRow = options?.returnRow ?? true; - const autocommitBackend = - isBundledRootAutocommitEligible(backend) ? backend : undefined; - const registeredKind = - hasOwnKey(ctx.graph.nodes, kind) ? - getNodeRegistration(ctx.graph, kind) - : undefined; - const candidate = - registeredKind === undefined ? undefined : ( - ({ - backend, - schemaVersion: ctx.schemaVersion, - historyEnabled: ctx.historyEnabled, - revisionTrackingEnabled: ctx.revisionTrackingEnabled, - identityEnabled: ctx.identity !== undefined, - idGenerated: input.id === undefined, - kindRegistered: true, - uniqueConstraintCount: registeredKind.unique?.length ?? 0, - disjointKindCount: ctx.registry.getDisjointKinds(kind).length, - schema: registeredKind.type.schema, - } as const) - ); - const schemaFenceInFirstWrite = - candidate !== undefined && - canFuseSchemaFenceInFirstWrite({ kind: "node", candidate }); - const autocommitSingleStatement = - autocommitBackend !== undefined && - candidate !== undefined && - isAutocommitSingleStatementWrite({ kind: "node", candidate }); - const plan = nodeWritePlan( - nodeFencesConstraintProbe(ctx, kind, "create"), - nodeCreateRequiresIdentityLock(ctx, input), +): readonly (CompositionCreateWork | undefined)[] { + return inputs.map((input) => + resolveCompositionCreate(ctx.registry, input, backend), ); +} - const rowWork = async ( - session: NodeWriteSession, - target: WriteTarget, - _overlaidSession: OverlaidSessionMint<"node">, - _lock: GraphWriteLock, - transactionMode: WriteTransactionMode, - ): Promise => { - // The outer backend's mark chooses the optimistic plan, but a custom - // transaction wrapper can replace its callback target. Re-check the - // factory-owned origin at the actual write receiver before letting that - // receiver carry the schema fence; otherwise a wrapper that dropped the - // ordinary diagnostic fence could silently write a verified store. - const targetBackend = unfencedTarget(target); - const fuseSchemaFenceInFirstWrite = - schemaFenceInFirstWrite && - isSchemaFencedInsertEligible(targetBackend) && - !hasLeasedSchemaFence(ctx, targetBackend); - if (schemaFenceInFirstWrite && !fuseSchemaFenceInFirstWrite) { - await lockSchemaVersionForStoreWrite(ctx, targetBackend); - } - const identity = ctx.identity; - const draft = draftNodeCreate(ctx, input, id, options); - const claimPlan = planNodeCreateClaims( - { graphId: ctx.graphId, registry: ctx.registry }, +/** + * The constraint-fence probe a composition attachment owes for the edge it + * writes — `composition: true` makes `edgeWriteNeedsConstraintFence` answer + * `"edgeComposition"` unconditionally, whatever else the realizing edge kind + * declares, so a backend that cannot hold the fence refuses the whole write + * rather than writing a node it cannot attach. The single spelling of that + * probe, reused by every create, restore and reparent path that attaches. + */ +const COMPOSITION_EDGE_FENCE = edgeWriteNeedsConstraintFence({ + composition: true, +}); + +/** + * The constraint-fence probes a batch's composition edges owe, folded + * alongside the batch's own node probes by both create paths + * (`executeNodeCreateNoReturnBatch`, `executeNodeCreateBatch`). Every + * attachment owes the same probe and a batch is constrained when any member + * is, so one entry stands for all of them. + */ +function compositionBatchConstraintProbes( + compositionWorks: readonly (CompositionCreateWork | undefined)[], +): readonly (ConstraintFenceReason | undefined)[] { + return compositionWorks.some((work) => work !== undefined) ? + [COMPOSITION_EDGE_FENCE] + : []; +} + +/** One batch item that owes a composition edge: the written row and its work. */ +type BatchCompositionAttachment = Readonly<{ + prepared: NodeCreatePrepared; + work: CompositionCreateWork; +}>; + +/** + * The batch's items that owe a composition edge, in input order. + * + * `preparedCreates` preserves `inputs`' order (see `prepareBatchCreates`), so + * zipping it against `compositionWorks` (index-aligned with the ORIGINAL + * `inputs`, from {@link resolveBatchCompositionWorks}) is the one place a + * resolved id and its composition work are joined. + */ +function batchCompositionAttachments( + preparedCreates: readonly NodeCreatePrepared[], + compositionWorks: readonly (CompositionCreateWork | undefined)[], +): readonly BatchCompositionAttachment[] { + return preparedCreates.flatMap((prepared, index) => { + const work = compositionWorks[index]; + return work === undefined ? [] : [{ prepared, work }]; + }); +} + +/** + * The READ half of one node create's composition edge, run BEFORE the node + * row is written: the whole's liveness, cardinality and acyclicity are all + * judged while the frame has issued no statement, so a caller that catches + * the refusal inside an enclosing `store.transaction(...)` — where there is + * no nested frame to roll back — is left with no part row. The one statement + * left after the node insert is {@link insertPreparedCompositionEdge}. + * + * Only the PART endpoint goes unread: its row does not exist yet (or is the + * tombstone this create restores), and the frame's own write is its proof. A + * part named as its own whole has no whole row to read either; the + * acyclicity probe refuses that self-loop. + * + * A lost claim at insert time is not a refusal this ordering leaves open: + * every create that owes a composition edge holds the constraint fence + * ({@link COMPOSITION_EDGE_FENCE}), so no other writer can change + * what these reads judged before the insert lands, and the claim rows stay + * the database backstop for the same verdict. + */ +async function prepareCompositionEdgeForCreate( + ctx: NodeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + work: CompositionCreateWork | undefined, + part: Readonly<{ kind: string; id: string }>, +): Promise { + if (work === undefined) return undefined; + const wholeIsPart = nodeRefKey(work.whole) === nodeRefKey(part); + return prepareCompositionCreateEdge( + ctx, + target, + lock, + work, + part.id, + work.edgeWindow, + { + endpoints: + wholeIsPart ? + { source: "wholeInFrame" } + : { + source: "primedWhole", + wholeRow: await target.getNode( + ctx.graphId, + work.whole.kind, + work.whole.id, + ), + }, + validateAcyclicity: true, + }, + ); +} + +/** + * The READ half of a node batch's composition edges, run BEFORE the batch's + * node rows are written — one owner reached from every prepared row, so a + * mixed batch of required/optional/no-`partOf` items each prepares exactly the + * edge it owes, and every refusal an attachment can reach precedes the + * batch's first statement (see {@link prepareCompositionEdgeForCreate} for why + * that order is load-bearing, and why a lost claim cannot reopen it). Shared + * by both batch create paths so neither re-spells the join, the loop, or the + * probe below. + * + * - The WHOLE rows are read once per kind ({@link readNodeRowsByRef}) + * and judged per item by {@link assertCompositionWholeEndpointLive} — the + * same owner the fenced attachment decision uses, so a dead or missing + * whole refuses with the identical `EndpointNotFoundError` on the identical + * endpoint side, in input order. A whole that is itself an item of this + * batch has no row yet and is not read: the batch's own insert (or + * resurrection) is its proof, exactly as it is the part's. + * - CARDINALITY is judged per item against the in-batch accounting every edge + * batch uses ({@link createEdgeBatchValidationBackend}), so a later item's + * probe counts the earlier items' edges although none is inserted yet. + * - ACYCLICITY is probed ONCE for the whole batch + * ({@link assertPreparedEdgeCreatesAcyclic}), as an overlay of the prepared + * edges on the stored relation, so a cycle that runs through more than one + * of the batch's own edges (an item naming another item as its whole, or + * restoring a tombstone that still carries edges of its own) is refused + * too. The refusal names a concrete offending edge: a self-loop is + * attributed to the item that states it, and a cycle to the first of the + * batch's own edges that lies on it. + * + * `operation` is the batch shape that reached here, carried into the probe's + * diagnostics (an `EdgeAcyclicityIndeterminateError` names the operation whose + * statement the engine cut short). Supplied by the caller rather than spelled + * here, because this one function serves both batch executors. + */ +async function prepareBatchCompositionCreateEdges( + ctx: NodeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + operation: string, + preparedCreates: readonly NodeCreatePrepared[], + compositionWorks: readonly (CompositionCreateWork | undefined)[], +): Promise { + const attachments = batchCompositionAttachments( + preparedCreates, + compositionWorks, + ); + if (attachments.length === 0) return []; + + const batchRowKeys = new Set( + preparedCreates.map((prepared) => nodeRefKey(prepared)), + ); + const wholeIsBatchRow = (work: CompositionCreateWork): boolean => + batchRowKeys.has(nodeRefKey(work.whole)); + // One round trip per distinct whole kind, through the owner the + // `compositionExistence` audit reads as well. `nodeRefKey` below is the tuple + // key that read returns its rows under. + const wholeRows = await readNodeRowsByRef( + target, + ctx.graphId, + attachments + .filter(({ work }) => !wholeIsBatchRow(work)) + .map(({ work }) => work.whole), + ctx.batchPointRead, + ); + const { backend: validationTarget, registerPendingEdgeForCardinality } = + createEdgeBatchValidationBackend(target); + const preparedEdges: EdgeCreatePrepared[] = []; + for (const { prepared, work } of attachments) { + const preparedEdge = await prepareCompositionCreateEdge( + ctx, + validationTarget, + lock, + work, + prepared.id, + work.edgeWindow, { - kind: draft.kind, - id: draft.id, - props: draft.validatedProps, - constraints: draft.uniqueConstraints, + endpoints: + wholeIsBatchRow(work) ? + { source: "wholeInFrame" } + : { + source: "primedWhole", + wholeRow: wholeRows.get(nodeRefKey(work.whole)), + }, + validateAcyclicity: false, }, ); - const projections = resolveNodeInsertProjections( - draft.nodeKind.schema, - draft.validatedProps, + registerPendingEdgeForCardinality( + preparedEdge.insertParams, + preparedEdge.declarations, ); - const preparationMode: NodeCreatePreparationMode = - ( - shouldReturnRow && - !fuseSchemaFenceInFirstWrite && - supportsNodeCreatePlan(target, { - params: buildInsertNodeParams( - ctx.graphId, - draft.kind, - draft.id, - draft.validatedProps, - draft.validFrom, - draft.validTo, - ), - idGenerated: !draft.idProvided, - mode: { kind: "ordinary" }, + preparedEdges.push(preparedEdge); + } + await assertPreparedEdgeCreatesAcyclic( + ctx, + target, + lock, + operation, + preparedEdges, + ); + return preparedEdges; +} + +/** + * The WRITE half of a node batch's composition edges, after every node row of + * the batch exists: one insert per prepared edge, each carrying the claims + * its preparation decided. + */ +async function insertPreparedCompositionEdges( + ctx: NodeOperationContext, + session: WriteSession, + preparedEdges: readonly EdgeCreatePrepared[], +): Promise { + for (const preparedEdge of preparedEdges) { + await insertPreparedCompositionEdge(ctx, session, preparedEdge); + } +} + +/** + * THE write every attachment surface performs once it holds the per-graph + * fence and a fenced DECISION to apply (`decideCompositionAttachmentUnderFence`, + * `composition-create.ts`): write at most one retire and one attach. Returns + * whether it wrote. + * + * One owner, reached by every surface that can attach a part to a whole + * against a row that already exists — `reparent` + * ({@link executeNodeReparent}, `onIncumbent: "replace"`), the + * get-or-create `partOf` postcondition + * ({@link applyExistingPartOfPostcondition}) and the get-or-create + * update/resurrection leg ({@link executeNodeUpsertUpdate}), both + * `onIncumbent: "refuse"`. The disposition is the ONLY dimension that + * differs, and it is applied to the incumbent the DECIDE half read on this + * same frame's target: a verdict from a lock-free read is never what decides + * the write, which is what keeps two racing callers requesting different + * wholes from ending in a silent move (one wins, one refuses). + * + * The write is split from its reads + * ({@link prepareCompositionAttachmentMoves}, then + * {@link writeCompositionAttachmentMoves}) so a frame applying several + * decisions reaches every verdict before its first statement. A frame that + * owes OTHER statements sequences its reads first in the same way: the + * get-or-create update leg ({@link executeNodeUpsertUpdate}) decides, then + * prepares the edge ({@link prepareCompositionAttachmentDecision}), updates + * properties, and only then inserts — it never reaches the `"replace"` arm, + * which is `reparent`'s alone. + * + * How the incumbent retires follows the population declared on the INCUMBENT + * row's own pair, resolved through the realizing edge that actually holds + * the attachment — not `compositionPopulation(partKind)`, which re-derives + * it from the part kind and agrees only because + * `ONTOLOGY_COMPOSITION_POPULATION_MIXED` forbids a part kind's pairs from + * disagreeing. The lookup runs through {@link requireCompositionPairVia}, + * which owns that resolution and its should-be-impossible invariant for + * every composition read. A `population: "one"` edge is DELETED (a `one` binding persists for the + * row's whole life, ended or not, so an ended row would still read as an + * attachment), while a `population: "oneActive"` edge has its window ENDED + * at the move instant, leaving the previous membership readable as + * valid-time history. + * + * The move instant is read ONCE and is both the incumbent window's `validTo` + * and the new edge's `validFrom`. For a `oneActive` pair this makes the two + * halves of the move abut in valid time: no `store.asOf(t)` coordinate shows + * the part with zero wholes, and none shows it with two. A second clock read + * would open a real gap (the first on any clock, the second on a + * non-monotonic one — issue #242's failure mode), and neither is fenceable: + * each write is legal at the instant it samples. + */ +type AttachmentApplyResult = Readonly<{ + wrote: boolean; + edge: Edge | undefined; +}>; + +async function readHeldEdge( + target: WriteTarget, + graphId: string, + id: string, +): Promise { + const row = await target.getEdge(graphId, id); + return rowToEdge( + requireDefined( + row, + `composition edge "${id}" was written but not readable`, + ), + ); +} + +function attachmentWindow( + work: CompositionCreateWork, + moveAt: string | undefined, +): Readonly<{ validFrom?: string | null; validTo?: string }> { + return moveAt === undefined ? work.edgeWindow : { validFrom: moveAt }; +} + +/** One fenced decision a frame is about to apply, with its move instant. */ +type DecidedAttachmentMove = Readonly<{ + partId: string; + decided: FencedCompositionAttachment; + /** + * `reparent`'s move instant, stated or defaulted. Absent on every other + * surface, none of which can decide `"replace"`. + */ + moveAt: CompositionMoveInstant | undefined; + /** + * What the frame already knows about the two endpoint rows + * ({@link CompositionEndpointEvidence}): `"restoredByUpdate"` for a tombstone + * a later statement of the same frame restores (a get-or-create + * resurrection, the fact the decision itself was reached under — + * `decideCompositionAttachmentUnderFence`'s `partRowRestoredByUpdate`), and + * `"primedWhole"` for a row this frame is about to create, whose whole was + * read by the caller. + */ + endpoints: CompositionEndpointEvidence; +}>; + +/** + * One decision with every verdict its write can reach already passed. + * `replacement` is absent for a `"satisfied"` decision, which writes nothing; + * `retire` is present for a `"replace"` decision. + */ +type PreparedAttachmentMove = Readonly<{ + partId: string; + decided: FencedCompositionAttachment; + replacement?: EdgeCreatePrepared; + retire?: Readonly<{ + incumbent: BackendEdgeRow; + population: "one" | "oneActive"; + moveInstant: ResolvedCompositionMoveInstant; + }>; +}>; + +/** + * The READ half of applying a SET of fenced decisions: every refusal any of + * them can reach, with no statement issued. A frame that moves several parts + * (`bulkReparent`) therefore refuses before its first move, and a caller that + * catches the refusal inside an enclosing `store.transaction(...)` — where + * there is no nested frame to roll back — is left with no part moved. + * + * The set is judged as the state it produces, not one move at a time: + * + * - CARDINALITY is counted with every incumbent the set retires already gone + * ({@link createRetiringEdgeValidationBackend}) and every earlier + * replacement already present ({@link createEdgeBatchValidationBackend}). + * - ACYCLICITY is probed once, as an overlay of every replacement on the + * stored relation less the retired incumbents + * ({@link assertPreparedEdgeCreatesAcyclic}), so two moves that close a + * cycle between them are refused, and a move under a part that the same + * set moves out of the way is not. + * - Each RETIRE's own verdict is reached here too: a `oneActive` incumbent's + * window end ({@link assertCompositionEdgeWindowEndable}) and a `one` + * incumbent's delete ({@link assertCompositionExistencePreserved}). + * + * `"replace"` decides ONE instant per move ({@link incumbentRetire}) — the + * instant the incumbent window ends is the instant the new attachment begins, + * and the instant both edge writes judge their window against. + */ +async function prepareCompositionAttachmentMoves( + ctx: NodeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + operation: string, + moves: readonly DecidedAttachmentMove[], +): Promise { + const retires = moves.map((move) => incumbentRetire(ctx, move)); + const { backend: validationTarget, registerPendingEdgeForCardinality } = + createEdgeBatchValidationBackend( + createRetiringEdgeValidationBackend( + target, + retires.flatMap((retire) => + retire === undefined ? [] : [retiringEdgeOf(retire)], + ), + ), + ); + + const prepared: PreparedAttachmentMove[] = []; + for (const [index, move] of moves.entries()) { + const { partId, decided, endpoints } = move; + if (decided.disposition === "satisfied") { + if (move.moveAt !== undefined) { + assertSatisfiedMoveInstantHonored( + decided.request.work.partKind, + partId, + move.moveAt, + requireDefined( + decided.incumbent, + 'decideCompositionIncumbent answered "satisfied" with no incumbent read', + ).edge, + ); + } + prepared.push({ partId, decided }); + continue; + } + const { work } = decided.request; + const part = { kind: work.partKind, id: partId }; + const retire = retires[index]; + const moveInstant = retire?.moveInstant ?? unretiringMoveInstant(move); + const replacement = await prepareCompositionCreateEdge( + ctx, + validationTarget, + lock, + work, + partId, + attachmentWindow(work, moveInstant), + { + endpoints, + validateAcyclicity: false, + moveInstant, + }, + ); + registerPendingEdgeForCardinality( + replacement.insertParams, + replacement.declarations, + ); + if (retire === undefined) { + prepared.push({ partId, decided, replacement }); + continue; + } + if (retire.population === "oneActive") { + await assertCompositionEdgeWindowEndable( + ctx, + retire.incumbent, + part, + retire.moveInstant, + target, + lock, + ); + } else { + // For this arm the exemption's own match is unconditional: the edge + // retired is always the part's own current edge, so the function + // returns at its `reattachedPart` early return. Reached anyway so a + // rule added ABOVE that return applies to a `population: "one"` move + // too, as it does to the `oneActive` arm through the edge update body. + await assertCompositionExistencePreserved( + { + graphId: ctx.graphId, + registry: ctx.registry, + lock, + reattachedPart: part, + }, + retire.incumbent, + target, + ); + } + prepared.push({ partId, decided, replacement, retire }); + } + + await assertPreparedEdgeCreatesAcyclic( + ctx, + target, + lock, + operation, + prepared.flatMap((move) => + move.replacement === undefined ? [] : [move.replacement], + ), + retires.flatMap((retire) => + retire !== undefined && retireRemovesFromAcyclicRelation(ctx, retire) ? + [retire.incumbent.id] + : [], + ), + ); + return prepared; +} + +/** How one `"replace"` decision retires its incumbent. */ +type IncumbentRetire = NonNullable; + +/** + * How a decided move retires its incumbent, or `undefined` for a decision + * that retires nothing. The population is the one declared on the + * incumbent's OWN pair, resolved through the realizing edge that holds the + * attachment: a `one` incumbent is deleted, a `oneActive` one has its window + * ended at the move instant — one instant, shared by the ended window and the + * new one, decided against the incumbent by `resolveCompositionMoveInstant`. + */ +function incumbentRetire( + ctx: NodeOperationContext, + { partId, decided, moveAt }: DecidedAttachmentMove, +): IncumbentRetire | undefined { + if (decided.disposition !== "replace") return undefined; + const incumbent = requireDefined( + decided.incumbent, + 'decideCompositionIncumbent answered "replace" with no incumbent read', + ); + const { population } = requireCompositionPairVia( + ctx.registry, + decided.request.work.partKind, + incumbent.whole.kind, + incumbent.edge.kind, + ); + const moveInstant = resolveCompositionMoveInstant( + { + part: { kind: decided.request.work.partKind, id: partId }, + whole: decided.request.work.whole, + moveAt: requireDefined( + moveAt, + 'a composition attachment decided "replace" outside reparent, which alone states a move instant', + ), + }, + population === "oneActive" ? incumbent.edge : undefined, + ); + return { incumbent: incumbent.edge, population, moveInstant }; +} + +/** + * The instant of a decided move that retires nothing: a `reparent` of a part + * with no incumbent attaches at its own `at` (or clock read), through the same + * owner as a move that ends one. Every other surface states no move instant. + */ +function unretiringMoveInstant( + move: DecidedAttachmentMove, +): ResolvedCompositionMoveInstant | undefined { + if (move.moveAt === undefined) return undefined; + return resolveCompositionMoveInstant( + { + part: { kind: move.decided.request.work.partKind, id: move.partId }, + whole: move.decided.request.work.whole, + moveAt: move.moveAt, + }, + undefined, + ); +} + +/** The incumbent's row and the state its retire leaves it in. */ +function retiringEdgeOf(retire: IncumbentRetire): RetiringEdge { + return retire.population === "oneActive" ? + { + row: retire.incumbent, + retire: "endWindow", + validTo: retire.moveInstant, + } + : { row: retire.incumbent, retire: "delete" }; +} + +/** + * Whether retiring the incumbent takes it out of the acyclic relation its kind + * is probed against. A delete always does. A window end does only when that + * relation counts open rows alone ({@link acyclicRelationCountsRow}): an + * ended row of a composition kind is history, but the same row of a kind + * also declared `acyclic: true` stays in the relation, so the probe must + * still walk it. + */ +function retireRemovesFromAcyclicRelation( + ctx: NodeOperationContext, + retire: IncumbentRetire, +): boolean { + return ( + retire.population === "one" || + !acyclicRelationCountsRow(ctx.graph, ctx.registry, { + edgeKind: retire.incumbent.kind, + validTo: retire.moveInstant, + }) + ); +} + +/** + * The WRITE half of {@link prepareCompositionAttachmentMoves}: every retire, + * then every insert. Retiring first is what lets two moves exchange a slot + * the realizing edge kind bounds — each replacement's claim is taken only + * after the row that held it has let go — and is the order the preparation's + * counts assumed. + * + * A `population: "one"` incumbent is DELETED (a `one` binding persists for + * the row's whole life, ended or not, so an ended row would still read as an + * attachment); a `population: "oneActive"` incumbent has its window ENDED at + * the move instant, leaving the previous membership readable as valid-time + * history. + */ +async function writeCompositionAttachmentMoves( + ctx: NodeOperationContext, + session: WriteSession, + target: WriteTarget, + lock: GraphWriteLock, + moves: readonly PreparedAttachmentMove[], +): Promise { + for (const { partId, decided, retire } of moves) { + if (retire === undefined) continue; + if (retire.population === "oneActive") { + await endCompositionEdgeWindow( + ctx, + retire.incumbent, + { kind: decided.request.work.partKind, id: partId }, + retire.moveInstant, + session, + target, + lock, + ); + } else { + await session.retireEdge({ + id: retire.incumbent.id, + kind: retire.incumbent.kind, + }); + } + } + for (const { replacement } of moves) { + if (replacement !== undefined) { + await insertPreparedCompositionEdge(ctx, session, replacement); + } + } + const results: AttachmentApplyResult[] = []; + for (const { decided, replacement } of moves) { + results.push( + replacement === undefined ? + { + wrote: false, + edge: + decided.incumbent === undefined ? + undefined + : rowToEdge(decided.incumbent.edge), + } + : { + wrote: true, + edge: await readHeldEdge( + target, + ctx.graphId, + replacement.insertParams.id, + ), + }, + ); + } + return results; +} + +/** + * The READ half of applying a get-or-create leg's fenced decision, run + * BEFORE that frame's property update so every refusal the attachment can + * reach — a dead or missing part on the update leg, cardinality, acyclicity, + * the required-existence rule — precedes the update's first statement. The + * one statement left after the update is {@link insertPreparedCompositionEdge}. + * + * The order is load-bearing because the leg may run ON a caller's enclosing + * `store.transaction(...)`, which has no nested frame of its own to roll + * back: any statement already issued stays committed with it when the + * caller catches the refusal. Reads-then-writes is the only atomicity this + * layer has (no savepoints — see `claims/node-claims.ts`), so every + * refusal must be reached before the first write. The insert's claim rows + * remain the database backstop for verdicts the fenced reads already + * reached; no writer holding the fence can contradict them. + * + * `"satisfied"` prepares nothing (the arm writes nothing). `"replace"` is + * unreachable here: a get-or-create request is resolved with + * `onIncumbent: "refuse"` (`resolveGetOrCreateAttachmentRequest`), so + * `decideCompositionIncumbent` refuses a different incumbent instead of + * answering `"replace"`; a move is `reparent`'s + * ({@link prepareCompositionAttachmentMoves}). Reaching it is an invariant + * failure, not a supported path. + * + * `partRowRestoredByUpdate` names the resurrection leg: the part row is a + * tombstone until the update restores it, so the endpoint read is skipped + * there (the whole was already refused-or-passed at decide time, and the + * restoring update is the part's own liveness proof). On the update leg the + * part row is re-read under the fence like any other edge create's endpoint. + */ +async function prepareCompositionAttachmentDecision( + ctx: NodeOperationContext, + target: WriteTarget, + lock: GraphWriteLock, + partId: string, + decided: FencedCompositionAttachment, + options: Readonly<{ partRowRestoredByUpdate: boolean }>, +): Promise { + switch (decided.disposition) { + case "satisfied": { + return undefined; + } + case "attach": { + return prepareCompositionCreateEdge( + ctx, + target, + lock, + decided.request.work, + partId, + decided.request.work.edgeWindow, + { + endpoints: + options.partRowRestoredByUpdate ? + { source: "restoredByUpdate" } + : { source: "read" }, + validateAcyclicity: true, + }, + ); + } + case "replace": { + throw new CompilerInvariantError( + `A get-or-create attachment for "${decided.request.work.partKind}" "${partId}" decided "replace", but its request is resolved with onIncumbent "refuse" and can only be satisfied, attached, or refused.`, + { partKind: decided.request.work.partKind, partId }, + ); + } + } +} + +/** + * The fenced attachment of parts in one call — re-read, decide, prepare, + * write — for a frame whose only statements are the attachments' own: one + * part's ({@link runCompositionAttachmentWritePlan}) or a reparent's + * ({@link executeNodeReparentBatch}). A frame that owes other statements + * calls the halves separately so its refusals come first + * ({@link executeNodeUpsertUpdate}). + */ +async function applyCompositionAttachmentsUnderFence( + ctx: NodeOperationContext, + session: WriteSession, + target: WriteTarget, + lock: GraphWriteLock, + operation: "nodes.attach" | "nodes.reparent", + partKind: string, + items: readonly Readonly<{ + id: string; + request: CompositionAttachmentRequest; + moveAt: CompositionMoveInstant | undefined; + }>[], +): Promise { + // Every item is decided, then every move prepared, before the first + // write: see `prepareCompositionAttachmentMoves`. + const decisions: DecidedAttachmentMove[] = []; + for (const item of items) { + // Re-read under the lock: the caller's gate is lock-free, and a + // concurrent delete between the two must not leave this frame + // attaching a tombstoned part to a live whole. + await assertLiveNodeRow(target, ctx.graphId, partKind, item.id); + decisions.push({ + partId: item.id, + moveAt: item.moveAt, + endpoints: { source: "read" }, + decided: await decideCompositionAttachmentUnderFence( + ctx.registry, + target, + ctx.graphId, + item.id, + item.request, + lock, + { partRowRestoredByUpdate: false }, + ), + }); + } + return writeCompositionAttachmentMoves( + ctx, + session, + target, + lock, + await prepareCompositionAttachmentMoves( + ctx, + target, + lock, + operation, + decisions, + ), + ); +} + +/** + * THE `partOf` POSTCONDITION a `getOrCreateByConstraint` call owes for a + * match that resolved to `"found"`: when this returns, the resolved node + * holds exactly the stated attachment. + * + * The attachment is RESOLVED before this is reached + * (`resolveGetOrCreateAttachmentRequest`), so an undeclared whole kind, an + * unknown `via`, and an ambiguous omitted `via` are refused identically + * whether the constraint matched an existing node or created one. + * + * Every verdict and every write comes from the fenced re-read + * ({@link applyCompositionAttachmentsUnderFence} inside + * {@link runCompositionAttachmentWritePlan}, `onIncumbent: "refuse"`): an + * already-held attachment is satisfied (idempotent, stated `props` + * honored), no live whole has the attachment written now — so an optional + * part found unattached is attached rather than told to attach itself, and a + * REQUIRED part found unattached (only reachable through rows written + * outside the store's write path) is repaired on the same terms — and a + * DIFFERENT whole, or the same whole through a different realizing edge, is + * refused with `CompositionExistenceError` (`situation: "existing"`). Moving + * a part is `reparent`'s decision, never a side effect of a lookup. + * + * The lock-free read below is a PRE-CHECK and nothing more: its only effect + * is to let the overwhelmingly common already-satisfied call stay read-only + * instead of opening a write transaction to discover it has nothing to do. + * It can therefore skip work but never decide it — a stale "no incumbent" + * verdict acted on directly is exactly how a refusing caller would perform + * the silent move this disposition exists to prevent. + * + * The skip reads the SAME conjunction the fenced verdict's satisfied arm does + * (`incumbentSatisfiesRequestedAttachment`), stated `props` included: the + * whole and the realizing edge come off the row this read returned, so + * trusting those two and not the third would be inconsistent, and a restated + * `partOf.props` — what an idempotent ingest passes every time — would + * otherwise take the per-graph write fence on every call for a verdict of + * "nothing to do" (graph-wide serialization on Postgres, for a no-op). A + * DISAGREEMENT is never decided here: it escalates to the fence, which owns + * the `situation: "props"` refusal. + */ +async function applyExistingPartOfPostcondition( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + concreteKind: string, + concreteId: string, + request: CompositionAttachmentRequest, +): Promise { + if ( + await existingPartOfPostconditionHeld( + ctx, + backend, + concreteKind, + concreteId, + request, + ) + ) { + return; + } + + await runCompositionAttachmentWritePlan( + ctx, + concreteKind, + concreteId, + request, + backend, + ); +} + +/** + * The lock-free PRE-CHECK of {@link applyExistingPartOfPostcondition}: whether + * the part already holds exactly the stated attachment, read without the + * fence. It can only ever SKIP work — anything else escalates to the fence, + * which owns every refusal and every write. One spelling, read by the + * postcondition itself and by the batch that counts how many of its matched + * items still owe a write ({@link partitionExistingGetOrCreateLegs}). + */ +async function existingPartOfPostconditionHeld( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + concreteKind: string, + concreteId: string, + request: CompositionAttachmentRequest, +): Promise { + const current = await findLiveCompositionAttachment( + ctx.registry, + backend, + ctx.graphId, + concreteKind, + concreteId, + ); + return ( + current !== undefined && + incumbentSatisfiesRequestedAttachment(ctx.registry, request, current) + ); +} + +/** + * What `onIncumbent` every `getOrCreateByConstraint` leg's `partOf` states, + * for a match that resolved to an EXISTING row (found, updated, or + * resurrected): `"refuse"`. A lookup resolves an attachment; it never moves a + * part. The resolution itself is `resolveCompositionAttachmentRequest`'s + * (`composition-create.ts`), the one owner every attachment surface shares, + * so a `partOf` naming an undeclared or ambiguous pair refuses before any row + * is read. + * + * `undefined` means this leg owes no attachment at all. Resolving against a + * resurrection's TOMBSTONE kind/id (never the requested `kind`: a subclass + * scope can resurrect under a sibling/parent kind) is the caller's to pass; + * a required-existence kind resurrected with no `partOf` refuses in that + * owner, as it would on a fresh create. + */ +function resolveGetOrCreateAttachmentRequest( + ctx: NodeOperationContext, + concreteKind: string, + concreteId: string, + partOf: CompositionAttachment | undefined, +): CompositionAttachmentRequest | undefined { + const part = { kind: concreteKind, id: concreteId }; + return resolveCompositionAttachmentRequest( + ctx.registry, + part, + partOf === undefined ? undefined : readCompositionAttachment(partOf, part), + "refuse", + ); +} + +/** + * `getOrCreateByConstraint`'s (single-item and bulk) six create + * fallbacks each forward the caller's `partOf` onto the underlying + * `executeNodeCreate` input — one spelling of that optional-field forward + * instead of six copies of the same conditional spread. + */ +function createInputWithPartOf( + kind: string, + props: Record, + partOf: CompositionAttachment | undefined, +): CreateNodeInput { + return { kind, props, ...(partOf === undefined ? {} : { partOf }) }; +} + +/** + * What a `getOrCreateByConstraint` call owes a constraint match that resolved + * to an EXISTING row — the one decision the single-item entry, the bulk entry + * and the bulk entry's preflight all read, so none can classify a row + * differently from the write that follows: + * + * - `"found"` — the row is live and the caller did not ask to update it. It + * is returned as it is; a stated `partOf` is discharged as a postcondition + * ({@link applyExistingPartOfPostcondition}). + * - `"revise"` — the row is a tombstone (`restoresRow`), or `ifExists: + * "update"` asked for the properties to be written. The attachment and the + * property update land in ONE write plan ({@link executeNodeUpsertUpdate}), + * so an update this row refuses leaves ownership untouched. + * + * `request` is resolved against the EXISTING row's own kind and id, never the + * requested kind — a subclass scope can match (and resurrect) under a + * sibling/parent kind — and unconditionally on a restoring leg, which brings + * the part back alone and owes the same required-existence refusal a fresh + * create owes. `undefined` means the leg owes no attachment. + */ +type ExistingGetOrCreateLeg = + | Readonly<{ + leg: "found"; + request: CompositionAttachmentRequest | undefined; + }> + | Readonly<{ + leg: "revise"; + restoresRow: boolean; + request: CompositionAttachmentRequest | undefined; + }>; + +function resolveExistingGetOrCreateLeg( + ctx: NodeOperationContext, + existing: BackendNodeRow, + options: Readonly<{ + ifExists: IfExistsMode; + partOf: CompositionAttachment | undefined; + }>, +): ExistingGetOrCreateLeg { + const restoresRow = existing.deleted_at !== undefined; + const resolveRequest = (): CompositionAttachmentRequest | undefined => + resolveGetOrCreateAttachmentRequest( + ctx, + existing.kind, + existing.id, + options.partOf, + ); + if (restoresRow || options.ifExists === "update") { + return { + leg: "revise", + restoresRow, + request: + restoresRow || options.partOf !== undefined ? + resolveRequest() + : undefined, + }; + } + return { + leg: "found", + request: options.partOf === undefined ? undefined : resolveRequest(), + }; +} + +/** One constraint match on an existing row, with the leg it resolved to. */ +type ExistingGetOrCreateMatch = Readonly<{ + existing: BackendNodeRow; + validatedProps: Record; + leg: ExistingGetOrCreateLeg; +}>; + +type GetOrCreateResult = Readonly<{ node: Node; action: GetOrCreateAction }>; + +/** + * The WRITE of one existing-row leg that owes one, in its own write plan — + * every refusal the leg can reach precedes its own first statement + * ({@link executeNodeUpsertUpdate}, {@link runCompositionAttachmentWritePlan}). + * The caller has already established that the leg owes a write: a `"found"` + * leg owes it only when its attachment is not held + * ({@link existingPartOfPostconditionHeld}). + */ +async function writeOwedExistingGetOrCreateLeg( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + match: ExistingGetOrCreateMatch, +): Promise { + const { existing, validatedProps, leg } = match; + if (leg.leg === "found") { + await runCompositionAttachmentWritePlan( + ctx, + existing.kind, + existing.id, + requireDefined( + leg.request, + "a found get-or-create leg owes a write only for a stated attachment", + ), + backend, + ); + return { node: rowToNode(existing), action: "found" }; + } + const node = await executeNodeUpsertUpdate( + ctx, + { + kind: existing.kind, + id: existing.id as UpdateNodeInput["id"], + props: validatedProps, + }, + backend, + { + clearDeleted: leg.restoresRow, + ...(leg.request === undefined ? + {} + : { compositionAttachment: leg.request }), + }, + ); + return { node, action: leg.restoresRow ? "resurrected" : "updated" }; +} + +/** + * One existing-row leg of the SINGLE-item entry: a `"found"` leg discharges + * its stated attachment as a postcondition + * ({@link applyExistingPartOfPostcondition}, whose lock-free pre-check keeps an + * already-satisfied call read-only); any other leg is written + * ({@link writeOwedExistingGetOrCreateLeg}). + */ +async function applyExistingGetOrCreateLeg( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + match: ExistingGetOrCreateMatch, +): Promise { + const { existing, leg } = match; + if (leg.leg !== "found") { + return writeOwedExistingGetOrCreateLeg(ctx, backend, match); + } + if (leg.request !== undefined) { + await applyExistingPartOfPostcondition( + ctx, + backend, + existing.kind, + existing.id, + leg.request, + ); + } + return { node: rowToNode(existing), action: "found" }; +} + +/** + * The matches of a batch split by whether they owe a write, each judged ONCE. + * A `"found"` leg owes one only when its stated attachment is not already held + * (the lock-free pre-check, {@link existingPartOfPostconditionHeld}); a + * revising leg (an update or a resurrection) always does. The batch writes the + * `owed` ones and returns the rest as found, so the pre-check is not read + * again per item at write time. + */ +async function partitionExistingGetOrCreateLegs< + G extends GraphDef, + M extends ExistingGetOrCreateMatch, +>( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + matches: readonly M[], +): Promise> { + const owed: M[] = []; + const held: M[] = []; + for (const match of matches) { + const { existing, leg } = match; + if ( + leg.leg === "found" && + (leg.request === undefined || + (await existingPartOfPostconditionHeld( + ctx, + backend, + existing.kind, + existing.id, + leg.request, + ))) + ) { + held.push(match); + } else { + owed.push(match); + } + } + return { owed, held }; +} + +/** + * Whether a batch of get-or-create items must have its verdicts reached before + * its first write: at least one matched item owes a write that something else + * in the batch could still refuse after — another owed matched item, or a row + * the batch creates after the matched items are written (its unique keys, its + * attachment). With exactly one owed leg and no create, that leg's own write + * plan already reaches every verdict before its first statement, and the + * batch needs no frame of its own; with no owed leg, the create batch is the + * only write and its own plan does the same. + */ +function getOrCreateBatchDecidesBeforeWriting( + owedCount: number, + createCount: number, +): boolean { + return owedCount > 0 && (owedCount > 1 || createCount > 0); +} + +/** + * The READ half of a BATCH of get-or-create writes: every refusal any of them + * can reach by reading, under the fence, before the batch's first statement. + * + * Each leg runs in its own write plan ({@link writeOwedExistingGetOrCreateLeg}) + * and orders its own reads before its own writes, which is enough for one + * leg and not for several: on a caller's enclosing `store.transaction(...)` + * there is no nested frame to roll back, so a leg refused after an earlier + * leg wrote would leave that earlier attachment, update or resurrection + * committed with a batch that threw. This frame is what makes the batch + * all-or-nothing there — it writes nothing, and the legs that follow it + * re-reach, on the same transaction and under the same fence, verdicts this + * frame already passed. + * + * The set is judged as the state the batch produces, by the owners each leg + * itself decides through: + * + * - every stated attachment is DECIDED + * ({@link decideCompositionAttachmentUnderFence}: satisfied, attach, or the + * refusal for a different whole, a different realizing edge, differing + * `props` or window, a dead whole) and the attachments PREPARED together + * ({@link prepareCompositionAttachmentMoves}: part liveness, cardinality + * counting the batch's earlier edges, and one acyclicity probe over all of + * them) — the attachments of the rows the batch is about to CREATE among + * them, counted after the matched ones in the order the batch writes them, + * so a whole-side bound the matched and the new rows exceed together is + * refused here and not after the matched items wrote; + * - a restoring leg that states no attachment may only bring back a part + * that still holds a live whole + * ({@link assertRestoredRequiredPartsAttached}), and may not bring back a + * row whose id a disjoint kind now holds ({@link checkDisjointnessConstraint}, + * the very read a create answers it by); + * - every property update is validated against the row it will merge over + * ({@link resolveNodeUpdateProps}) and its uniqueness claims probed in + * order, a later row seeing the keys an earlier row gives up or takes + * ({@link assertNodeClaimTransitionsAvailable}) — the rows the batch is + * about to CREATE among them, after the matched ones as the batch writes + * them ({@link newRowClaimTransitions}), so a new row's key that a matched + * update hands it is free and one an earlier row holds or takes is refused + * here, not when the create batch prepares it after the matched items wrote; + * - a restoring leg's identity fold is read too (the identity hook + * `assertFoldable`), against the identity state as stored. + * + * What stays with the write: a peer that is not excluded by the fence (a kind + * whose unique keys are enforced by the claim row's primary key alone takes + * none) can still claim a key, or remove a row, between this frame and a + * leg's statement; and an identity refusal that depends on another row this + * same batch restores is judged when the restore lands. A new row's id is + * generated, so no disjoint kind can hold it. + */ +async function assertGetOrCreateBatchApplicable( + ctx: NodeOperationContext, + backend: GraphBackend | TransactionBackend, + batch: Readonly<{ + owed: readonly ExistingGetOrCreateMatch[]; + creates: readonly CreateNodeInput[]; + attachingCreates: readonly CreateNodeInput[]; + createWorks: readonly (CompositionCreateWork | undefined)[]; + partOf: CompositionAttachment | undefined; + }>, +): Promise { + const { owed, creates, attachingCreates, createWorks, partOf } = batch; + const revisions = owed.flatMap((match) => + match.leg.leg === "revise" ? [{ ...match, leg: match.leg }] : [], + ); + await runWritePlan( + nodeWritePlanContext(ctx), + mixedBatchWritePlan( + [ + ...nodeUpdateConstraintProbes( + ctx, + revisions.map(({ existing, leg }) => ({ + input: existing, + clearDeleted: leg.restoresRow, + })), + ), + ...owed.map(({ leg }) => + leg.request === undefined ? undefined : COMPOSITION_EDGE_FENCE, + ), + ...nodeBatchConstraintProbes(ctx, creates, "create"), + ...compositionBatchConstraintProbes(createWorks), + ], + revisions.some(({ leg }) => leg.restoresRow) && + nodeRequiresIdentityLock(ctx), + ), + backend, + async (_session, target, _overlaidSession, lock) => { + await assertRestoredRequiredPartsAttached( + { graphId: ctx.graphId, registry: ctx.registry, lock }, + target, + revisions + .filter(({ leg }) => leg.restoresRow && leg.request === undefined) + .map(({ existing }) => ({ kind: existing.kind, id: existing.id })), + ); + + const moves: DecidedAttachmentMove[] = []; + const transitions: NodeClaimTransition[] = []; + for (const { existing, validatedProps, leg } of owed) { + const { kind, id } = existing; + // Re-read under the lock: the row the leg was resolved from was read + // lock-free. + const current = await target.getNode(ctx.graphId, kind, id); + const restoresRow = leg.leg === "revise" && leg.restoresRow; + if (current === undefined || (!restoresRow && !isLiveNodeRow(current))) + throw new NodeNotFoundError(kind, id); + if (restoresRow) { + await checkDisjointnessConstraint( + { graphId: ctx.graphId, registry: ctx.registry, backend: target }, + kind, + id, + ); + } + if (leg.request !== undefined) { + moves.push({ + partId: id, + moveAt: undefined, + endpoints: + restoresRow ? { source: "restoredByUpdate" } : { source: "read" }, + decided: await decideCompositionAttachmentUnderFence( + ctx.registry, + target, + ctx.graphId, + id, + leg.request, + lock, + { partRowRestoredByUpdate: restoresRow }, + ), + }); + } + if (leg.leg === "revise") { + const { registration, validatedProps: nextProps } = + resolveNodeUpdateProps(ctx, current, validatedProps); + transitions.push( + nodeClaimTransition(current, nextProps, registration.unique ?? []), + ); + } + } + // A restoring leg re-runs the identity fold when it lands; what that + // fold would refuse is read here, for every restoring leg at once. + await ctx.identity?.assertFoldable( + target, + revisions + .filter(({ leg }) => leg.restoresRow) + .map(({ existing }) => ({ kind: existing.kind, id: existing.id })), + ); + moves.push( + ...(await newRowAttachmentMoves(ctx, target, attachingCreates, partOf)), + ); + await prepareCompositionAttachmentMoves( + ctx, + target, + lock, + "nodes.bulkGetOrCreateByConstraint", + moves, + ); + await assertNodeClaimTransitionsAvailable( + createUniquenessContext( + ctx.graphId, + ctx.registry, + target, + ctx.uniqueSidecarBatch, + ), + [...transitions, ...newRowClaimTransitions(ctx, creates)], + ); + }, + { didWrite: () => false }, + ); +} + +/** + * The claim transitions of the rows a batch is about to create, judged after + * the matched items' ({@link assertNodeClaimTransitionsAvailable}). A row that + * does not exist yet holds no reservation going in and re-reserves every key + * it owes — the shape {@link nodeClaimTransition} gives a restored tombstone, + * which holds none either. The id is a stand-in, as in + * {@link newRowAttachmentMoves}: the create batch assigns the real one, and + * no other node can own a freshly generated id. + */ +function newRowClaimTransitions( + ctx: NodeOperationContext, + creates: readonly CreateNodeInput[], +): readonly NodeClaimTransition[] { + return creates.map((input) => ({ + kind: input.kind, + id: generateId(), + heldProps: undefined, + props: input.props, + constraints: getNodeRegistration(ctx.graph, input.kind).unique ?? [], + })); +} + +/** + * The attachments of the rows a batch is about to create, as the moves + * {@link prepareCompositionAttachmentMoves} counts alongside the matched + * items' — each an `"attach"` of a part the frame has no row for yet, judged + * against its whole's row exactly as the create batch will judge it. Every + * request resolves the call's one `partOf`, so they share one whole, read + * once. The part's id is a stand-in: the created row's real id is assigned + * when the create batch prepares it, and nothing the frame counts depends on + * it. + */ +async function newRowAttachmentMoves( + ctx: NodeOperationContext, + target: WriteTarget, + attachingCreates: readonly CreateNodeInput[], + partOf: CompositionAttachment | undefined, +): Promise { + const requests = attachingCreates.map(({ kind }) => { + const id = generateId(); + const request = requireDefined( + resolveGetOrCreateAttachmentRequest(ctx, kind, id, partOf), + "a create that owes a composition attachment resolved to no request", + ); + return { id, request }; + }); + const whole = requests[0]?.request.work.whole; + if (whole === undefined) return []; + const wholeRow = await target.getNode(ctx.graphId, whole.kind, whole.id); + return requests.map(({ id, request }) => ({ + partId: id, + moveAt: undefined, + endpoints: { source: "primedWhole", wholeRow }, + decided: { request, disposition: "attach" }, + })); +} + +// ============================================================ +// Node Reparent Operations +// ============================================================ + +/** + * Moves one composition part to a new whole, atomically: one write plan, one + * per-graph fence, the old attachment retired and the new one created inside + * the SAME transaction. + * + * Why a first-class operation rather than "delete the edge, then create the + * other one": those two writes cannot both hold. One whole per part gives a + * part exactly one + * whole, so the create refuses (`COMPOSITION_WHOLE_OCCUPIED`) while the old + * edge still holds the claim; and `existence: "required"` refuses the delete + * (`CompositionExistenceError`, `situation: "detach"`) while the part is + * live. Between them a caller has no legal order — this operation is the + * order, with both invariants validated against the frame's FINAL state: + * + * - **one whole** — the new edge takes the composition claim only after the + * old row stopped holding it, so a concurrent attach still loses; + * - **acyclicity over the oriented composition union** — the probe + * `validateAndPrepareEdgeCreate` runs sees the post-retire graph, so + * moving a subtree under one of its own former siblings is judged on where + * the part actually ends up, not on a transient state; + * - **required existence never violated mid-way** — either retire arm (the + * window end and the delete) carries the part as `reattachedPart` evidence + * ({@link assertCompositionExistencePreserved}), so the refusal is applied + * with the frame's real end state rather than bypassed. + * + * The part keeps its id, its properties, and every descendant beneath it: + * nothing below the part is rewritten, because a descendant's own + * composition edge names its immediate whole, which this move does not + * change. + * + * How the old attachment is retired follows the population declared on the + * INCUMBENT row's own pair (resolved through the realizing edge that holds + * the attachment, `KindRegistry.compositionPairVia`), so the row's meaning + * survives the move: a `population: "one"` edge is DELETED (a `one` binding + * persists for the row's whole life, ended or not, so an ended row would + * still read as an attachment), while a `population: "oneActive"` edge has + * its window ENDED at the move instant, leaving the previous membership + * readable as valid-time history. + * + * The move instant is read ONCE and is both the incumbent window's `validTo` + * and the new edge's `validFrom`. For a `oneActive` pair this makes the two + * halves of the move abut in valid time: no `store.asOf(t)` coordinate shows + * the part with zero wholes, and none shows it with two. A `one` pair instead + * DELETES the incumbent row (see above), so that guarantee does not apply to + * it — a `one` move removes the previous membership from valid-time reads + * entirely, at every coordinate before the move, which is what deleting a + * `one` binding means. Either way a second clock read would open a real gap + * (the first on any clock, the second on a non-monotonic one — issue #242's + * failure mode), and neither is fenceable: each write is legal at the instant + * it samples. + * + * Attaching to the whole the part already holds (through the same realizing + * edge) is accepted as a NO-OP (no write, no history), not refused: reparent + * states a destination, and a caller converging on one should not have to + * first ask where the part is. A stated `attachment.props` is still checked + * on this no-op (`assertSatisfiedAttachmentHonored`) — schema-invalid + * refuses as it would on a fresh attach, and valid-but-different from the + * edge's live stored props refuses with `situation: "props"` rather than + * being silently kept, since no write happens here to apply it. + * + * `onIncumbent` is stated by the CALLER, never defaulted: this is also the + * write plan the get-or-create `partOf` postcondition runs + * ({@link applyExistingPartOfPostcondition}), and the only difference between + * "move this part" and "make sure this part holds this whole" is what a + * DIFFERENT incumbent means — `"replace"` for `nodes..reparent(...)`, + * `"refuse"` for a lookup. Everything else (the fence, the locked re-read, + * the single move instant, the retire's population rule, the final-state + * validation) is one owner: + * {@link applyCompositionAttachmentsUnderFence}. + */ +export async function executeNodeReparent( + ctx: NodeOperationContext, + kind: string, + id: string, + options: NodeReparentOptions, + backend: GraphBackend | TransactionBackend, + disposition: Readonly<{ onIncumbent: CompositionIncumbentDisposition }>, +): Promise { + const [result] = await reparentParts( + ctx, + kind, + [{ id, options }], + backend, + disposition, + { kind: "single", id }, + ); + return requireDefined( + result, + "reparentParts returned no result for a single reparent", + ); +} + +/** + * `bulkReparent`: every move decided and prepared before the first write, + * in one frame. Like every other batch method it fires no operation hooks — + * a hook names one entity, and a batch is not one — so a caller observing + * each move calls `reparent` per part. + */ +export async function executeNodeReparentBatch( + ctx: NodeOperationContext, + kind: string, + items: readonly Readonly<{ id: string; options: NodeReparentOptions }>[], + backend: GraphBackend | TransactionBackend, + disposition: Readonly<{ onIncumbent: CompositionIncumbentDisposition }>, +): Promise { + return reparentParts(ctx, kind, items, backend, disposition, { + kind: "batch", + }); +} + +/** + * Refuses a reparent of a part with no live row — absent or tombstoned — with + * the `NodeNotFoundError` every reparent surface reports, both at the + * lock-free gate and at the re-read under the lock. + */ +async function assertLiveNodeRow( + reader: Readonly>, + graphId: string, + kind: string, + id: string, +): Promise { + const row = await reader.getNode(graphId, kind, id); + if (!isLiveNodeRowPresent(row)) throw new NodeNotFoundError(kind, id); +} + +/** + * Which surface a reparent frame serves, and so whether it fires operation + * hooks: the single-part `reparent` does, for its one part; a batch does not. + */ +type ReparentSurface = + Readonly<{ kind: "single"; id: string }> | Readonly<{ kind: "batch" }>; + +async function reparentParts( + ctx: NodeOperationContext, + kind: string, + items: readonly Readonly<{ id: string; options: NodeReparentOptions }>[], + backend: GraphBackend | TransactionBackend, + disposition: Readonly<{ onIncumbent: CompositionIncumbentDisposition }>, + surface: ReparentSurface, +): Promise { + if (items.length === 0) return []; + if (!ctx.registry.isCompositionPart(kind)) { + throw new ConfigurationError( + `Node kind "${kind}" is not a composition part: it declares no partOf/hasPart pair toward any whole.`, + { code: "COMPOSITION_NOT_A_PART", partKind: kind }, + { + suggestion: `Declare \`partOf(${kind}, , { via: ... })\` (or the mirrored \`hasPart\`) in the ontology, or move the relationship with an ordinary edge write.`, + }, + ); + } + const resolved = items.map((item) => { + const part = { kind, id: item.id }; + const { attachment, moveAt } = readReparentOptions(item.options, part); + return { + id: item.id, + moveAt, + request: resolveCompositionAttachmentRequest( + ctx.registry, + part, + attachment, + disposition.onIncumbent, + ), + }; + }); + const seenIds = new Set(); + for (const item of resolved) { + // One part holds one whole, so two moves of one part in one batch have no + // final state to judge. + if (seenIds.has(item.id)) { + throw new ValidationError( + `bulkReparent requires distinct part ids; received "${item.id}" more than once.`, + { + entityType: "node", + kind, + operation: "update", + id: item.id, + issues: [], + }, + { + suggestion: + "List each part once, with the whole it should end up under.", + }, + ); + } + seenIds.add(item.id); + } + const gates = await readNodeRowsByRef( + backend, + ctx.graphId, + resolved.map((item) => ({ kind, id: item.id })), + ctx.batchPointRead, + ); + for (const item of resolved) { + if (!isLiveNodeRowPresent(gates.get(nodeRefKey({ kind, id: item.id })))) { + throw new NodeNotFoundError(kind, item.id); + } + } + const plan = mixedWritePlan(COMPOSITION_EDGE_FENCE, false); + const rowWork: WriteRowWork<"mixed", readonly NodeReparentResult[]> = async ( + session, + target, + _overlaidSession, + lock, + ) => { + const applied = await applyCompositionAttachmentsUnderFence( + ctx, + session, + target, + lock, + "nodes.reparent", + kind, + resolved, + ); + return applied.map((result) => ({ + moved: result.wrote, + edge: requireDefined( + result.edge, + "a reparent that did not refuse produced no holding edge", + ), + })); + }; + const planOptions = { + didWrite: (results: readonly NodeReparentResult[]) => + results.some((result) => result.moved), + }; + return surface.kind === "single" ? + runHookedWritePlan( + nodeWritePlanContext(ctx), + ctx.createOperationContext("update", "node", kind, surface.id), + plan, + backend, + rowWork, + planOptions, + ) + : runWritePlan( + nodeWritePlanContext(ctx), + plan, + backend, + rowWork, + planOptions, + ); +} + +/** + * THE write plan one attachment runs when it is the frame's ONLY work: + * `reparent`'s own surface ({@link executeNodeReparent}) and the + * get-or-create `partOf` postcondition + * ({@link applyExistingPartOfPostcondition}) both reach it with a request + * their caller already resolved, so neither re-resolves the pair the other + * one just decided. The get-or-create `ifExists: "update"` / resurrection leg + * does NOT come here: it owes a property update in the same transaction, so + * it runs the two halves of the attachment around that update itself + * ({@link executeNodeUpsertUpdate}). + */ +async function runCompositionAttachmentWritePlan( + ctx: NodeOperationContext, + kind: string, + id: string, + request: CompositionAttachmentRequest, + backend: GraphBackend | TransactionBackend, +): Promise { + await assertLiveNodeRow(backend, ctx.graphId, kind, id); + + const opContext = ctx.createOperationContext("update", "node", kind, id); + return runHookedWritePlan( + nodeWritePlanContext(ctx), + opContext, + // `entity: "mixed"`: this frame writes only edges, but it writes TWO of + // them through a session that must be able to reach both surfaces (the + // retire uses the edge session, the attach the node frame's composition + // helper). The probe is the composition edge's own — a backend that + // cannot hold the fence refuses the move rather than retiring an + // attachment it cannot replace. + mixedWritePlan(COMPOSITION_EDGE_FENCE, false), + backend, + async (session, target, _overlaidSession, lock) => { + const [applied] = await applyCompositionAttachmentsUnderFence( + ctx, + session, + target, + lock, + "nodes.attach", + kind, + [{ id, request, moveAt: undefined }], + ); + return requireDefined( + applied, + "writeCompositionAttachmentMoves returned no result for its one move", + ); + }, + { didWrite: (applied) => applied.wrote }, + ); +} + +async function executeNodeCreateInternal( + ctx: NodeOperationContext, + input: CreateNodeInput, + backend: GraphBackend | TransactionBackend, + options?: Readonly<{ returnRow?: boolean }> & NodeCreateInternalOptions, +): Promise { + const kind = input.kind; + const id = input.id ?? generateId(); + // Synchronous and read-free — throws BEFORE any row is touched + // for the two refusal arms (required-existence with no `partOf`; a + // `partOf` naming an undeclared pair), which is what makes cases where no + // node row survives provable rather than merely likely. + const compositionWork = resolveCompositionCreate( + ctx.registry, + input, + backend, + ); + const opContext = ctx.createOperationContext("create", "node", kind, id); + const shouldReturnRow = options?.returnRow ?? true; + const autocommitBackend = + isBundledRootAutocommitEligible(backend) ? backend : undefined; + const registeredKind = + hasOwnKey(ctx.graph.nodes, kind) ? + getNodeRegistration(ctx.graph, kind) + : undefined; + const candidate = + registeredKind === undefined ? undefined : ( + ({ + backend, + schemaVersion: ctx.schemaVersion, + historyEnabled: ctx.historyEnabled, + revisionTrackingEnabled: ctx.revisionTrackingEnabled, + identityEnabled: ctx.identity !== undefined, + idGenerated: input.id === undefined, + kindRegistered: true, + uniqueConstraintCount: registeredKind.unique?.length ?? 0, + disjointKindCount: ctx.registry.getDisjointKinds(kind).length, + schema: registeredKind.type.schema, + } as const) + ); + const schemaFenceInFirstWrite = + candidate !== undefined && + canFuseSchemaFenceInFirstWrite({ kind: "node", candidate }); + // A composition create writes a second row (the edge) that must + // land in the SAME transaction as the node — never a candidate for a + // single-statement autocommit write, which has no transaction to share. + const autocommitSingleStatement = + compositionWork === undefined && + autocommitBackend !== undefined && + candidate !== undefined && + isAutocommitSingleStatementWrite({ kind: "node", candidate }); + // A mixed plan unconditionally — `entity` only widens the + // STATIC session type `rowWork` receives (`createWriteSession` always + // mints the full node+edge session; see `write-executor.ts`'s + // `planFrame`), so this has no runtime effect on the ordinary, + // no-`partOf` create. When this create owes a composition edge, the + // constraint probe folds the node's own with the edge's + // ({@link COMPOSITION_EDGE_FENCE}), so a backend that cannot hold + // the fence refuses the WHOLE create, naming the composition declaration, + // rather than writing a node it cannot attach. + const plan = mixedBatchWritePlan( + [ + nodeFencesConstraintProbe(ctx, kind, "create"), + compositionWork === undefined ? undefined : COMPOSITION_EDGE_FENCE, + ], + nodeCreateRequiresIdentityLock(ctx, input), + ); + + const rowWork = async ( + session: WriteSession, + target: WriteTarget, + _overlaidSession: OverlaidSessionMint<"mixed">, + lock: GraphWriteLock, + transactionMode: WriteTransactionMode, + ): Promise => { + // The outer backend's mark chooses the optimistic plan, but a custom + // transaction wrapper can replace its callback target. Re-check the + // factory-owned origin at the actual write receiver before letting that + // receiver carry the schema fence; otherwise a wrapper that dropped the + // ordinary diagnostic fence could silently write a verified store. + const targetBackend = unfencedTarget(target); + // A composition create declines EVERY fused single-statement + // shape (schema-fence fusion, projection fusion) — none has a slot for + // the second row this write also owes, and a fused command is an + // optimization attempt, not evidence its dimensions ran (the same + // principle the BATCH fused programs follow, generalized here to this + // function's own single-create fusions). It still takes the + // ordinary portable schema-version lock below when the kind is + // schema-fenced. + const fuseSchemaFenceInFirstWrite = + compositionWork === undefined && + schemaFenceInFirstWrite && + isSchemaFencedInsertEligible(targetBackend) && + !hasLeasedSchemaFence(ctx, targetBackend); + if (schemaFenceInFirstWrite && !fuseSchemaFenceInFirstWrite) { + await lockSchemaVersionForStoreWrite(ctx, targetBackend); + } + const draft = draftNodeCreate(ctx, input, id, options); + const claimPlan = planNodeCreateClaims( + { graphId: ctx.graphId, registry: ctx.registry }, + { + kind: draft.kind, + id: draft.id, + props: draft.validatedProps, + constraints: draft.uniqueConstraints, + }, + ); + const projections = resolveNodeInsertProjections( + draft.nodeKind.schema, + draft.validatedProps, + ); + const preparationMode: NodeCreatePreparationMode = + ( + shouldReturnRow && + !fuseSchemaFenceInFirstWrite && + supportsNodeCreatePlan(target, { + params: buildInsertNodeParams( + ctx.graphId, + draft.kind, + draft.id, + draft.validatedProps, + draft.validFrom, + draft.validTo, + ), + idGenerated: !draft.idProvided, + mode: { kind: "ordinary" }, claims: claimPlan.claims, projections, allowNonTransactionalClaims: @@ -2699,16 +5412,25 @@ async function executeNodeCreateInternal( ) ? "authoritative-plan" : "probe"; + // A create that owes a composition edge learns whether its id is taken + // from the read, not from the insert: the edge is prepared before the + // node row is written, and against a live incumbent of the same id that + // preparation would find the incumbent's own attachment and report a + // composition refusal for what is an id collision. const prepared = await finishNodeCreatePreparation( ctx, draft, target, - true, + compositionWork === undefined, preparationMode, claimPlan, ); + const foldCreated = await prepareNodeCreatesFold(ctx, target, [prepared]); const projectionFusionEligible = - shouldReturnRow && !prepared.idProvided && projections.length > 0; + compositionWork === undefined && + shouldReturnRow && + !prepared.idProvided && + projections.length > 0; const fuseProjections = projectionFusionEligible && supportsNodeInsertProjections(target, projections); @@ -2717,6 +5439,26 @@ async function executeNodeCreateInternal( projectionFusionEligible && supportsNodeInsertProjections(target, projections); + // Every refusal the composition edge can reach, before this frame's + // first statement — see `prepareCompositionEdgeForCreate`. The realizing + // edge's window is the attachment's own (`compositionWork.edgeWindow`), + // never the part node's insert params. + const preparedCompositionEdge = await prepareCompositionEdgeForCreate( + ctx, + target, + lock, + compositionWork, + { kind: prepared.kind, id }, + ); + const attachCompositionEdge = async (): Promise => { + if (preparedCompositionEdge === undefined) return; + await insertPreparedCompositionEdge( + ctx, + session, + preparedCompositionEdge, + ); + }; + const existing = prepared.tombstone; if (existing !== undefined) { const resurrected = await resurrectPreparedNode( @@ -2725,9 +5467,8 @@ async function executeNodeCreateInternal( target, prepared, ); - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences([prepared])); - } + await foldCreated("restore"); + await attachCompositionEdge(); return shouldReturnRow ? rowToNode(resurrected) : undefined; } @@ -2806,9 +5547,8 @@ async function executeNodeCreateInternal( await session.createNodeIfAbsent(nodeCreateWork(prepared)) ); if (inserted !== undefined) { - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences([prepared])); - } + await foldCreated("fold"); + await attachCompositionEdge(); return shouldReturnRow ? rowToNode(inserted) : undefined; } @@ -2852,9 +5592,8 @@ async function executeNodeCreateInternal( target, prepared, ); - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences([prepared])); - } + await foldCreated("restore"); + await attachCompositionEdge(); return shouldReturnRow ? rowToNode(resurrected) : undefined; } @@ -2885,9 +5624,9 @@ async function executeNodeCreateInternal( return; }); - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences([prepared])); - } + await foldCreated("fold"); + + await attachCompositionEdge(); if (row === undefined) return; return rowToNode(row); @@ -2956,6 +5695,9 @@ export async function executeNodeCreateNoReturnBatch( ): Promise { if (inputs.length === 0) return; + // See `resolveBatchCompositionWorks`'s docblock. + const compositionWorks = resolveBatchCompositionWorks(ctx, inputs, backend); + const atomicExecutor = resolveAtomicNodeBatchExecutor({ backend, graph: ctx.graph, @@ -3017,16 +5759,38 @@ export async function executeNodeCreateNoReturnBatch( await runWritePlan( nodeWritePlanContext(ctx), - nodeBatchWritePlan( - nodeBatchConstraintProbes(ctx, inputs, "create"), + mixedBatchWritePlan( + [ + ...nodeBatchConstraintProbes(ctx, inputs, "create"), + ...compositionBatchConstraintProbes(compositionWorks), + ], nodeBatchCreateRequiresIdentityLock(ctx, inputs), ), backend, - async (session, target) => { - const identity = ctx.identity; + async (session, target, _overlaidSession, lock) => { const preparedCreates = await prepareBatchCreates(ctx, inputs, target); - const partition = partitionCreates(preparedCreates); + const foldInserts = await prepareNodeCreatesFold( + ctx, + target, + partition.inserts, + ); + const foldResurrections = await prepareNodeCreatesFold( + ctx, + target, + partition.resurrections, + ); + + // See `prepareBatchCompositionCreateEdges`'s docblock. + const preparedCompositionEdges = await prepareBatchCompositionCreateEdges( + ctx, + target, + lock, + "nodes.bulkInsert", + preparedCreates, + compositionWorks, + ); + // ## Resurrections follow the whole insert unit // // The batch INSERT, its claim groups and its sidecar batch are ONE session @@ -3048,9 +5812,13 @@ export async function executeNodeCreateNoReturnBatch( for (const prepared of partition.resurrections) { await resurrectPreparedNode(ctx, session, target, prepared); } - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences(preparedCreates)); - } + await foldInserts("fold"); + await foldResurrections("restore"); + await insertPreparedCompositionEdges( + ctx, + session, + preparedCompositionEdges, + ); }, { didWrite: writeResultAlwaysChanges }, ); @@ -3069,20 +5837,30 @@ export async function executeNodeCreateBatch( ctx: NodeOperationContext, inputs: readonly CreateNodeInput[], backend: GraphBackend | TransactionBackend, - options?: NodeCreateInternalOptions, + options?: NodeCreateBatchOptions, ): Promise { if (inputs.length === 0) return []; - const atomicExecutor = resolveAtomicNodeBatchExecutor({ - backend, - graph: ctx.graph, - registry: ctx.registry, - inputs, - schemaVersion: ctx.schemaVersion, - identityEnabled: ctx.identity !== undefined, - historyEnabled: ctx.historyEnabled, - revisionTrackingEnabled: ctx.revisionTrackingEnabled, - }); + // See `executeNodeCreateNoReturnBatch`'s identical preamble. + const compositionWorks = resolveBatchCompositionWorks(ctx, inputs, backend); + + const pairedUpdates = options?.pairedUpdates ?? []; + // The native program issues its inserts in the statement that decides + // them, leaving no point at which the paired updates could be decided + // first, so a batch that has any takes the portable frame. + const atomicExecutor = + pairedUpdates.length > 0 ? + undefined + : resolveAtomicNodeBatchExecutor({ + backend, + graph: ctx.graph, + registry: ctx.registry, + inputs, + schemaVersion: ctx.schemaVersion, + identityEnabled: ctx.identity !== undefined, + historyEnabled: ctx.historyEnabled, + revisionTrackingEnabled: ctx.revisionTrackingEnabled, + }); if (atomicExecutor !== undefined) { const preparedCreates = await prepareAtomicBatchCreates( @@ -3139,21 +5917,57 @@ export async function executeNodeCreateBatch( return runWritePlan( nodeWritePlanContext(ctx), - nodeBatchWritePlan( - nodeBatchConstraintProbes(ctx, inputs, "create"), + mixedBatchWritePlan( + [ + ...nodeBatchConstraintProbes(ctx, inputs, "create"), + ...compositionBatchConstraintProbes(compositionWorks), + // The paired updates are decided under this frame's fence. + ...nodeUpdateConstraintProbes(ctx, pairedUpdates), + ], nodeRequiresIdentityLock(ctx), ), backend, - async (session, target) => { - const identity = ctx.identity; + async (session, target, _overlaidSession, lock) => { const preparedCreates = await prepareBatchCreates( ctx, inputs, target, options, ); - const partition = partitionCreates(preparedCreates); + const foldInserts = await prepareNodeCreatesFold( + ctx, + target, + partition.inserts, + ); + const foldResurrections = await prepareNodeCreatesFold( + ctx, + target, + partition.resurrections, + ); + if (pairedUpdates.length > 0) { + await prepareNodeUpdateSet(ctx, pairedUpdates, target, lock, { + storedRows: new Map( + pairedUpdates.flatMap((entry) => + entry.existing === undefined ? + [] + : [[entry.input.id, entry.existing]], + ), + ), + pendingCreates: preparedCreates, + }); + } + + // See `prepareBatchCompositionCreateEdges`'s docblock. + const preparedCompositionEdges = await prepareBatchCompositionCreateEdges( + ctx, + target, + lock, + "nodes.bulkCreate", + preparedCreates, + compositionWorks, + ); + // One call for claims + row + sidecars, so the resurrections follow the // whole unit — same reasoning as {@link executeNodeCreateNoReturnBatch}. const inserted = await withAlreadyExistsTranslation("node", () => @@ -3169,19 +5983,23 @@ export async function executeNodeCreateBatch( } const byReference = new Map( [...inserted, ...resurrected].map((row) => [ - refKey({ kind: row.kind, id: row.id }), + nodeRefKey({ kind: row.kind, id: row.id }), row, ]), ); const rows = preparedCreates.map((prepared) => requireDefined( - byReference.get(refKey({ kind: prepared.kind, id: prepared.id })), + byReference.get(nodeRefKey({ kind: prepared.kind, id: prepared.id })), `Missing written row for ${prepared.kind} ${prepared.id}`, ), ); - if (identity !== undefined) { - await identity.foldCreated(target, foldReferences(preparedCreates)); - } + await foldInserts("fold"); + await foldResurrections("restore"); + await insertPreparedCompositionEdges( + ctx, + session, + preparedCompositionEdges, + ); return rows.map((row) => rowToNode(row)); }, @@ -3193,6 +6011,92 @@ export async function executeNodeCreateBatch( // Node Update Operations // ============================================================ +/** The one identity hook {@link applyIdentityWindowEnd} reads off a context. */ +export type IdentityWindowEndContext = Readonly<{ + identity?: Pick< + NonNullable["identity"]>, + "requireValidityEndCompatible" + >; +}>; + +/** Whether a node write states a new valid-time window end — set or cleared. */ +function nodeWriteMovesWindowEnd( + input: Readonly<{ validTo?: string; clearValidTo?: true }>, +): boolean { + return input.validTo !== undefined || input.clearValidTo === true; +} + +/** + * Whether identity participates in an update of an existing node: when it + * RESURRECTS (the restored row folds), and when it moves its validity end (an + * end reads the identity's other members). A live-row update cannot change a + * node's kind, so nothing else folds. + */ +function nodeUpdateTouchesIdentity( + input: Readonly<{ validTo?: string; clearValidTo?: true }>, + clearDeleted: boolean, +): boolean { + return clearDeleted || nodeWriteMovesWindowEnd(input); +} + +/** + * The identity half of a node write that moves its own window end, inside the + * write frame and before the row write: every update path that can carry + * `validTo` or `clearValidTo` runs this one owner — the store's own updates + * and interchange import's `onConflict: "update"` (through + * `StoreRuntime.applyImportedNodeWindowEnd`) — so a narrowed, widened or + * cleared end is refused or noted the same way whichever path writes it. + */ +export async function applyIdentityWindowEnd( + ctx: IdentityWindowEndContext, + target: IdentityTarget, + input: Readonly<{ + kind: string; + id: string; + validTo?: string; + clearValidTo?: true; + }>, +): Promise { + const validTo = validateOptionalCanonicalIsoDate(input.validTo, "validTo"); + const identity = ctx.identity; + if (identity === undefined || !nodeWriteMovesWindowEnd(input)) return; + await identity.requireValidityEndCompatible( + target, + { kind: input.kind, id: input.id }, + validTo, + ); +} + +/** + * A node row write that moves its window end, with its identity half, as one + * unit: {@link applyIdentityWindowEnd} refuses or notes, then `writeRow` runs. + * The note describes that write, so it stands only if the write does: when + * `writeRow` is refused the note is withdrawn before the refusal propagates. + * A caller that catches the refusal inside `store.transaction(...)` and + * commits therefore records no `window-end` transition, and allocates no + * recorded revision, for an update that never happened. + * + * `writeRow` covers every statement up to and including the row write, so a + * refusal any of them raises withdraws the note too. + */ +async function writeNodeRowMovingWindowEnd( + ctx: NodeOperationContext, + target: IdentityTarget, + input: Parameters[2], + writeRow: () => Promise, +): Promise { + const withdrawWindowEndNote = await collectRecordedIdentityTransitionNotes( + target, + () => applyIdentityWindowEnd(ctx, target, input), + ); + try { + return await writeRow(); + } catch (error) { + withdrawWindowEndNote(); + throw error; + } +} + function resolveAtomicNodeUpdateExecutor( ctx: NodeOperationContext, entries: readonly NodeUpsertUpdateBatchEntry[], @@ -3273,41 +6177,37 @@ export async function executeNodeUpdate( return runHookedWritePlan( nodeWritePlanContext(ctx), opContext, - nodeWritePlan( - nodeFencesConstraintProbe(ctx, input.kind, "update"), - // Identity participates in an update when it RESURRECTS, and when it - // states a validity end: a live-row update cannot change a node's kind, so - // nothing folds, but an end reads the identity assertions that touch it. - options?.clearDeleted === true || input.validTo !== undefined ? - nodeRequiresIdentityLock(ctx) - : false, + nodeBatchWritePlan( + nodeUpdateConstraintProbes(ctx, [atomicEntry]), + nodeUpdateTouchesIdentity(input, options?.clearDeleted === true) && + nodeRequiresIdentityLock(ctx), ), backend, - async (session, target) => { - const validTo = validateOptionalCanonicalIsoDate( - input.validTo, - "validTo", - ); - const identity = ctx.identity; - if (identity !== undefined && validTo !== undefined) { - await identity.requireValidityEndCompatible( + async (session, target, _overlaidSession, lock) => { + const restoring = options?.clearDeleted === true; + if (restoring) { + await assertRestoredRequiredPartsAttached( + { graphId: ctx.graphId, registry: ctx.registry, lock }, target, - { kind: input.kind, id: input.id }, - validTo, + [{ kind: input.kind, id: input.id }], ); } - const node = await performNodeUpdateWithResurrectionRecovery( - ctx, - input, - session, - target, - options, + const foldRestored = + restoring ? + await prepareNodeFold(ctx, target, [ + { kind: input.kind, id: input.id }, + ]) + : undefined; + const node = await writeNodeRowMovingWindowEnd(ctx, target, input, () => + performNodeUpdateWithResurrectionRecovery( + ctx, + input, + session, + target, + options, + ), ); - if (options?.clearDeleted && identity !== undefined) { - await identity.foldCreated(target, [ - { kind: input.kind, id: input.id }, - ]); - } + await foldRestored?.("restore"); return node; }, { didWrite: writeResultAlwaysChanges }, @@ -3629,47 +6529,109 @@ export async function executeNodeUpsertUpdate( ctx: NodeOperationContext, input: UpsertUpdateNodeInput, backend: GraphBackend | TransactionBackend, - options?: Readonly<{ clearDeleted?: boolean }>, + options?: Readonly<{ + clearDeleted?: boolean; + /** + * Present only from the get-or-create entries' existing-row + * leg (`executeNodeGetOrCreateByConstraint` and its bulk twin): the + * stated `partOf` is decided, prepared, and written in the SAME + * transaction as this property update, reads first and writes last + * (decide, prepare, update, insert). A refusal from any read leaves + * nothing written, and a refused update never reaches the insert — see + * {@link prepareCompositionAttachmentDecision} for why the order is + * load-bearing. The request is resolved with `onIncumbent: "refuse"`, so + * this leg attaches or is satisfied; a move is `reparent`'s decision. + */ + compositionAttachment?: CompositionAttachmentRequest; + }>, ): Promise { if (input.clearValidTo === true) { assertClearValidToSupported(backend, "node"); } + const compositionAttachment = options?.compositionAttachment; return runWritePlan( nodeWritePlanContext(ctx), - nodeWritePlan( - nodeFencesConstraintProbe(ctx, input.kind, "update"), - // Conditional for the same reason as {@link executeNodeUpdate}: a - // resurrecting upsert folds, and stating a validity end reads the - // identity's other members, so both take the lock. - options?.clearDeleted === true || input.validTo !== undefined ? - nodeRequiresIdentityLock(ctx) - : false, + // A mixed plan unconditionally — see `executeNodeCreateInternal`'s + // identical note: `entity` only widens the STATIC session type, with no + // runtime effect on a call that carries no `compositionAttachment`. + mixedBatchWritePlan( + [ + ...nodeUpdateConstraintProbes(ctx, [ + { input, clearDeleted: options?.clearDeleted === true }, + ]), + compositionAttachment === undefined ? undefined : ( + COMPOSITION_EDGE_FENCE + ), + ], + nodeUpdateTouchesIdentity(input, options?.clearDeleted === true) && + nodeRequiresIdentityLock(ctx), ), backend, - async (session, target) => { - const validTo = validateOptionalCanonicalIsoDate( - input.validTo, - "validTo", - ); - const identity = ctx.identity; - if (identity !== undefined && validTo !== undefined) { - await identity.requireValidityEndCompatible( + async (session, target, _overlaidSession, lock) => { + // A resurrection that states an attachment is decided below; one that + // states none may only restore a part that still holds a live whole. + if ( + options?.clearDeleted === true && + compositionAttachment === undefined + ) { + await assertRestoredRequiredPartsAttached( + { graphId: ctx.graphId, registry: ctx.registry, lock }, target, - { kind: input.kind, id: input.id }, - validTo, + [{ kind: input.kind, id: input.id }], ); } - const node = await performNodeUpdateWithResurrectionRecovery( + const restoresPartRow = options?.clearDeleted === true; + const foldRestored = + restoresPartRow ? + await prepareNodeFold(ctx, target, [ + { kind: input.kind, id: input.id }, + ]) + : undefined; + const { node, preparedAttachment } = await writeNodeRowMovingWindowEnd( ctx, - input, - session, target, - options, + input, + async () => { + // Reads first, then writes — `prepareCompositionAttachmentDecision` + // owns the reason. + const decided = + compositionAttachment === undefined ? undefined : ( + await decideCompositionAttachmentUnderFence( + ctx.registry, + target, + ctx.graphId, + input.id, + compositionAttachment, + lock, + { partRowRestoredByUpdate: restoresPartRow }, + ) + ); + const prepared = + decided === undefined ? undefined : ( + await prepareCompositionAttachmentDecision( + ctx, + target, + lock, + input.id, + decided, + { partRowRestoredByUpdate: restoresPartRow }, + ) + ); + return { + preparedAttachment: prepared, + node: await performNodeUpdateWithResurrectionRecovery( + ctx, + input, + session, + target, + options, + ), + }; + }, ); - if (options?.clearDeleted && identity !== undefined) { - await identity.foldCreated(target, [ - { kind: input.kind, id: input.id }, - ]); + await foldRestored?.("restore"); + if (preparedAttachment !== undefined) { + await insertPreparedCompositionEdge(ctx, session, preparedAttachment); } return node; }, @@ -3827,14 +6789,17 @@ export async function executeNodeUpsertUpdateBatch( return runWritePlan( nodeWritePlanContext(ctx), - nodeWritePlan( - nodeFencesConstraintProbe(ctx, first.input.kind, "update"), - entries.some( - (entry) => entry.clearDeleted || entry.input.validTo !== undefined, + nodeBatchWritePlan( + nodeUpdateConstraintProbes(ctx, entries), + entries.some((entry) => + nodeUpdateTouchesIdentity(entry.input, entry.clearDeleted), ) && nodeRequiresIdentityLock(ctx), ), backend, - async (session, target) => { + async (session, target, _overlaidSession, lock) => { + // A restoring entry never takes the resolved batch statement below; the + // row-by-row writer judges it, with every other entry, before its + // first write (`prepareNodeUpdateSet`). const resolvedRows = ( target.capabilities.execution.interactiveTransactions && @@ -3865,7 +6830,7 @@ export async function executeNodeUpsertUpdateBatch( if (canBatchResolvedUpdates) { const resolvedEntries = entries.map((entry) => { const existing = resolvedRows.get(entry.input.id); - if (existing === undefined || !isLiveNodeRow(existing)) { + if (!isLiveNodeRowPresent(existing)) { throw new NodeNotFoundError(entry.input.kind, entry.input.id); } const props = @@ -3923,32 +6888,14 @@ export async function executeNodeUpsertUpdateBatch( // from the rows that are CURRENT now: carrying the old preimage would // turn a partial upsert into a stale full replacement. const fallbackRows = batchMissed ? undefined : resolvedRows; - const nodes: Node[] = []; - for (const entry of entries) { - nodes.push( - await performNodeUpdateWithResurrectionRecovery( - ctx, - entry.input, - session, - target, - entry.clearDeleted || entry.replacementProps !== undefined ? - { - ...(entry.clearDeleted ? { clearDeleted: true } : {}), - ...(entry.replacementProps === undefined ? - {} - : { replacementProps: entry.replacementProps }), - } - : undefined, - fallbackRows?.get(entry.input.id), - ), - ); - if (entry.clearDeleted && ctx.identity !== undefined) { - await ctx.identity.foldCreated(target, [ - { kind: entry.input.kind, id: entry.input.id }, - ]); - } - } - return nodes; + return writeNodeUpdateSet( + ctx, + entries, + session, + target, + lock, + fallbackRows, + ); }, { didWrite: writeResultAlwaysChanges }, ); @@ -4062,12 +7009,73 @@ async function executeAtomicNodeResolvedUpdates( // Node Delete Operations // ============================================================ +/** + * One node delete's row-work result: whether it wrote, and which composition + * parts its cascade removed. + * + * Carried on the RESULT rather than captured in a mutable local, because + * `runInWriteTransaction` may retry the whole frame under the + * `"optimistic-retry"` tier: a local would accumulate a rolled-back + * attempt's parts alongside the surviving attempt's, while the result is + * always the last attempt's alone. + */ +type NodeDeleteOutcome = Readonly<{ + wrote: boolean; + /** Leaf-first, empty when the kind declares no composition parts. */ + cascadedParts: readonly CompositionCascadedPart[]; +}>; + +const NODE_DELETE_NOT_WRITTEN: NodeDeleteOutcome = { + wrote: false, + cascadedParts: [], +}; + +/** + * One batch node delete's row-work result: how many items it actually + * retired, and every composition part their cascades removed, leaf-first + * within each item and in the batch's own item order. + * + * On the RESULT for {@link NodeDeleteOutcome}'s reason — an + * `"optimistic-retry"` replay of the frame must report the surviving + * attempt's parts alone. + */ +type NodeDeleteBatchOutcome = Readonly<{ + affectedCount: number; + cascadedParts: readonly CompositionCascadedPart[]; +}>; + +function nodeDeleteWrote(outcome: NodeDeleteOutcome): boolean { + return outcome.wrote; +} + +/** + * THE delete-operation facts its `onOperationEnd` context carries — see + * {@link OperationOutcomeFacts}. Shared by the soft and hard single-delete + * paths so the two cannot report the cascade differently. + */ +function nodeDeleteOperationFacts( + outcome: NodeDeleteOutcome, +): OperationOutcomeFacts { + return { cascadedParts: outcome.cascadedParts }; +} + +/** + * Soft-deletes one node, cascading through its composition parts. + * + * `request.policy` is an internal caller's narrowed enforcement (merge apply, + * provenance). `request.expect` is the public `delete(id, { expect })` guard: + * the delete's own plan, read in its frame under its lock, must equal it, or + * the delete refuses before its first write — an absent or tombstoned node + * included, which is otherwise a no-op. + */ export async function executeNodeDelete( ctx: NodeOperationContext, kind: string, id: string, backend: GraphBackend | TransactionBackend, + request: NodeDeleteRequest = {}, ): Promise { + const { policy, expect } = request; // Gate outside hooks and execution (matching edge deletes): an absent or // already-tombstoned node is a no-op, so it neither fires hooks nor submits // a write. Eligible plain deletes carry the live/restrict/schema verdicts in @@ -4075,21 +7083,38 @@ export async function executeNodeDelete( // so a node concurrently deleted between this gate and the write lock is // still handled correctly. const gate = await backend.getNode(ctx.graphId, kind, id); - if (!gate || gate.deleted_at) return; + if (!gate || gate.deleted_at) { + if (expect !== undefined) { + assertNodeDeletePlanExpected({ kind, id }, expect, undefined); + } + return; + } const opContext = ctx.createOperationContext("delete", "node", kind, id); - const atomicExecutor = resolveAtomicNodeDeleteBatchExecutor({ - backend, - graph: ctx.graph, - kind, - ids: [id], - schemaVersion: ctx.schemaVersion, - identityEnabled: ctx.identity !== undefined, - registry: ctx.registry, - historyEnabled: ctx.historyEnabled, - revisionTrackingEnabled: ctx.revisionTrackingEnabled, - }); + // The fused atomic executor has no notion of `policy` or `expect`: its + // restrict check is a single, read-free SQL shape that cannot narrow the + // edges it counts, cannot skip its own enforcement, and plans nothing to + // compare. A request stating a dimension it cannot honor therefore always + // takes the portable path below, the only path that reads and honors them + // — the fused command is an optimization attempt, not evidence its + // dimensions ran, and must not be reached when a dimension it cannot honor + // is in play. `nodeDeleteRequiresPortablePath` is the one owner of that + // decision across every request dimension. + const atomicExecutor = + nodeDeleteRequiresPortablePath(request) ? undefined : ( + resolveAtomicNodeDeleteBatchExecutor({ + backend, + graph: ctx.graph, + kind, + ids: [id], + schemaVersion: ctx.schemaVersion, + identityEnabled: ctx.identity !== undefined, + registry: ctx.registry, + historyEnabled: ctx.historyEnabled, + revisionTrackingEnabled: ctx.revisionTrackingEnabled, + }) + ); if (atomicExecutor !== undefined) { await runAtomicProgramWithHooks( ctx, @@ -4100,14 +7125,20 @@ export async function executeNodeDelete( return; } - await runHookedWritePlan( + const outcome = await runHookedWritePlan( nodeWritePlanContext(ctx), opContext, - nodeWritePlan(undefined, nodeRequiresIdentityLock(ctx)), + nodeWritePlan( + nodeDeleteConstraintProbe(ctx, kind), + nodeRequiresIdentityLock(ctx), + ), backend, - async (session, target) => { - const identity = ctx.identity; - const registration = getNodeRegistration(ctx.graph, kind); + async ( + session, + target, + _overlaidSession, + lock, + ): Promise => { // This preflight is NOT removable round-trip fat: the soft-delete // pipeline consumes the pre-image (uniqueness entries are keyed by // props-derived constraint keys), and this in-transaction read is @@ -4121,24 +7152,186 @@ export async function executeNodeDelete( id, "delete", ); - if (!preflight || !isLiveNodeRow(preflight)) return false; + if (!isLiveNodeRowPresent(preflight)) { + if (expect !== undefined) { + assertNodeDeletePlanExpected({ kind, id }, expect, undefined); + } + return NODE_DELETE_NOT_WRITTEN; + } - // The cascade (connected edges, uniques, embeddings, fulltext, node) is - // not individually atomic, so it runs in one write transaction. Under - // recorded-time capture this also collapses the cascade into a single - // recorded commit instant instead of one instant per sub-write. - await session.retireNode({ - existing: preflight, - schema: registration.type.schema, - uniqueConstraints: registration.unique ?? [], - onDelete: registration.onDelete, - }); - if (identity !== undefined) { - await identity.detachDeleted(target, { kind, id }, "soft"); + // Composition parts, leaf-first, BEFORE the whole itself — under the + // per-graph write lock this write plan already fenced for a + // composition whole (`nodeDeleteConstraintProbe`). A guarded delete + // judges its root here too: the root's own removed edges belong to the + // plan it compares, and the comparison must precede every write. + const effects = noPlannedDeleteEffects(); + const cascadePlan = await planCascadingNodeDelete( + ctx, + { kind, id }, + target, + lock, + policy, + effects, + { judgeRoot: expect !== undefined, lockMembers: true }, + ); + if (expect !== undefined) { + assertNodeDeletePlanExpected( + { kind, id }, + expect, + nodeDeletePlanOf(cascadePlan, effects), + ); + } + return { + wrote: true, + cascadedParts: await deleteRootWithCascade( + ctx, + session, + target, + "soft", + { kind, id }, + policy, + cascadePlan, + preflight, + ), + }; + }, + { + didWrite: nodeDeleteWrote, + operationFacts: nodeDeleteOperationFacts, + }, + ); + ctx.recordCascadedParts?.(outcome.cascadedParts); +} + +/** + * What {@link executeNodeDelete} would do to one live node, without writing: + * the read half of its portable frame — the live-row gate, then + * {@link planCascadingNodeDelete} with the root always judged — and none of + * the writes. `undefined` for an absent or tombstoned node, where the delete + * is a no-op. A refusal the planner reaches propagates as the delete's own + * error. + * + * Inside a transaction the caller owns (`"existing"`), the plan is read in a + * write frame under the delete's own plan: the same constraint fence, so the + * per-graph write lock a composition whole's delete takes is taken here and + * held to the end of the caller's transaction. The frame reports + * `didWrite: false`, so it advances no revision. Identity is not locked: the + * plan folds nothing. Anywhere else there is no transaction to hold that lock + * in, so the plan is a best-effort unlocked read, as merge plan time reads + * the cascade (`uncapturedGraphWriteLock`). + * + * That lock is a write (on PostgreSQL a composition whole's delete advances + * the per-graph fence row), so a read-only transaction refuses the preview + * before any statement, on every backend, rather than leave the engine to + * reject it for some kinds and dialects only. + */ +export async function executeNodeDeletePlan( + ctx: NodeOperationContext, + kind: string, + id: string, + backend: GraphBackend | TransactionBackend, +): Promise { + if (ctx.readOnlyTransaction) { + refuseReadOnlyTransactionWrite( + `tx.nodes.${kind}.planDelete()`, + ctx.graphId, + `Call store.nodes.${kind}.planDelete(id) outside the transaction for an unlocked preview, or remove accessMode: "read_only" so the preview holds the delete's lock.`, + ); + } + if (resolveWriteTransactionMode(backend) !== "existing") { + return planLiveNodeDelete( + ctx, + kind, + id, + backend, + uncapturedGraphWriteLock(), + ); + } + return runWritePlan( + nodeWritePlanContext(ctx), + nodeWritePlan(nodeDeleteConstraintProbe(ctx, kind), false), + backend, + (_session, target, _overlaidSession, lock) => + planLiveNodeDelete(ctx, kind, id, target, lock), + { didWrite: () => false }, + ); +} + +async function planLiveNodeDelete( + ctx: NodeOperationContext, + kind: string, + id: string, + target: WriteTarget, + lock: GraphWriteLock, +): Promise { + const existing = await target.getNode(ctx.graphId, kind, id); + if (!isLiveNodeRowPresent(existing)) return undefined; + const effects = noPlannedDeleteEffects(); + const cascadePlan = await planCascadingNodeDelete( + ctx, + { kind, id }, + target, + lock, + undefined, + effects, + { judgeRoot: true, lockMembers: false }, + ); + return nodeDeletePlanOf(cascadePlan, effects); +} + +/** + * THE projection of one delete's planning into a {@link NodeDeletePlan}: what + * `planDelete` answers and what a guarded delete compares against `expect`. + * `effects` must come from a {@link planCascadingNodeDelete} call that judged + * the root, so the root's own removed edges are in it. + */ +function nodeDeletePlanOf( + cascadePlan: CompositionCascadePlan, + effects: PlannedDeleteEffects, +): NodeDeletePlan { + return { + cascadedParts: cascadedPartReferences(cascadePlan), + removedEdgeIds: [...effects.removedEdgeIds].toSorted((left, right) => + compareStrings(left, right), + ), + }; +} + +/** + * Revives one soft-deleted node with its stored props, under the node's + * "update" operation hooks — a currency reopen (provenance), not a create: no + * input is validated and the validity window is left as it was. A no-op when + * the node is absent or live. + * + * @throws {UniquenessError} when a unique key the node held was taken by + * another node while it was tombstoned. + */ +export async function executeNodeRevive( + ctx: NodeOperationContext, + kind: string, + id: string, + backend: GraphBackend | TransactionBackend, +): Promise { + const gate = await backend.getNode(ctx.graphId, kind, id); + if (gate === undefined || !isTombstonedNodeRow(gate)) return; + + await runHookedWritePlan( + nodeWritePlanContext(ctx), + ctx.createOperationContext("update", "node", kind, id), + nodeWritePlan( + nodeFencesConstraintProbe(ctx, kind, "update"), + nodeRequiresIdentityLock(ctx), + ), + backend, + async (session, target): Promise => { + const existing = await target.getNode(ctx.graphId, kind, id); + if (existing === undefined || !isTombstonedNodeRow(existing)) { + return false; } + await reviveNodeRowInFrame(ctx, session, target, existing); return true; }, - { didWrite: booleanWriteResultChanges }, + { didWrite: (revived) => revived }, ); } @@ -4196,6 +7389,20 @@ async function findConnectedEdgesForNodeBatch( * Batch collection methods deliberately omit per-item hooks for throughput. * Owning the write transaction here also prevents a per-item success from * being reported before the batch's outer COMMIT. + * + * Takes no {@link NodeDeletePolicy} — every item's delete-behavior + * enforcement always runs unnarrowed by a caller-supplied + * `consumedEdgeIds` — but a composition whole in the batch still cascades to + * its own parts: `applyCompositionCascade` per item, under the one write + * lock this batch's plan fences for when `kind` declares composition parts. + * + * Every item's cascaded parts are reported to the transaction receipt + * (`ctx.recordCascadedParts`), accumulated on the row-work RESULT for the + * reason {@link NodeDeleteOutcome} states: an `"optimistic-retry"` replay of + * the frame must report the surviving attempt's parts alone, which a mutable + * local spanning the retry could not. Per-item operation HOOKS stay absent, + * as they are for every other dimension of a batch delete — the receipt is + * transaction-scoped, not per-item. */ export async function executeNodeDeleteBatch( ctx: NodeOperationContext, @@ -4219,17 +7426,29 @@ export async function executeNodeDeleteBatch( return; } - await runWritePlan( + const outcome = await runWritePlan( nodeWritePlanContext(ctx), - nodeWritePlan(undefined, nodeRequiresIdentityLock(ctx)), + nodeWritePlan( + nodeDeleteConstraintProbe(ctx, kind), + nodeRequiresIdentityLock(ctx), + ), backend, - async (session, target) => { - const identity = ctx.identity; - const registration = getNodeRegistration(ctx.graph, kind); - let affectedCount = 0; + async ( + session, + target, + _overlaidSession, + lock, + ): Promise => { + // Reads first, for the WHOLE batch: every item's live pre-image, its + // cascade plan and every delete-behavior verdict, so a refusal on a + // later item is reached before an earlier one is deleted — see + // `planCascadingNodeDelete`. An item an earlier item's cascade already + // deletes is skipped here exactly as its own in-order read would have + // found it gone. + // // One delete-lock statement for the whole batch, in id order, before - // any member's connected-edge read: concurrent batches with overlapping - // ids then lock in one order instead of deadlocking member by member. + // any item's connected-edge read: concurrent batches with overlapping + // ids then lock in one order instead of deadlocking item by item. const preflights = await lockNodeRowsById( target, ctx.graphId, @@ -4237,30 +7456,57 @@ export async function executeNodeDeleteBatch( ids, "delete", ); - + const effects = noPlannedDeleteEffects(); + const plannedDeletes: Readonly<{ + id: string; + // Both the existence gate and the concurrency-correct pre-image + // consumed by uniqueness cleanup, read inside the batch transaction + // after the graph write lock is held. + preflight: LiveNodeRow; + cascadePlan: CompositionCascadePlan; + }>[] = []; for (const id of ids) { - // This is both the existence gate and the concurrency-correct - // pre-image consumed by uniqueness cleanup. It must stay inside the - // batch transaction after the graph write lock is held. + if (effects.deletedNodeKeys.has(nodeRefKey({ kind, id }))) continue; const preflight = preflights.get(id); - if (!preflight || !isLiveNodeRow(preflight)) continue; - - await session.retireNode({ - existing: preflight, - schema: registration.type.schema, - uniqueConstraints: registration.unique ?? [], - onDelete: registration.onDelete, + if (!isLiveNodeRowPresent(preflight)) continue; + plannedDeletes.push({ + id, + preflight, + cascadePlan: await planCascadingNodeDelete( + ctx, + { kind, id }, + target, + lock, + undefined, + effects, + { judgeRoot: ids.length > 1, lockMembers: true }, + ), }); - if (identity !== undefined) { - await identity.detachDeleted(target, { kind, id }, "soft"); - } + } + + let affectedCount = 0; + const cascadedParts: CompositionCascadedPart[] = []; + for (const { id, preflight, cascadePlan } of plannedDeletes) { + cascadedParts.push( + ...(await deleteRootWithCascade( + ctx, + session, + target, + "soft", + { kind, id }, + undefined, + cascadePlan, + preflight, + )), + ); affectedCount += 1; } - return affectedCount; + return { affectedCount, cascadedParts }; }, - { didWrite: (affectedCount) => affectedCount > 0 }, + { didWrite: (result) => result.affectedCount > 0 }, ); + ctx.recordCascadedParts?.(outcome.cascadedParts); } async function executeAtomicNodeDeletes( @@ -4325,13 +7571,17 @@ async function executeAtomicNodeDeletes( * Executes a node hard delete operation (permanent removal). * * Unlike soft delete, this permanently removes the node and all - * associated data (uniqueness entries, embeddings) from the database. + * associated data (uniqueness entries, embeddings) from the database. A + * composition whole still cascades to its parts first, leaf-first, through + * their own hard-delete pipeline — the mirror of the soft-delete cascade in + * {@link executeNodeDelete}. */ export async function executeNodeHardDelete( ctx: NodeOperationContext, kind: string, id: string, backend: GraphBackend | TransactionBackend, + policy?: NodeDeletePolicy, ): Promise { // Gate outside hooks and transaction so an absent node neither fires hooks // nor opens an empty transaction (see executeNodeDelete). The cascade @@ -4341,14 +7591,20 @@ export async function executeNodeHardDelete( const opContext = ctx.createOperationContext("delete", "node", kind, id); - return runHookedWritePlan( + const outcome = await runHookedWritePlan( nodeWritePlanContext(ctx), opContext, - nodeWritePlan(undefined, nodeRequiresIdentityLock(ctx)), + nodeWritePlan( + nodeDeleteConstraintProbe(ctx, kind), + nodeRequiresIdentityLock(ctx), + ), backend, - async (session, target) => { - const identity = ctx.identity; - const registration = getNodeRegistration(ctx.graph, kind); + async ( + session, + target, + _overlaidSession, + lock, + ): Promise => { // The delete lock, before the delete-behavior check reads connected // edges. Its row is not needed (unlike soft delete, whose pipeline // consumes the pre-image for uniqueness-key cleanup): every hard @@ -4362,21 +7618,38 @@ export async function executeNodeHardDelete( await lockNodeRow(target, ctx.graphId, kind, id, "delete"); } - // The cascade (edges, node, embeddings) is not individually atomic, so - // it runs in one write transaction. Embeddings live in strategy-owned - // per-`(kind, field)` tables, so they are cleaned up here rather than - // in the backend's graph-agnostic `hardDeleteNode` cascade. - await session.purgeNode({ - kind, - id, - schema: registration.type.schema, - onDelete: registration.onDelete, - }); - if (identity !== undefined) { - await identity.detachDeleted(target, { kind, id }, "hard"); - } + // Composition parts, leaf-first, BEFORE the whole itself. + const cascadePlan = await planCascadingNodeDelete( + ctx, + { kind, id }, + target, + lock, + policy, + noPlannedDeleteEffects(), + { judgeRoot: false, lockMembers: true }, + ); + // Embeddings live in strategy-owned per-`(kind, field)` tables, so + // they are cleaned up here rather than in the backend's graph-agnostic + // `hardDeleteNode` cascade. + return { + wrote: true, + cascadedParts: await deleteRootWithCascade( + ctx, + session, + target, + "hard", + { kind, id }, + policy, + cascadePlan, + ), + }; }, + // No `didWrite`: a hard delete reports no authoritative mutation verdict + // (its statements are id-keyed and idempotent), so `onOperationEnd`'s + // outcome stays `"unknown"` exactly as it always has. + { operationFacts: nodeDeleteOperationFacts }, ); + ctx.recordCascadedParts?.(outcome.cascadedParts); } // ============================================================ @@ -4392,6 +7665,7 @@ export async function executeNodeGetOrCreateByConstraint( options?: NodeGetOrCreateByConstraintOptions, ): Promise> { const ifExists = options?.ifExists ?? "return"; + const partOf = options?.partOf; const registration = getNodeRegistration(ctx.graph, kind); const nodeKind = registration.type; @@ -4406,7 +7680,7 @@ export async function executeNodeGetOrCreateByConstraint( if (key === undefined) { const node = await executeNodeCreate( ctx, - { kind, props: validatedProps }, + createInputWithPartOf(kind, validatedProps, partOf), backend, { propsPreValidated: true }, ); @@ -4449,30 +7723,21 @@ export async function executeNodeGetOrCreateByConstraint( if (existingRow === undefined) { const node = await executeNodeCreate( ctx, - { kind, props: validatedProps }, + createInputWithPartOf(kind, validatedProps, partOf), backend, { propsPreValidated: true }, ); return { node, action: "created" }; } - const isSoftDeleted = existingRow.deleted_at !== undefined; - - if (isSoftDeleted || ifExists === "update") { - const node = await executeNodeUpsertUpdate( - ctx, - { - kind: existingRow.kind, - id: existingRow.id as UpdateNodeInput["id"], - props: validatedProps, - }, - backend, - { clearDeleted: isSoftDeleted }, - ); - return { node, action: isSoftDeleted ? "resurrected" : "updated" }; - } - - return { node: rowToNode(existingRow), action: "found" }; + return applyExistingGetOrCreateLeg(ctx, backend, { + existing: existingRow, + validatedProps, + leg: resolveExistingGetOrCreateLeg(ctx, existingRow, { + ifExists, + partOf, + }), + }); }; try { @@ -5058,6 +8323,7 @@ export async function executeNodeBulkGetOrCreateByConstraint< if (items.length === 0) return []; const ifExists = options?.ifExists ?? "return"; + const partOf = options?.partOf; const registration = getNodeRegistration(ctx.graph, kind); const nodeKind = registration.type; const constraint = resolveConstraint(ctx.graph, kind, constraintName); @@ -5074,18 +8340,37 @@ export async function executeNodeBulkGetOrCreateByConstraint< type Result = Readonly<{ node: Node; action: GetOrCreateAction }>; - // Steps 2-6 are one convergence attempt: the batch probe runs outside any - // transaction and each write leg opens its own, so a concurrent create can - // reserve a key between them. The uniques primary key catches that and raises - // `UniquenessError`; re-running the whole attempt converges on the winner's - // row. Without this the single-item path retried and the batch failed - // outright, which is the asymmetry #428 called out. - async function attempt(): Promise { + // Steps 2-7 are one convergence attempt, in one transaction: the legs are + // nested writes of it, and a leg that attaches a composition part decides + // under the per-graph write fence, so a transaction the store opens here + // it also replays (`runOwnedMutationSetTransaction`). A concurrent create + // the fence does not exclude can still reserve a key between the batch + // probe and a leg; the uniques primary key catches that and raises + // `UniquenessError`, and re-running the whole attempt converges on the + // winner's row. Without this the single-item path retried and the batch + // failed outright, which is the asymmetry #428 called out. + // + // Every verdict the batch can reach by reading precedes its first write + // (step 5), so a refusal after a leg wrote is such a race, and the retry + // runs only where nothing the failed attempt wrote survives it: the + // attempt opened its own transaction (the refusal rolled it back), or it + // wrote nothing (`progress`). Inside a caller's transaction, or on a + // backend without one, re-running would apply the written legs a second + // time. + const transactionMode = resolveWriteTransactionMode(backend); + const runAttempt = (progress: { wrote: boolean }): Promise => + runOwnedMutationSetTransaction(backend, (target) => + attempt(target, progress), + ); + async function attempt( + target: GraphBackend | TransactionBackend, + progress: { wrote: boolean }, + ): Promise { // Step 2: Batch-check existing keys const candidatesByKey = uniqueKeys.length > 0 ? await batchFindClaimCandidatesAcrossKinds( - backend, + target, ctx.uniqueSidecarBatch, ctx.graphId, constraint.name, @@ -5108,7 +8393,10 @@ export async function executeNodeBulkGetOrCreateByConstraint< for (const [index, { validatedProps, key }] of validated.entries()) { if (key === undefined) { - toCreate.push({ index, input: { kind, props: validatedProps } }); + toCreate.push({ + index, + input: createInputWithPartOf(kind, validatedProps, partOf), + }); continue; } @@ -5122,7 +8410,10 @@ export async function executeNodeBulkGetOrCreateByConstraint< const candidates = candidatesByKey.get(key); if (candidates === undefined) { - toCreate.push({ index, input: { kind, props: validatedProps } }); + toCreate.push({ + index, + input: createInputWithPartOf(kind, validatedProps, partOf), + }); } else { toFetch.push({ index, candidates, key, validatedProps }); } @@ -5130,13 +8421,90 @@ export async function executeNodeBulkGetOrCreateByConstraint< const results: Result[] = Array.from({ length: items.length }); - // Step 4: Execute creates - if (toCreate.length > 0) { - const createInputs = toCreate.map((entry) => entry.input); + // Step 4: Resolve every matched row to the leg it owes, writing nothing. + // + // The leg is read from the NODE ROW fetched here, not from the uniques + // row the batch probe captured in step 2 — one decision, one owner + // (`resolveExistingGetOrCreateLeg`), shared with the single-item path. + // A match whose node row is gone joins the creates. + const matches: (ExistingGetOrCreateMatch & { index: number })[] = []; + for (const { index, candidates, key, validatedProps } of toFetch) { + const existing = await resolveClaimedNode( + ctx, + target, + candidates, + constraint.name, + key, + ); + if (existing === undefined) { + toCreate.push({ + index, + input: createInputWithPartOf(kind, validatedProps, partOf), + }); + continue; + } + matches.push({ + index, + existing, + validatedProps, + leg: resolveExistingGetOrCreateLeg(ctx, existing, { ifExists, partOf }), + }); + } + const createInputs = toCreate.map((entry) => entry.input); + // The creates' read-free refusals (a required part with no `partOf`, an + // undeclared or ambiguous pair) are reached here, before any matched item + // writes, rather than when the create batch runs in step 6. + const createWorks = resolveBatchCompositionWorks(ctx, createInputs, target); + const attachingCreates = createInputs.filter( + (_input, position) => createWorks[position] !== undefined, + ); + + // Step 5: Write the matched items that owe a write — after the verdicts + // of the whole batch, when it owes any. See + // `assertGetOrCreateBatchApplicable` for why those precede the first write. + const { owed, held } = await partitionExistingGetOrCreateLegs( + ctx, + target, + matches, + ); + if ( + getOrCreateBatchDecidesBeforeWriting(owed.length, createInputs.length) + ) { + await assertGetOrCreateBatchApplicable(ctx, target, { + owed, + creates: createInputs, + attachingCreates, + createWorks, + partOf, + }); + } + for (const match of held) { + results[match.index] = { + node: rowToNode(match.existing), + action: "found", + }; + } + for (const match of owed) { + results[match.index] = await writeOwedExistingGetOrCreateLeg( + ctx, + target, + match, + ); + progress.wrote = true; + } + + // Step 6: Execute creates + // + // After the matched items, the order the batch frame above judged them + // in: a matched update may hand a new row a unique key, and the frame + // already reached every refusal these rows' preparation can (their unique + // keys, their attachments), so none of them is raised here after the + // matched items wrote. + if (createInputs.length > 0) { const createdNodes = await executeNodeCreateBatch( ctx, createInputs, - backend, + target, { propsPreValidated: true }, ); for (const [batchIndex, entry] of toCreate.entries()) { @@ -5147,59 +8515,18 @@ export async function executeNodeBulkGetOrCreateByConstraint< } } - // Step 5: Handle existing nodes (fetch/update/resurrect) - for (const entry of toFetch) { - const { index, candidates, key, validatedProps } = entry; - - const existingRow = await resolveClaimedNode( - ctx, - backend, - candidates, - constraint.name, - key, - ); - - if (existingRow === undefined) { - const node = await executeNodeCreate( - ctx, - { kind, props: validatedProps }, - backend, - { propsPreValidated: true }, - ); - results[index] = { node, action: "created" }; - continue; - } - - // Read from the NODE ROW this loop just fetched, not from the uniques row - // the batch probe captured back in step 2 — the single-item path has - // always derived it here (see `executeNodeGetOrCreateByConstraint`), and - // one decision with two owners drifts. The uniques copy is also the - // staler of the two: step 4's creates run between the probe and this - // read, and a peer can soft-delete or resurrect the node in that window. - // Whether this write RESURRECTS has to come from the row it will target. - const isSoftDeleted = existingRow.deleted_at !== undefined; - - if (isSoftDeleted || ifExists === "update") { - const node = await executeNodeUpsertUpdate( - ctx, - { - kind: existingRow.kind, - id: existingRow.id as UpdateNodeInput["id"], - props: validatedProps, - }, - backend, - { clearDeleted: isSoftDeleted }, - ); - results[index] = { - node, - action: isSoftDeleted ? "resurrected" : "updated", - }; - } else { - results[index] = { node: rowToNode(existingRow), action: "found" }; - } - } - - // Step 6: Resolve within-batch duplicates by copying the first occurrence's result + // Step 7: Resolve within-batch duplicates by copying the first occurrence's result. + // + // No `partOf` postcondition call belongs here. A duplicate resolves to + // the SAME node as its source, and the source already had the + // postcondition discharged: steps 5/6 either wrote the attachment with + // the row (`"created"`, `resolveCompositionCreate`'s work), inside the + // row's own write plan (`"resurrected"`/`"updated"`, + // `resolveGetOrCreateAttachmentRequest`'s fenced request), or ran + // `applyExistingPartOfPostcondition` against it (`"found"`), which + // returned only once the node provably held the stated attachment. + // Re-checking the same node once per duplicate would re-read the same + // rows for the same verdict. for (const { index, sourceIndex } of duplicateOf) { const sourceResult = requireDefined(results[sourceIndex]); results[index] = { node: sourceResult.node, action: "found" }; @@ -5208,10 +8535,12 @@ export async function executeNodeBulkGetOrCreateByConstraint< return results; } + const progress = { wrote: false }; try { - return await attempt(); + return await runAttempt(progress); } catch (error) { if (!(error instanceof UniquenessError)) throw error; - return attempt(); + if (progress.wrote && transactionMode !== "opened") throw error; + return runAttempt({ wrote: false }); } } diff --git a/packages/typegraph/src/store/operations/node-write-pipeline.ts b/packages/typegraph/src/store/operations/node-write-pipeline.ts index a17a1cc4a..e5650c9d6 100644 --- a/packages/typegraph/src/store/operations/node-write-pipeline.ts +++ b/packages/typegraph/src/store/operations/node-write-pipeline.ts @@ -58,8 +58,9 @@ import { deleteUniquenessEntries, hardDeleteClaimsByNodeIds, type NodeClaimContext, + type NodeClaimTransition, planNodeClaimReinsert, - planNodeClaimUpdate, + planNodeClaimTransition, type UniquenessUpdatePlan, withNodeClaimTransition, withNodeCreateClaimsBatch, @@ -83,6 +84,7 @@ import { syncFulltextBatchForKind, } from "../fulltext-sync"; import { type GraphWriteLock } from "../recorded-capture/clock"; +import { type NodeDeletePlan } from "../types"; type Backend = GraphBackend | TransactionBackend; @@ -112,7 +114,7 @@ export function createNodeWriteContext( } /** Whether a delete removes the node (`hard`) or tombstones it (`soft`). */ -type NodeDeleteMode = "soft" | "hard"; +export type NodeDeleteMode = "soft" | "hard"; /** * Tunes how a delete treats the node's connected edges. @@ -126,8 +128,90 @@ type NodeDeleteMode = "soft" | "hard"; */ export type NodeDeletePolicy = Readonly<{ enforceDeleteBehavior: boolean; + /** + * Edges a caller has already decided to consume outside this delete's own + * enforcement: excluded from both the `restrict` count and the `cascade` / + * `disconnect` removal, so a consumed edge is evaluated and deleted exactly + * once — by whichever caller planned the consumption, not a second time by + * this delete's own delete-behavior enforcement. + * + * Populated by the composition cascade (`node-operations.ts`): every + * composition edge id a whole's cascade consumes is folded into the + * ROOT's own policy via `withCascadeConsumedEdges`, and each individual + * MEMBER delete the cascade drives is itself given a policy naming the + * edge that bound it to its (already-being-deleted) whole. Honored on + * the SOFT-delete path (`applyNodeSoftDelete`), the HARD-delete path + * (`applyNodeHardDelete`), and the batch delete path + * (`executeNodeDeleteBatch`, which builds its own per-item policy from + * that item's cascade plan internally) — all three route through + * `enforceNodeDeleteBehavior`, the one place this field is read. + */ + consumedEdgeIds?: ReadonlySet; + /** + * Whether a whole delete cascades to its parts. Default `true`. + * `planCascadingNodeDelete` (`node-operations.ts`) reads this: `false` + * yields an empty plan without touching a single part row. Merge apply + * passes `false` (`graph-merge/merge.ts`) because its plan already + * carries every part deletion the cascade would otherwise recompute — + * apply trusts the plan rather than re-walking the closure a second time + * under its own lock. + */ + cascadeComposition?: boolean; }>; +/** + * What a caller states about one node delete beyond its target: the + * {@link NodeDeletePolicy} an internal caller (merge apply, provenance) + * narrows enforcement with, and the `expect` plan a public + * `delete(id, { expect })` must still match (`node-delete-expectation.ts`). + */ +export type NodeDeleteRequest = Readonly<{ + policy?: NodeDeletePolicy | undefined; + expect?: NodeDeletePlan | undefined; +}>; + +/** + * Whether a stated {@link NodeDeleteRequest} carries a REQUEST-SHAPED + * dimension the fused atomic delete command cannot honor. + * + * The fused command is a single, read-free SQL shape: it has no notion of + * `consumedEdgeIds`, cannot skip its own delete-behavior enforcement, and + * computes no plan to compare an `expect` plan against. A request stating + * ANY of these — `policy.enforceDeleteBehavior: false`, a non-empty + * `policy.consumedEdgeIds`, or an `expect` plan — must therefore route around + * the fused command to the portable path, the only path that reads and + * honors them (`enforceNodeDeleteBehavior` for the policy, + * `assertNodeDeletePlanExpected` for the plan). This is the ONE place that + * decision is made for these fields: a caller choosing between the fused and + * portable delete must call this rather than re-deriving the answer from any + * field directly. + * + * This predicate does NOT own `cascadeComposition`, and is not the only + * fused-vs-portable gate in the store. That dimension is registry-shaped, + * not request-shaped — "does this kind participate in composition at all" + * is knowable from the graph's declaration alone, before any request is + * constructed — so it is decided statically by + * `resolveAtomicNodeDeleteBatchExecutor`'s composition guard + * (`atomic-mutation-program.ts`), which returns no executor for a kind that + * is a composition whole (`registry.isCompositionWhole`) or part + * (`registry.isCompositionPart`) on either side, unconditionally on the + * caller's request. Together these are the two EXHAUSTIVE owners of "should + * this delete run the portable path": one for request fields, one for static + * composition participation. A future third dimension must extend one of + * these two predicates (or, if neither shape fits, add and name a third + * owner here) — never re-derive the routing decision inline at a call site. + */ +export function nodeDeleteRequiresPortablePath( + request: NodeDeleteRequest, +): boolean { + const { policy, expect } = request; + if (expect !== undefined) return true; + return ( + policy !== undefined && + (!policy.enforceDeleteBehavior || (policy.consumedEdgeIds?.size ?? 0) > 0) + ); +} + function uniquenessContext(ctx: NodeWriteContext, backend: Backend) { return createUniquenessContext( ctx.graphId, @@ -156,6 +240,29 @@ function nodeSyncContext( * the delete (`restrict`) or are removed alongside the node (`cascade` / * `disconnect`). Skipped entirely when the caller's {@link NodeDeletePolicy} * disables enforcement. + * + * `policy.consumedEdgeIds` narrows the edges considered by EITHER arm before + * the behavior switch runs: an edge a caller already consumed neither blocks + * a `restrict` delete nor gets removed a second time by this delete's own + * `cascade` / `disconnect` cleanup. The narrowing is the single seam a future + * composition cascade plans against — see {@link NodeDeletePolicy}. + * + * A composition edge is a SEPARATE, unconditional exclusion from the + * `restrict` OBSTACLE count only (by contract, a composition edge is never a + * restrict obstacle, on either the part end — a part may always be deleted out of its whole — or an + * intermediate whole's upward edge into ITS OWN whole). This holds whether + * or not the edge is this delete's own `consumedEdgeIds`, so it is checked + * independently via `registry.isCompositionEdge` rather than folded into + * that set. Exclusion from the obstacle count is not exclusion from + * removal: a `restrict` delete that clears (no non-composition obstacle + * remains) still removes every unconsumed composition edge before + * returning, through the same {@link deleteEdgesById} owner the + * `cascade` / `disconnect` arm uses — otherwise the node would tombstone + * while a composition edge still pointed at it, which is the state edges + * can never be in (see that arm's own comment). The `cascade` / `disconnect` + * arm is untouched by the obstacle exclusion: a composition edge not already + * consumed by a policy is still removed alongside the node, exactly as any + * other cascaded/disconnected edge is. */ async function enforceNodeDeleteBehavior( ctx: NodeWriteContext, @@ -168,7 +275,47 @@ async function enforceNodeDeleteBehavior( backend: Backend, policy?: NodeDeletePolicy, ): Promise { - if (policy?.enforceDeleteBehavior === false) return; + const verdict = await judgeNodeDeleteBehavior(ctx, args, backend, policy); + if (verdict.removedEdgeIds.length === 0) return; + await deleteEdgesById(ctx, backend, args.mode, verdict.removedEdgeIds); +} + +/** + * What one node's delete behavior decides about its connected edges: the edge + * rows the delete removes alongside the node. A `restrict` node with a + * non-composition edge left has no verdict at all — it refuses. + */ +type NodeDeleteBehaviorVerdict = Readonly<{ + removedEdgeIds: readonly string[]; +}>; + +const NO_EDGES_REMOVED: NodeDeleteBehaviorVerdict = { removedEdgeIds: [] }; + +/** + * THE delete-behavior decision, read-only: what {@link enforceNodeDeleteBehavior} + * applies, and what a frame that deletes MORE than one node asks for every one + * of them before its first statement (the composition cascade and the batch + * delete, `node-operations.ts`), so a `restrict` refusal on a later node is + * reached while nothing has been written. + * + * `policy.consumedEdgeIds` is how such a frame states the edges its EARLIER + * deletes will already have removed by the time this node's turn comes: the + * verdict is then the one the node's own delete reaches in sequence. + * + * @throws RestrictedDeleteError when a `restrict` node keeps a + * non-composition edge. + */ +export async function judgeNodeDeleteBehavior( + ctx: Pick, + args: Readonly<{ + kind: string; + id: string; + onDelete: DeleteBehavior | undefined; + }>, + backend: Pick, + policy?: NodeDeletePolicy, +): Promise { + if (policy?.enforceDeleteBehavior === false) return NO_EDGES_REMOVED; const behavior = args.onDelete ?? "restrict"; const connectedEdges = await backend.findEdgesConnectedTo({ graphId: ctx.graphId, @@ -176,15 +323,38 @@ async function enforceNodeDeleteBehavior( nodeId: args.id, }); - if (connectedEdges.length === 0) return; + if (connectedEdges.length === 0) return NO_EDGES_REMOVED; + + const { consumedEdgeIds } = policy ?? {}; + const unconsumedEdges = + consumedEdgeIds === undefined ? connectedEdges : ( + connectedEdges.filter((edge) => !consumedEdgeIds.has(edge.id)) + ); + if (unconsumedEdges.length === 0) return NO_EDGES_REMOVED; + const removedEdgeIds = unconsumedEdges.map((edge) => edge.id); switch (behavior) { case "restrict": { + const restrictedEdges = unconsumedEdges.filter( + (edge) => !ctx.registry.isCompositionEdge(edge.kind), + ); + if (restrictedEdges.length === 0) { + // Every unconsumed edge is a composition edge: none of them blocks + // the delete, but they are not this delete's `cascade`/`disconnect` + // arm's business either (that arm never runs for `restrict`), so + // nothing else removes their rows. Leaving them live would point a + // composition edge at the node this delete is about to tombstone — + // the exact state the comment above the cascade/disconnect arm says + // cannot exist. They are removed through the same owner that arm + // uses, so a restrict-behaved delete leaves no dangling composition + // edge on either the part or an intermediate whole's upward edge. + return { removedEdgeIds }; + } throw new RestrictedDeleteError({ nodeKind: args.kind, nodeId: args.id, - edgeCount: connectedEdges.length, - edgeKinds: [...new Set(connectedEdges.map((edge) => edge.kind))], + edgeCount: restrictedEdges.length, + edgeKinds: [...new Set(restrictedEdges.map((edge) => edge.kind))], }); } @@ -193,40 +363,48 @@ async function enforceNodeDeleteBehavior( // Both behaviors remove connected edges. "cascade" signals intent to // remove dependent data; "disconnect" signals intent to sever the // relationship. The effect is identical because edges cannot exist - // without both endpoints. One batched statement per bind-budget - // chunk instead of one statement per edge; the per-edge loop remains - // for backends without the batch members. - const connectedEdgeIds = connectedEdges.map((edge) => edge.id); - const batchDelete = - args.mode === "hard" ? - backend.hardDeleteEdgesBatch - : backend.deleteEdgesBatch; - if (batchDelete === undefined) { - for (const edge of connectedEdges) { - await (args.mode === "hard" ? - backend.hardDeleteEdge({ graphId: ctx.graphId, id: edge.id }) - : backend.deleteEdge({ graphId: ctx.graphId, id: edge.id })); - } - } else { - await batchDelete({ - graphId: ctx.graphId, - ids: connectedEdgeIds, - }); - } - // Hard-deleted holders are already takeable through the liveness probe; - // this is housekeeping so node cascades do not grow edgeClaims forever. - // Soft-deleted edges retain their rows for resurrection and are not - // reaped here. - if (args.mode === "hard") { - await purgeEdgeClaims( - backend, - ctx.claimsVerdict(), - ctx.graphId, - connectedEdgeIds, - ); - } - break; + // without both endpoints. + return { removedEdgeIds }; + } + } +} + +/** + * Deletes a set of edges by id, in the given mode. One batched statement per + * bind-budget chunk instead of one statement per edge; the per-edge loop + * remains for backends without the batch members. + * + * The one owner of "how does a delete remove an edge row" for both + * {@link enforceNodeDeleteBehavior}'s cascade/disconnect arm (unconsumed + * edges) and the composition cascade's explicit cleanup of the edges it + * consumed (`node-operations.ts`) — a composition edge is deliberately + * excluded from every member's OWN `unconsumedEdges` pass, so nothing else + * ever removes its row. + */ +export async function deleteEdgesById( + ctx: Pick, + backend: Backend, + mode: NodeDeleteMode, + edgeIds: readonly string[], +): Promise { + if (edgeIds.length === 0) return; + const batchDelete = + mode === "hard" ? backend.hardDeleteEdgesBatch : backend.deleteEdgesBatch; + if (batchDelete === undefined) { + for (const edgeId of edgeIds) { + await (mode === "hard" ? + backend.hardDeleteEdge({ graphId: ctx.graphId, id: edgeId }) + : backend.deleteEdge({ graphId: ctx.graphId, id: edgeId })); } + } else { + await batchDelete({ graphId: ctx.graphId, ids: [...edgeIds] }); + } + // Hard-deleted holders are already takeable through the liveness probe; + // this is housekeeping so node cascades do not grow edgeClaims forever. + // Soft-deleted edges retain their rows for resurrection and are not + // reaped here. + if (mode === "hard") { + await purgeEdgeClaims(backend, ctx.claimsVerdict(), ctx.graphId, edgeIds); } } @@ -314,17 +492,11 @@ export type NodeUpdateTarget = | Readonly<{ existing: NodeRow; clearDeleted: true }>; /** - * Decides a node update's uniqueness transition, writing nothing. - * - * Two shapes behind one seam. A live row diffs its old and new keys; a - * resurrecting update (`clearDeleted` on a tombstoned row) cannot, because the - * soft delete already removed its entries — the diff would skip an unchanged - * key and leave the resurrected node holding NO reservation, so a later create - * could silently duplicate the value. It re-reserves every applying key - * instead. Both refuse a conflict before returning, and both hand the caller a - * plan that only {@link withNodeClaimTransition} may carry out. + * Decides a node update's uniqueness transition, writing nothing: the row's + * own facts handed to {@link planNodeClaimTransition}, which owns the two + * shapes (a live row's diff, a restored tombstone's re-reservation). */ -async function planNodeUpdateUniqueness( +function planNodeUpdateUniqueness( ctx: NodeWriteContext, args: Readonly<{ existing: NodeRow; @@ -333,28 +505,37 @@ async function planNodeUpdateUniqueness( }>, backend: Backend, ): Promise { - const { kind, id } = args.existing; - - if (args.existing.deleted_at !== undefined) { - return planNodeClaimReinsert( - uniquenessContext(ctx, backend), - kind, - id, + return planNodeClaimTransition( + uniquenessContext(ctx, backend), + nodeClaimTransition( + args.existing, args.validatedProps, args.uniqueConstraints, - ); - } - - return planNodeClaimUpdate( - uniquenessContext(ctx, backend), - kind, - id, - parseRowProps(args.existing), - args.validatedProps, - args.uniqueConstraints, + ), ); } +/** + * THE claim transition one existing row's update owes, read off the row: a + * tombstone holds no reservation, a live row holds its current props'. + * Shared by the update itself and by a batch that judges several updates + * before writing any of them, so the two cannot read a row differently. + */ +export function nodeClaimTransition( + existing: NodeRow, + props: Record, + constraints: readonly UniqueConstraint[], +): NodeClaimTransition { + return { + kind: existing.kind, + id: existing.id, + heldProps: + existing.deleted_at === undefined ? parseRowProps(existing) : undefined, + props, + constraints, + }; +} + /** * Applies a node update: uniqueness maintenance (diff-based for a live row; * check-and-reinsert for a resurrecting update, whose entries the soft delete @@ -543,11 +724,39 @@ export async function applyResolvedNodeUpdateBatch( return rows; } +/** + * The props a soft delete releases uniqueness entries for: the ones the row + * holds once its tombstone is written, not the pre-image's. + * + * An ordinary property update takes no per-graph lock, so it can commit + * between the pre-image read and the tombstone. The tombstone statement is + * what locks the row: read after it, the stored props are final for this + * transaction, and a release computed from them cannot leave the key that + * update moved the node to claimed by a tombstone. A kind with no unique + * constraint keys nothing off its props and is not read again. + */ +async function readTombstonedNodeProps( + ctx: NodeWriteContext, + existing: LiveNodeRow, + uniqueConstraints: readonly UniqueConstraint[], + backend: Backend, +): Promise> { + if (uniqueConstraints.length === 0) return parseRowProps(existing); + const tombstoned = await backend.getNode( + ctx.graphId, + existing.kind, + existing.id, + ); + return parseRowProps(tombstoned ?? existing); +} + /** * Applies a node soft delete: delete-behavior enforcement, the tombstone * write, then removal of uniqueness entries, embeddings, and fulltext. * Requires a {@link LiveNodeRow}: deleting an already-tombstoned row would * re-run sidecar cleanup against entries the first delete already removed. + * The uniqueness release reads the row again after the tombstone + * ({@link readTombstonedNodeProps}). */ export async function applyNodeSoftDelete( ctx: NodeWriteContext, @@ -576,7 +785,12 @@ export async function applyNodeSoftDelete( uniquenessContext(ctx, backend), kind, id, - parseRowProps(args.existing), + await readTombstonedNodeProps( + ctx, + args.existing, + args.uniqueConstraints, + backend, + ), args.uniqueConstraints, ); await deleteNodeEmbeddings( @@ -595,6 +809,11 @@ export async function applyNodeSoftDelete( * backend's `hardDeleteNode` cascade; embeddings live in strategy-owned * per-`(kind, field)` tables the graph-agnostic cascade cannot reach, so they * are cleaned here. + * + * Takes an optional {@link NodeDeletePolicy}, honored exactly as the soft + * delete path honors it (`enforceDeleteBehavior`, `consumedEdgeIds`): the + * composition cascade's hard-delete arm narrows a member's restrict count by + * the edges it is itself consuming, the same way the soft-delete arm does. */ export async function applyNodeHardDelete( ctx: NodeWriteContext, @@ -605,11 +824,13 @@ export async function applyNodeHardDelete( onDelete: DeleteBehavior | undefined; }>, backend: Backend, + policy?: NodeDeletePolicy, ): Promise { await enforceNodeDeleteBehavior( ctx, { kind: args.kind, id: args.id, mode: "hard", onDelete: args.onDelete }, backend, + policy, ); await backend.hardDeleteNode({ graphId: ctx.graphId, diff --git a/packages/typegraph/src/store/operations/write-executor.ts b/packages/typegraph/src/store/operations/write-executor.ts index 3046e2700..c4e2e7901 100644 --- a/packages/typegraph/src/store/operations/write-executor.ts +++ b/packages/typegraph/src/store/operations/write-executor.ts @@ -24,7 +24,10 @@ import { type GraphWriteLock, uncapturedGraphWriteLock, } from "../recorded-capture/clock"; -import { type OperationHookContext } from "../types"; +import { + type OperationHookContext, + type OperationOutcomeFacts, +} from "../types"; import { AutocommitWriteRequiresTransaction } from "./autocommit-single-statement"; import { type RowWorkKind, type WritePlan } from "./write-plan"; import { @@ -80,18 +83,24 @@ export type HookedWritePlanContext = WritePlanContext & export type WritePlanOptions = Omit< WriteTransactionOptions, "fencesConstraintProbe" ->; +> & + Readonly<{ + /** + * What this operation learned while it ran, folded onto the context + * `onOperationEnd` receives ({@link OperationOutcomeFacts}). Read from + * the row work's own result, so a fact the write did not produce cannot + * be reported: the node delete's composition `cascadedParts` is the one + * caller today. Hook-only — {@link planTransactionOptions} strips it + * before the transaction layer, which has no notion of hooks. + */ + operationFacts?: (result: T) => OperationOutcomeFacts | undefined; + }>; /** Classifies a successful command whose contract guarantees a mutation. */ export function writeResultAlwaysChanges(): boolean { return true; } -/** Classifies a command that returns its authoritative mutation verdict. */ -export function booleanWriteResultChanges(result: boolean): boolean { - return result; -} - /** * Selects the optimistic update budget without duplicating singleton routing. * @@ -211,18 +220,20 @@ function planTransactionOptions( plan: WritePlan, options: WritePlanOptions | undefined, ): WriteTransactionOptions { - return { ...options, fencesConstraintProbe: plan.constraintProbe }; + const { operationFacts: _operationFacts, ...transactionOptions } = + options ?? {}; + return { ...transactionOptions, fencesConstraintProbe: plan.constraintProbe }; } /** * Runs one managed write under its plan. * - * Retrying under the `"optimistic-retry"` tier is `runInWriteTransaction`'s - * own concern (`write-transaction.ts`), not re-spelled here: that function - * wraps its whole attempt — opening the transaction, taking the locks, and - * calling `rowWork` — in `runRetriedUnit` whenever `backend`'s tier requires - * it, so a retried attempt re-enters this call from the top with brand-new - * everything. + * Retrying is `runInWriteTransaction`'s own concern (`write-transaction.ts`), + * not re-spelled here: that function wraps its whole attempt — opening the + * transaction, taking the locks, and calling `rowWork` — in `runRetriedUnit` + * whenever `backend`'s tier is `"optimistic-retry"` or the store owns a unit + * its keyed fence can refuse for a stale snapshot, so a retried attempt + * re-enters this call from the top with brand-new everything. */ export function runWritePlan( ctx: WritePlanContext, @@ -262,6 +273,7 @@ export function runHookedWritePlan( backend, planFrame(ctx, plan, rowWork), planTransactionOptions(plan, options), + options?.operationFacts, ); } diff --git a/packages/typegraph/src/store/operations/write-plan.ts b/packages/typegraph/src/store/operations/write-plan.ts index 90604ef37..c4fda947c 100644 --- a/packages/typegraph/src/store/operations/write-plan.ts +++ b/packages/typegraph/src/store/operations/write-plan.ts @@ -61,26 +61,31 @@ export function nodeWritePlan( } /** - * The plan for a batched node write. - * - * Owns one decision the single-write builder does not: **a batch is - * constrained when ANY member is.** One transaction means one fence, so a - * single constrained member makes the whole write constrained, and the first - * such member names the class a refusal would report. - * - * `node-operations.ts`'s private `nodeBatchFencesConstraintProbe` is the - * pre-migration spelling of this fold; it is replaced by this builder in the - * batch that moves those call sites (B1), not left as a second owner. + * THE "a batch is constrained when ANY member is" fold: one transaction + * means one fence, so a single constrained member makes the whole write + * constrained, and the first such member names the class a refusal would + * report. Every batched `WritePlan` builder below folds through this one + * function, so a call site can never re-spell its own `.find()`. + */ +function foldBatchConstraintProbe( + constraintProbes: readonly (ConstraintFenceReason | undefined)[], +): ConstraintFenceReason | undefined { + return constraintProbes.find((probe) => probe !== undefined); +} + +/** + * The plan for a node write whose constraint probe folds several sources: a + * batch's members, or one update's own probe beside the restore probe a + * cleared tombstone owes. */ export function nodeBatchWritePlan( constraintProbes: readonly (ConstraintFenceReason | undefined)[], requiresIdentityLock: boolean, ): WritePlan<"node"> { - return { - entity: "node", - constraintProbe: constraintProbes.find((probe) => probe !== undefined), + return nodeWritePlan( + foldBatchConstraintProbe(constraintProbes), requiresIdentityLock, - }; + ); } /** @@ -94,6 +99,18 @@ export function edgeWritePlan( return { entity: "edge", constraintProbe, requiresIdentityLock: false }; } +/** + * The plan for an edge write whose constraint probe folds several sources: a + * batch's members, or one update's reopen probe beside the detach probe a + * stated window end owes. A batch of purely `many`, non-acyclic, + * non-composition edges still takes no lock. + */ +export function edgeBatchWritePlan( + constraintProbes: readonly (ConstraintFenceReason | undefined)[], +): WritePlan<"edge"> { + return edgeWritePlan(foldBatchConstraintProbe(constraintProbes)); +} + /** A single frame that deliberately coordinates node and edge writes. */ export function mixedWritePlan( constraintProbe: ConstraintFenceReason | undefined, @@ -101,3 +118,26 @@ export function mixedWritePlan( ): WritePlan<"mixed"> { return { entity: "mixed", constraintProbe, requiresIdentityLock }; } + +/** + * The plan for a mixed (node + composition edge) write whose constraint probe + * folds several sources. + * + * Widens {@link foldBatchConstraintProbe}'s fold to `entity: "mixed"`: a + * composition create or restore (`node-operations.ts`) folds the node + * writes' own constraint probes with the composition edges its + * `partOf`/required-existence items owe — one item or a whole batch — into + * one verdict. Passing every probe through ONE combined list keeps that a + * single fold instead of an independent `.find()` or `??` chain each call + * site would otherwise re-spell. + */ +export function mixedBatchWritePlan( + constraintProbes: readonly (ConstraintFenceReason | undefined)[], + requiresIdentityLock: boolean, +): WritePlan<"mixed"> { + return { + entity: "mixed", + constraintProbe: foldBatchConstraintProbe(constraintProbes), + requiresIdentityLock, + }; +} diff --git a/packages/typegraph/src/store/operations/write-session.ts b/packages/typegraph/src/store/operations/write-session.ts index 07823a810..b6e4f228e 100644 --- a/packages/typegraph/src/store/operations/write-session.ts +++ b/packages/typegraph/src/store/operations/write-session.ts @@ -81,7 +81,7 @@ import { CompilerInvariantError, ConfigurationError } from "../../errors"; import { type KindRegistry } from "../../registry/kind-registry"; import { type Assert, type Equal } from "../../utils/type-assert"; import { - claimEdgeCardinality, + claimEdgeCardinalities, claimEdgeCardinalityBatch, } from "../claims/edge-claims"; import { @@ -122,6 +122,8 @@ import { applyNodeUpdate, applyResolvedNodeUpdateBatch, createNodeWriteContext, + deleteEdgesById, + type NodeDeleteMode, type NodeDeletePolicy, type NodeSetUpdateResult, type NodeSetUpdateWork, @@ -370,13 +372,14 @@ type ResolvedNodeUpdateBatchWork = Readonly<{ }>; /** - * One edge insert: the row params and the cardinality claim the row owes. + * One edge insert: the row params and the cardinality claims the row owes. * * An edge write obliges no DERIVED data — no uniqueness entries, no fulltext, no - * embeddings — but a constrained kind owes a claim, and the claim is what fences - * the axis its declaration spans. It is absent for an unconstrained kind and for - * a born-ended row whose cardinality does not count it, which is what - * `edgeCardinalityClaim` decides; the caller states the decision and this + * embeddings — but a constrained kind owes a claim per declared axis, and each + * claim is what fences the axis its declaration spans. The list is empty when + * the kind reserves nothing — an unconstrained kind on both axes, or a + * born-ended row whose axis does not count it — which is what + * `edgeCardinalityClaims` decides; the caller states the decision and this * surface applies it at its PRE-INSERT placement. * * The update and delete work records live in `edge-write-pipeline.ts` instead, @@ -386,7 +389,8 @@ type ResolvedNodeUpdateBatchWork = Readonly<{ */ export type EdgeInsertWork = Readonly<{ params: InsertEdgeParams; - claim: ClaimEdgeCardinalityParams | undefined; + /** Every axis this row reserves, in claim order. Empty when it reserves none. */ + claims: readonly ClaimEdgeCardinalityParams[]; }>; /** @@ -395,7 +399,7 @@ export type EdgeInsertWork = Readonly<{ function edgeBatchClaims( work: readonly EdgeInsertWork[], ): readonly ClaimEdgeCardinalityParams[] { - return work.flatMap((item) => (item.claim === undefined ? [] : [item.claim])); + return work.flatMap((item) => item.claims); } export type NodeWriteSession = Readonly<{ @@ -422,8 +426,24 @@ export type NodeWriteSession = Readonly<{ work: NodeDeleteWork, policy?: NodeDeletePolicy, ) => Promise; - purgeNode: (work: NodeHardDeleteWork) => Promise; + purgeNode: ( + work: NodeHardDeleteWork, + policy?: NodeDeletePolicy, + ) => Promise; reviveNode: (work: NodeResurrectWork) => Promise; + /** + * Deletes a set of composition edges by id, in `mode` — the composition + * cascade's explicit cleanup of the edges it consumed + * (`node-operations.ts`'s `applyCompositionCascade`), which every member's + * own delete-behavior enforcement was told to skip via + * `NodeDeletePolicy.consumedEdgeIds`. Row work has no direct edge-write + * member to call (only the session may write), so this is that write's + * one seam. + */ + deleteCompositionEdges: ( + edgeIds: readonly string[], + mode: NodeDeleteMode, + ) => Promise; // ---- B1b: delegates to node-write-pipeline.ts's applyNodeSetUpdate reviseNodeSet: ( @@ -724,10 +744,14 @@ export function createWriteSession( retireNode: (work, policy) => applyNodeSoftDelete(writeContext, work, target, policy), - purgeNode: (work) => applyNodeHardDelete(writeContext, work, target), + purgeNode: (work, policy) => + applyNodeHardDelete(writeContext, work, target, policy), reviveNode: (work) => applyNodeResurrect(writeContext, work, target), + deleteCompositionEdges: (edgeIds, mode) => + deleteEdgesById(writeContext, target, mode, edgeIds), + reviseNodeSet: (work, fences) => { // The fences are applied for their REFUSAL, not for their contribution: // this kind's one applier throws for a stated bound and writes nothing @@ -753,9 +777,7 @@ export function createWriteSession( // `compareClaimTargets`), so a batch and a peer batch take their row locks // in the same order. createEdge: async (work) => { - if (work.claim !== undefined) { - await claimEdgeCardinality(target, ctx.claimsVerdict(), work.claim); - } + await claimEdgeCardinalities(target, ctx.claimsVerdict(), work.claims); return edgeDispatch.one(work.params); }, @@ -764,18 +786,21 @@ export function createWriteSession( target.commands, command, ); + // Membership, not exact-arity identity: a two-axis declaration's + // `unsupported` result still names only `"cardinalityClaim"` dimensions + // (repeated per unmet claim), and `.every` over that non-empty tuple + // recognizes it exactly as it recognized the one-axis case before this + // port ever fused more than one claim. + const claims = command.plan.cardinalityClaims ?? []; if ( result.outcome === "unsupported" && - result.dimensions.length === 1 && - result.dimensions[0] === "cardinalityClaim" && + result.dimensions.every( + (dimension) => dimension === "cardinalityClaim", + ) && command.plan.schemaFence === undefined && - command.plan.cardinalityClaim !== undefined + claims.length > 0 ) { - await claimEdgeCardinality( - target, - ctx.claimsVerdict(), - command.plan.cardinalityClaim, - ); + await claimEdgeCardinalities(target, ctx.claimsVerdict(), claims); const retryCommand: EdgeCreateCommand = { kind: "edge.create", plan: { @@ -793,9 +818,7 @@ export function createWriteSession( }, createEdgeNoReturn: async (work) => { - if (work.claim !== undefined) { - await claimEdgeCardinality(target, ctx.claimsVerdict(), work.claim); - } + await claimEdgeCardinalities(target, ctx.claimsVerdict(), work.claims); await runInsertNoReturn(edgeDispatch, work.params); }, diff --git a/packages/typegraph/src/store/operations/write-transaction.ts b/packages/typegraph/src/store/operations/write-transaction.ts index 0a893b7dd..7b05dc510 100644 --- a/packages/typegraph/src/store/operations/write-transaction.ts +++ b/packages/typegraph/src/store/operations/write-transaction.ts @@ -82,7 +82,12 @@ import { runOptionallyInTransaction, type TransactionBackend, } from "../../backend/types"; -import { ConfigurationError, StaleVersionError } from "../../errors"; +import { + CompilerInvariantError, + ConfigurationError, + StaleVersionError, + UnsupportedBackendCapabilityError, +} from "../../errors"; import { type SqlSchema } from "../../query/compiler/schema"; import { sql } from "../../query/sql-fragment"; import { asCompiledRowsSql } from "../../query/sql-intent"; @@ -90,19 +95,25 @@ import { isPostgresFailedTransactionError, isSqliteStaleSnapshotError, } from "../../utils/sql-errors"; +import { CONSTRAINT_FENCE_DECIDES_UNDER_LOCK_ONLY } from "../claims/backing"; import { type ConstraintFenceReason } from "../constraints"; import { advanceRevisionClock, + lockRecordedGraphDecision, lockRecordedGraphWrite, recordedTransactionControlTarget, } from "../recorded-capture"; import { acquiredGraphWriteLockFromCombinedFence, + acquiringGraphWriteFence, type GraphWriteLock, memoizeAcquiredRecordedGraphWriteLock, uncapturedGraphWriteLock, } from "../recorded-capture/clock"; -import { type OperationHookContext } from "../types"; +import { + type OperationHookContext, + type OperationOutcomeFacts, +} from "../types"; /** * The slice of an operation context {@link runInWriteTransaction} needs: the @@ -119,7 +130,23 @@ export type WriteTransactionContext = Readonly<{ statementExecution?: BundleVerdictOf; }>; +/** + * Whether a transaction session may hand the fence evidence one managed write + * acquired to the writes after it: the per-graph write fence and the schema + * fence alike. + * + * PostgreSQL releases a lock taken after a savepoint, and undoes a fence-row + * advance made after it, when the transaction rolls back to that savepoint. + * A callback holding a raw SQL handle can issue that rollback between two + * managed writes, so its session is `"per-write"`: every write that needs a + * fence takes it again. A callback with no such handle cannot, and its + * session is `"reusable"`. The Store decides which from the one raw handle a + * transaction context can carry, `tx.sql`. + */ +export type WriteTransactionFenceEvidence = "reusable" | "per-write"; + interface WriteTransactionSession { + fenceEvidence: WriteTransactionFenceEvidence; lock: GraphWriteLock | undefined; wrote: boolean; aborted?: Readonly<{ error: unknown }>; @@ -140,9 +167,10 @@ export function markWriteTransactionAborted( /** * Schema fences deliberately do not normally memoize: PostgreSQL releases a * lock acquired after a savepoint when a caller rolls back to that savepoint. - * The one safe exception is a TypeGraph-owned Store transaction whose public - * surface has no native SQL handle. Its first managed write therefore cannot - * follow a caller-controlled savepoint; once that write acquires the fence, + * The one safe exception is a TypeGraph-owned Store transaction whose session + * is `"reusable"` ({@link WriteTransactionFenceEvidence}): its callback holds + * no raw SQL handle, so its first managed write cannot follow a + * caller-controlled savepoint, and once that write acquires the fence, * subsequent managed writes can reuse it until the outer transaction ends. * * The key includes the expected version rather than only the graph id. A @@ -184,15 +212,16 @@ export function memoizeLeasedSchemaFence( } /** - * Marks a TypeGraph-owned transaction as eligible for lazy schema-fence - * leasing. The first managed write acquires the fence; read-only transactions - * pay nothing and retain their existing visibility/locking behavior. + * Marks a `"reusable"` TypeGraph-owned transaction session as eligible for + * lazy schema-fence leasing. The first managed write acquires the fence; + * read-only transactions pay nothing and retain their existing + * visibility/locking behavior. * * Custom transaction backends deliberately take the callback unchanged: their * savepoint/connection lifetime has not been audited by TypeGraph, so their * managed writes retain the conservative per-call fence behavior. */ -export async function withTransactionSchemaFenceLease( +async function withTransactionSchemaFenceLease( ctx: Pick, target: TransactionBackend, fn: () => Promise, @@ -295,7 +324,8 @@ export function forceWriteTransactionRevision( /** * Graphs whose per-graph write lock a still-running {@link runInWriteTransaction} - * frame already holds on a given target. + * frame already holds — or is still acquiring — on a given target, each with + * the acquisition's own evidence token. * * `pg_advisory_xact_lock` is reentrant and held to the end of the top-level * transaction, so re-acquiring it is pure round-trip churn. Operations compose @@ -303,14 +333,55 @@ export function forceWriteTransactionRevision( * update against ITS transaction target — and each nested call would otherwise * pay a lock round trip the enclosing frame already paid. * + * The entry is the acquisition PROMISE, not a bare marker: a nested or sibling + * frame that skips the round trip awaits it and works under the very token + * the acquiring frame minted. That token carries the isolation the lock + * statement observed on this session, so a decision that must see commits + * made while the session waited for the fence is judged identically in every + * frame of the transaction. Handing such a frame an evidence-free token + * instead would read as "this engine serializes writers" and skip the check. + * * `store.transaction(...)` is covered by {@link WriteTransactionSession} * instead; this covers the operation-calls-operation nesting inside a single - * managed write. Same caveat as the capture layer's memo in + * managed write and sibling frames started before the first acquisition + * returns. Same caveat as the capture layer's memo in * `recorded-capture/clock.ts`: NOT savepoint-aware. A manual `SAVEPOINT` rolled * back across the outer acquisition releases the lock but not this entry; * manual savepoints inside a managed write are outside the contract. */ -const heldGraphWriteLocks = new WeakMap>(); +const heldGraphWriteLocks = new WeakMap< + object, + Map> +>(); + +function noop(): void { + // Marks a rejection as observed; the acquiring frame reports the error. +} + +type GraphWriteLockAcquisition = Readonly<{ + promise: Promise; + resolve: (lock: GraphWriteLock) => void; + reject: (error: unknown) => void; +}>; + +/** + * An acquisition other frames can await before it has started. A failed one + * is observed here so that it is not an unhandled rejection when no sibling + * frame happens to be waiting; the acquiring frame rethrows the error itself. + */ +function pendingGraphWriteLockAcquisition(): GraphWriteLockAcquisition { + let settle: Omit | undefined; + const promise = new Promise((resolve, reject) => { + settle = { resolve, reject }; + }); + promise.catch(noop); + if (settle === undefined) { + throw new CompilerInvariantError( + "A promise executor did not run synchronously.", + ); + } + return { promise, ...settle }; +} function adoptedConstraintWriterSlotError( ctx: Pick, @@ -381,20 +452,27 @@ async function ensureAdoptedConstraintWriterSlot( /** * Binds nested typed mutations to one caller-owned transaction commit so a - * multi-operation `store.transaction(...)` advances its durable revision once. + * multi-operation `store.transaction(...)` advances its durable revision once, + * and owns every reuse of fence evidence across those mutations: + * `fenceEvidence` decides both whether the session remembers the per-graph + * write fence and whether it leases the schema fence. */ export async function withWriteTransactionSession( target: TransactionBackend, ctx: WriteTransactionContext, + fenceEvidence: WriteTransactionFenceEvidence, fn: () => Promise, ): Promise { const session: WriteTransactionSession = { + fenceEvidence, lock: undefined, wrote: false, }; writeTransactionSessions.set(target, session); try { - const result = await fn(); + const result = await (fenceEvidence === "reusable" ? + withTransactionSchemaFenceLease(ctx, target, fn) + : fn()); if (session.aborted !== undefined) { // A caller with tx.sql may have rolled back a native savepoint after // catching the error. Check the pinned session itself: 25P02 means the @@ -566,6 +644,25 @@ export function resolveWriteTransactionMode( : "none"; } +/** + * The one refusal for an operation that needs write access inside + * `store.transaction(fn, { accessMode: "read_only" })`. Callers raise it + * before the operation issues any statement, so the engine never reports its + * own read-only error and the caller's transaction stays usable. + */ +export function refuseReadOnlyTransactionWrite( + operation: string, + graphId: string, + suggestion: string, +): never { + throw new UnsupportedBackendCapabilityError( + operation, + "write transaction access", + { graphId, accessMode: "read_only" }, + suggestion, + ); +} + /** * Whether a write about to run through `backend` must be wrapped in * {@link runRetriedUnit}: the tier requires it @@ -610,8 +707,12 @@ export { /** What a caller must change to make each refused constraint class writable. */ const CONSTRAINT_FENCE_ADVICE = { + edgeAcyclicity: + "drop `acyclic: true` from the edge and detect cycles in application code", edgeCardinality: 'declare the edge `cardinality: "many"` and enforce the limit in application code', + edgeComposition: + "drop the `partOf`/`hasPart` declaration realized by this edge kind and enforce the single-whole rule in application code", edgeMatchKeyConvergence: "use `create` with a caller-chosen id, whose uniqueness the edges primary key enforces, instead of `getOrCreateByEndpoints`", nodeDisjointness: @@ -729,7 +830,10 @@ export function constraintFenceRefusal( * the wrapping. A nested unit (`transactionMode === "existing"`) never * retries here regardless of the tier: it cannot restart a transaction it * does not own, so its conflict propagates to the outermost owner. Under - * `"interactive"` this function's behavior is unchanged: one attempt. + * `"interactive"` a unit takes one attempt unless it is one this store owns + * AND its graph-write fence can refuse a stale snapshot + * ({@link ownsSnapshotFencedUnit}); that unit is replayed through the same + * owner, with the same budget. */ export function runInWriteTransaction( ctx: WriteTransactionContext, @@ -752,20 +856,151 @@ export function runInWriteTransaction( ); if (refusal !== undefined) return Promise.reject(refusal); - const attempt = (): Promise => - runInWriteTransactionAttempt(ctx, backend, fn, options, transactionMode); + return runMaybeRetried( + backend, + "runInWriteTransaction", + () => + requiresOptimisticRetryUnit(backend) || + ownsSnapshotFencedUnit(backend, fenceReason), + () => + runInWriteTransactionAttempt(ctx, backend, fn, options, transactionMode), + ); +} - if (!requiresOptimisticRetryUnit(backend)) return attempt(); +/** + * Whether this store needs the per-graph write fence for a write: it captures + * (history or revision tracking, whose clocks must serialize) or the write + * declared a constraint only the fence protects. + */ +function writeNeedsGraphWriteFence( + ctx: WriteTransactionContext, + fenceReason: ConstraintFenceReason | undefined, +): boolean { + return ( + ctx.historyEnabled || + ctx.revisionTrackingEnabled || + fenceReason !== undefined + ); +} + +/** + * Whether a write names a constraint whose verdict only the per-graph write + * fence protects ({@link CONSTRAINT_FENCE_DECIDES_UNDER_LOCK_ONLY}): its + * frame takes the fence in its deciding form, which advances the graph's + * fence row. A frame that names any other reason, or none, leaves the row + * alone; if it reaches such a decision after all, the decision site advances + * the row itself (`fenceLockOnlyDecision`). + */ +function writeDecidesUnderLockOnly( + fenceReason: ConstraintFenceReason | undefined, +): boolean { + return ( + fenceReason !== undefined && + CONSTRAINT_FENCE_DECIDES_UNDER_LOCK_ONLY[fenceReason] + ); +} + +/** + * Whether a transaction the store opens on `backend` takes its per-graph + * write fence as a keyed acquisition (`lock` or `row`) — the one whose + * deciding form advances the graph's fence row, and therefore fails a session + * whose snapshot predates another decider's commit. A nested unit + * (`"existing"`) is the caller's transaction, and a serialized engine takes + * no keyed acquisition; neither answers `true`. + * + * THE one decision of "can the fence refuse a transaction the store opened + * for a stale snapshot", shared by every store-owned transaction that replays + * on it: {@link runInWriteTransaction} and + * {@link runOwnedMutationSetTransaction}. + */ +function opensKeyedFenceTransaction( + backend: GraphBackend | TransactionBackend, +): boolean { + if (resolveWriteTransactionMode(backend) !== "opened") return false; + const plan = resolveWriteFencePlan(backend); + return plan.kind === "lock" || plan.kind === "row"; +} + +/** + * Whether this write is a unit the store OWNS whose graph-write fence can + * refuse it for a stale snapshot: it names a constraint — so it can reach a + * decision that advances the fence row, in its own frame or at the decision + * site — in a transaction the store opened itself, on a keyed fence + * ({@link opensKeyedFenceTransaction}). Such a unit is replayed whole by + * {@link runRetriedUnit}, exactly as an `"optimistic-retry"` unit is: the + * replay opens a new transaction and with it a snapshot that includes the + * winner. + * + * An unconstrained write decides nothing under the fence, never advances the + * fence row and so is never refused there: it takes one attempt, as it always + * has. A nested unit is the caller's transaction: it cannot be restarted from + * here, so the refusal propagates to whoever owns it. + */ +function ownsSnapshotFencedUnit( + backend: GraphBackend | TransactionBackend, + fenceReason: ConstraintFenceReason | undefined, +): boolean { + return fenceReason !== undefined && opensKeyedFenceTransaction(backend); +} + +/** + * The transaction a collection opens around a resolved mutation set — a bulk + * upsert's or bulk replace's prefetch and the writes it elects, or a bulk + * get-or-create's legs, on one target. + * + * The writes inside are nested units of {@link runInWriteTransaction}: each + * runs in THIS transaction and cannot restart it, so a stale-snapshot refusal + * of a lock-only decision one of them makes under the per-graph write fence + * would otherwise surface from a transaction the caller never opened. The store owns it, so the store replays it — the whole + * callback, prefetch included, through the same {@link runRetriedUnit} owner + * and budget as every other store-owned unit. A caller's own transaction, a + * serialized engine and a backend without transactions run `fn` once, as + * {@link runOptionallyInTransaction} always has. + */ +export function runOwnedMutationSetTransaction( + backend: GraphBackend | TransactionBackend, + fn: (target: GraphBackend | TransactionBackend) => Promise, +): Promise { + return runMaybeRetried( + backend, + "runOwnedMutationSetTransaction", + () => opensKeyedFenceTransaction(backend), + () => runOptionallyInTransaction(backend, (target) => fn(target)), + ); +} + +/** + * Runs a store-owned unit's `attempt` once, or replays it whole through + * {@link runRetriedUnit} when `replays` says the unit is one the store + * retries. The decision is taken before the first attempt and, like a + * refusal, surfaces as a rejection rather than a synchronous throw: a caller + * that composes a promise-returning unit without `await` must see every + * failure the same way. + */ +function runMaybeRetried( + backend: GraphBackend | TransactionBackend, + operation: string, + replays: () => boolean, + attempt: () => Promise, +): Promise { + let replayed: boolean; + try { + replayed = replays(); + } catch (error) { + return Promise.reject(asRejection(error)); + } + if (!replayed) return attempt(); return runRetriedUnit( - { - operation: "runInWriteTransaction", - attempts: OPTIMISTIC_RETRY_ATTEMPTS, - target: backend, - }, + { operation, attempts: OPTIMISTIC_RETRY_ATTEMPTS, target: backend }, attempt, ); } +/** A thrown value, as the `Error` a promise-returning function rejects with. */ +function asRejection(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +} + /** * One attempt of {@link runInWriteTransaction}: everything that function did * as one body before the `"optimistic-retry"` tier existed, unchanged. @@ -787,10 +1022,8 @@ function runInWriteTransactionAttempt( ): Promise { const ownsWriteLock = transactionMode === "opened"; const fenceReason = options?.fencesConstraintProbe; - const needsGraphWriteLock = - ctx.historyEnabled || - ctx.revisionTrackingEnabled || - fenceReason !== undefined; + const needsGraphWriteLock = writeNeedsGraphWriteFence(ctx, fenceReason); + const decidesUnderLockOnly = writeDecidesUnderLockOnly(fenceReason); return runOptionallyInTransaction(backend, async (target) => { await ensureAdoptedConstraintWriterSlot( ctx, @@ -800,11 +1033,10 @@ function runInWriteTransactionAttempt( ); const session = needsGraphWriteLock ? writeTransactionSessions.get(target) : undefined; - // Either constructor yields the same compile-time evidence token; which one - // ran says why no acquisition was needed. `uncapturedGraphWriteLock` covers - // both "this store needs no lock" and "an enclosing frame on this target - // already holds it" — in the second case the lock is genuinely held, which - // is a stronger claim than the constructor makes, not a weaker one. + // A frame that does not acquire works under the token of whichever frame + // did: the transaction session's, or the acquisition an enclosing or + // sibling frame on this target registered. `uncapturedGraphWriteLock` is + // left for the one case with nothing to hold — this store needs no lock. // // The claim is registered SYNCHRONOUSLY, before any await: two frames on // one target that both reached this point would otherwise each read an @@ -812,18 +1044,23 @@ function runInWriteTransactionAttempt( // leaving the first frame's `finally` clearing a Set the map no longer // holds, and the graph marked held forever. Reading-and-registering with no // suspension point in between makes that interleaving unrepresentable. - const held = heldGraphWriteLocks.get(target) ?? new Set(); + const held = + heldGraphWriteLocks.get(target) ?? + new Map>(); heldGraphWriteLocks.set(target, held); + const heldLock = needsGraphWriteLock ? held.get(ctx.graphId) : undefined; const acquiresLock = needsGraphWriteLock && session?.lock === undefined && - !held.has(ctx.graphId); - // Marked before the acquisition rather than after it, for the same reason: - // a sibling frame that starts while this one is still awaiting the lock - // must see the claim, not race it. Its statements queue behind ours on the - // one connection either way, so observing an in-flight claim as held is - // correct. A failed acquisition retracts the claim. - if (acquiresLock) held.add(ctx.graphId); + heldLock === undefined; + // Registered before the acquisition rather than after it, for the same + // reason: a sibling frame that starts while this one is still awaiting the + // lock must see the claim, not race it. Its statements queue behind ours on + // the one connection either way, and it awaits this acquisition for its + // token. A failed acquisition retracts the claim. + const acquisition = + acquiresLock ? pendingGraphWriteLockAcquisition() : undefined; + if (acquisition !== undefined) held.set(ctx.graphId, acquisition.promise); const expectedSchemaVersion = ctx.schemaVersion; const combinedSchemaGraphFence = ( @@ -838,7 +1075,8 @@ function runInWriteTransactionAttempt( params: { graphId: ctx.graphId, expectedVersion: expectedSchemaVersion, - }, + fenceRow: decidesUnderLockOnly ? "advance" : "leave", + } as const, } : undefined; let lock: GraphWriteLock; @@ -847,32 +1085,53 @@ function runInWriteTransactionAttempt( if (!options?.schemaFenceInFirstWrite) { await lockSchemaVersionForStoreWrite(ctx, target); } - lock = - acquiresLock ? - await lockRecordedGraphWrite(target, ctx.graphId) - : (session?.lock ?? uncapturedGraphWriteLock()); + if (!acquiresLock) { + const heldEvidence = + session?.lock ?? (await heldLock) ?? uncapturedGraphWriteLock(); + // A frame that decides under evidence of the exclusion alone — a + // session or sibling that took the fence for a write that decided + // nothing — advances the fence row behind the lock it works under. + lock = + decidesUnderLockOnly ? + await lockRecordedGraphDecision(target, ctx.graphId, heldEvidence) + : heldEvidence; + } else if (decidesUnderLockOnly) { + lock = await lockRecordedGraphDecision(target, ctx.graphId); + } else { + lock = await lockRecordedGraphWrite(target, ctx.graphId); + } } else { // The optional strong member owns the same canonical order as the two // portable calls: schema row first, graph advisory lock second. It is // one statement only when THIS frame owes both acquisitions; a held // graph lock must never suppress the per-write schema fence. - const isolation = await combinedSchemaGraphFence.acquire( - combinedSchemaGraphFence.params, + const acquireCombinedFence = combinedSchemaGraphFence.acquire; + const fenceParams = combinedSchemaGraphFence.params; + const fenceAcquisition = await acquiringGraphWriteFence(target, () => + acquireCombinedFence(fenceParams), + ); + memoizeAcquiredRecordedGraphWriteLock( + target, + ctx.graphId, + fenceAcquisition, ); - memoizeAcquiredRecordedGraphWriteLock(target, ctx.graphId, isolation); // The combined statement acquired the real advisory lock, so mint // graph/port-bound command coordination only after it returns. lock = acquiredGraphWriteLockFromCombinedFence( target, ctx.graphId, - isolation, + fenceAcquisition, ); } } catch (error) { - if (acquiresLock) held.delete(ctx.graphId); + if (acquisition !== undefined) { + held.delete(ctx.graphId); + acquisition.reject(error); + } throw error; } - if (session !== undefined) session.lock = lock; + acquisition?.resolve(lock); + if (session?.fenceEvidence === "reusable") session.lock = lock; const result = await (acquiresLock ? fn(target, lock, transactionMode).finally(() => held.delete(ctx.graphId)) : fn(target, lock, transactionMode)); @@ -910,6 +1169,7 @@ export type HookedWriteOperationContext = WriteTransactionContext & ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ) => Promise; }>; @@ -934,10 +1194,12 @@ export function runHookedWriteOperation( transactionMode: WriteTransactionMode, ) => Promise, options?: WriteTransactionOptions, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ): Promise { return ctx.withOperationHooks( opContext, () => runInWriteTransaction(ctx, backend, body, options), options?.didWrite, + operationFacts, ); } diff --git a/packages/typegraph/src/store/recorded-capture.ts b/packages/typegraph/src/store/recorded-capture.ts index 4280372a0..83dd725fc 100644 --- a/packages/typegraph/src/store/recorded-capture.ts +++ b/packages/typegraph/src/store/recorded-capture.ts @@ -39,6 +39,11 @@ import { import { CompilerInvariantError, ConfigurationError } from "../errors"; import { type IdentityTarget } from "../identity/sql-target"; import { type IdentityAssertionStorageRow } from "../identity/storage-types"; +import { + type IdentityDecisionProvenance, + type IdentityTransitionDraft, + type IdentityTransitionNote, +} from "../identity/transition-log"; import { type SqlSchema } from "../query/compiler/schema"; import { sql as portableSql } from "../query/sql-fragment"; import { asCompiledStatementSql } from "../query/sql-intent"; @@ -61,6 +66,7 @@ import { entityKey, flushEdges, flushIdentityAssertions, + flushIdentityTransitions, flushNodes, queryConnectedEdgeIds, type TouchedEdge, @@ -89,7 +95,9 @@ export { ensureRevisionOrigin, ensureRevisionOriginRow, ensureRevisionOriginsRelation, + lockRecordedGraphDecision, lockRecordedGraphWrite, + readNextRecordedRevision, readRecordedClock, readRevisionOrigin, recordedClockAdvisoryLockSql, @@ -106,12 +114,14 @@ export { withRecordedRelationsPrecondition, } from "./recorded-capture/guards"; export { + changesSinceIncludingIdentity, encodeRecordedLineageRevision, mintsOriginNamespacedAnchor, recordedRelationsLineage, resolveLineage, } from "./recorded-capture/lineage"; export { + graphCapturesRecordedHistory, RECORDED_EDGE_COLUMNS, RECORDED_NODE_COLUMNS, } from "./recorded-capture/relations"; @@ -143,7 +153,37 @@ type RecordedCaptureSession = Readonly<{ id: string, afterImage?: IdentityAssertionStorageRow, ) => void; + /** + * Buffers one identity-transition note, stamping it with the graph id and + * the session's current ambient decision (see {@link withIdentityDecision}). + * Alongside `touched` in {@link checkpoint} / {@link restore}, and sealed by + * `flush` exactly as every other touch is. + */ + noteIdentityTransition: ( + graphId: string, + note: IdentityTransitionDraft, + ) => void; + /** + * Runs `fn` and returns the withdrawal of the notes it buffered: calling it + * removes exactly those notes, so a note describing a row write that is + * then refused never reaches the flush. + */ + collectIdentityTransitionNotes: ( + fn: () => Promise, + ) => Promise; + /** + * Sets the ambient decision provenance every `noteIdentityTransition` call + * takes for the duration of `fn`, then restores the PREVIOUS ambient value — + * a plain save/restore, not a push/pop stack, so an exception inside `fn` + * cannot leak a decision into a later, unrelated write. + */ + withIdentityDecision: ( + decision: IdentityDecisionProvenance, + fn: () => Promise, + ) => Promise; forceGraphRevision: (graphId: string) => void; + /** Whether identity transition notes for `graphId` are buffered for the flush. */ + hasIdentityTransitionNotes: (graphId: string) => boolean; checkpoint: () => RecordedCaptureCheckpoint; restore: (checkpoint: RecordedCaptureCheckpoint) => void; flush: ( @@ -157,9 +197,26 @@ type RecordedCaptureSession = Readonly<{ type RecordedCaptureCheckpoint = Readonly<{ touched: ReadonlyMap; forcedGraphRevisions: ReadonlySet; + identityTransitionNotes: readonly IdentityTransitionNote[]; }>; -export type RecordedFlushInstants = ReadonlyMap; +/** + * One graph's outcome from a recorded-capture flush: the allocated recorded + * instant, and how many identity transition notes this flush wrote for that + * graph (`0` when identity is disabled, opened without `history: true`, or + * nothing in the transaction changed a class) — the count `IdentityWriteSummary + * .transitions` is drawn from, so the receipt never re-derives it from a + * second reading of the notes buffer. + */ +type RecordedGraphFlushResult = Readonly<{ + recordedAt: string; + identityTransitions: number; +}>; + +export type RecordedFlushInstants = ReadonlyMap< + string, + RecordedGraphFlushResult +>; type RecordedFlushObserver = (instants: RecordedFlushInstants) => void; @@ -312,7 +369,7 @@ export function withTransactionPreCommitHook( } as RecordedFlushOptions; } -type RecordedTransactionScope = Readonly<{ +export type RecordedTransactionScope = Readonly<{ backend: TransactionBackend; flush: () => Promise; }>; @@ -333,6 +390,12 @@ function recordedCaptureSealedError( function createRecordedCaptureSession(): RecordedCaptureSession { const touched = new Map(); const forcedGraphRevisions = new Set(); + const identityTransitionNotes: IdentityTransitionNote[] = []; + // Ambient decision for `noteIdentityTransition`. A plain variable, not a + // stack: `withIdentityDecision` always restores it in a `finally`, so + // nesting still resolves to whichever call is innermost while it runs, and + // an exception cannot leave a stale decision attached to a later write. + let currentDecision: IdentityDecisionProvenance | undefined; // Sealed by flush(): a scope flushes exactly once, at its terminal point, so // any touch afterward means a graph write happened after capture lost its // flush window (e.g. a caller reused the withRecordedTransaction context after @@ -377,16 +440,69 @@ function createRecordedCaptureSession(): RecordedCaptureSession { touch({ entity: "identity", graphId, id, afterImage }); }, + noteIdentityTransition( + graphId: string, + note: IdentityTransitionDraft, + ): void { + if (sealed) { + throw recordedCaptureSealedError({ + entity: "identity-transition", + graphId, + }); + } + identityTransitionNotes.push({ + ...note, + graphId, + decision: currentDecision, + }); + }, + + async collectIdentityTransitionNotes( + fn: () => Promise, + ): Promise { + const firstCollected = identityTransitionNotes.length; + await fn(); + const collected = identityTransitionNotes.slice(firstCollected); + return () => { + if (sealed) { + throw recordedCaptureSealedError({ entity: "identity-transition" }); + } + for (const note of collected) { + const index = identityTransitionNotes.lastIndexOf(note); + if (index !== -1) identityTransitionNotes.splice(index, 1); + } + }; + }, + + async withIdentityDecision( + decision: IdentityDecisionProvenance, + fn: () => Promise, + ): Promise { + const previous = currentDecision; + currentDecision = decision; + try { + return await fn(); + } finally { + currentDecision = previous; + } + }, + forceGraphRevision(graphId: string): void { if (sealed) throw recordedCaptureSealedError({ graphId }); forcedGraphRevisions.add(graphId); }, + hasIdentityTransitionNotes(graphId: string): boolean { + if (sealed) throw recordedCaptureSealedError({}); + return identityTransitionNotes.some((note) => note.graphId === graphId); + }, + checkpoint(): RecordedCaptureCheckpoint { if (sealed) throw recordedCaptureSealedError({}); return { touched: new Map(touched), forcedGraphRevisions: new Set(forcedGraphRevisions), + identityTransitionNotes: [...identityTransitionNotes], }; }, @@ -398,6 +514,8 @@ function createRecordedCaptureSession(): RecordedCaptureSession { for (const graphId of checkpoint.forcedGraphRevisions) { forcedGraphRevisions.add(graphId); } + identityTransitionNotes.length = 0; + identityTransitionNotes.push(...checkpoint.identityTransitionNotes); }, async flush( @@ -421,13 +539,24 @@ function createRecordedCaptureSession(): RecordedCaptureSession { // flush() writes recorded rows directly (never via touch), so sealing here // does not block its own work. sealed = true; - if (touched.size === 0 && forcedGraphRevisions.size === 0) + if ( + touched.size === 0 && + forcedGraphRevisions.size === 0 && + identityTransitionNotes.length === 0 + ) return new Map(); - const recordedByGraph = new Map(); + const recordedByGraph = new Map(); const byGraph = groupBy(touched.values(), (entity) => entity.graphId); for (const graphId of forcedGraphRevisions) { if (!byGraph.has(graphId)) byGraph.set(graphId, []); } + const notesByGraph = groupBy( + identityTransitionNotes, + (note) => note.graphId, + ); + for (const graphId of notesByGraph.keys()) { + if (!byGraph.has(graphId)) byGraph.set(graphId, []); + } for (const [graphId, entities] of byGraph) { const recordedCommit = await allocateRecordedCommit( target, @@ -435,7 +564,11 @@ function createRecordedCaptureSession(): RecordedCaptureSession { graphId, ownsWriteLock, ); - recordedByGraph.set(graphId, recordedCommit.instant); + const graphNotes = notesByGraph.get(graphId) ?? []; + recordedByGraph.set(graphId, { + recordedAt: recordedCommit.instant, + identityTransitions: graphNotes.length, + }); const nodes = entities.filter( (entity): entity is TouchedNode => entity.entity === "node", ); @@ -469,9 +602,18 @@ function createRecordedCaptureSession(): RecordedCaptureSession { identityAssertions, recordedCommit.revision, ); + await flushIdentityTransitions( + target, + schema, + graphId, + graphNotes, + recordedCommit.revision, + recordedCommit.recordedAt, + ); } touched.clear(); forcedGraphRevisions.clear(); + identityTransitionNotes.length = 0; return recordedByGraph; }, }; @@ -621,6 +763,22 @@ export function hasPendingRecordedGraphWrites( ); } +/** + * Whether the capture session bound to `target` holds identity transition + * notes for `graphId` that its flush has yet to write to the transition log. + * Resolved through the same binding `withRecordedIdentityMutationTarget` + * buffers those notes through, so it answers for exactly the session a + * transaction's identity writes noted into. + */ +export function hasPendingIdentityTransitionNotes( + target: IdentityTarget, + graphId: string, +): boolean { + const session = recordedTransactionBindings.get(target)?.capture?.session; + if (session === undefined) return false; + return session.hasIdentityTransitionNotes(graphId); +} + /** Forces one revision allocation when this capture transaction flushes. */ export function forceRecordedGraphRevision( backend: TransactionBackend, @@ -637,6 +795,23 @@ function ignoreIdentityTouch(): void { return; } +function ignoreIdentityTransitionNote(): void { + return; +} + +/** + * The callback `withRecordedIdentityMutationTarget` hands its caller for + * recording one class-membership transition, scoped to the graph the caller + * names — matching the `touch(graphId, id, afterImage)` calling convention. + * A no-op capture session (no history, or a non-capturing backend) resolves + * to {@link ignoreIdentityTransitionNote}, so every note site can call this + * unconditionally. + */ +export type IdentityTransitionNoteFunction = ( + graphId: string, + note: IdentityTransitionDraft, +) => void; + /** * Both sides are {@link IdentityTarget}, not the backend union: what this hands * `fn` is the handle identity STATEMENTS run against, and the recorded binding @@ -653,16 +828,74 @@ export async function withRecordedIdentityMutationTarget( id: string, afterImage?: IdentityAssertionStorageRow, ) => void, + noteTransition: IdentityTransitionNoteFunction, ) => Promise, ): Promise { const binding = recordedTransactionBindings.get(target); if (binding === undefined) { - return fn(target, ignoreIdentityTouch); + return fn(target, ignoreIdentityTouch, ignoreIdentityTransitionNote); } binding.assertOpen(); - return fn(binding.target, (graphId, id, afterImage) => { - binding.sink.touchIdentity(graphId, id, afterImage); - }); + return fn( + binding.target, + (graphId, id, afterImage) => { + binding.sink.touchIdentity(graphId, id, afterImage); + }, + (graphId, note) => { + binding.capture?.session.noteIdentityTransition(graphId, note); + }, + ); +} + +/** Removes the identity-transition notes one collection gathered. */ +export type IdentityTransitionNoteWithdrawal = () => void; + +function nothingToWithdraw(): void { + // No capture session buffered anything for this target. +} + +/** + * Runs `fn` — the identity half of a row write, which notes the transition + * that write will cause — and returns the withdrawal of whatever it noted. + * The caller invokes the withdrawal when the row write is then refused, so a + * refusal caught inside an enclosing transaction that goes on to commit + * leaves no transition, and allocates no recorded revision, for a change that + * never happened. + * + * The collection spans `fn` alone, which issues only the identity half's own + * reads. A target with no capture session buffers nothing and withdraws + * nothing. + */ +export async function collectRecordedIdentityTransitionNotes( + target: IdentityTarget, + fn: () => Promise, +): Promise { + const session = recordedTransactionBindings.get(target)?.capture?.session; + if (session === undefined) { + await fn(); + return nothingToWithdraw; + } + return session.collectIdentityTransitionNotes(fn); +} + +/** + * Runs `fn` with every `noteIdentityTransition` call inside it stamped with + * `decision` — the seam `applyIdentityChangesForContext` (reconcile) uses so a + * governed merge apply's transitions carry the plan/review digests and branch + * ancestry that produced them. A target with no capture session (no history) + * simply runs `fn` — there is no ambient decision to attach to a note that + * will never be written. + */ +export async function withRecordedIdentityDecision( + target: IdentityTarget, + decision: IdentityDecisionProvenance, + fn: () => Promise, +): Promise { + const binding = recordedTransactionBindings.get(target); + if (binding === undefined) return fn(); + const session = binding.capture?.session; + if (session === undefined) return fn(); + return session.withIdentityDecision(decision, fn); } /** @@ -797,6 +1030,22 @@ function createRecordedTransactionBackend( return overlay; } +/** + * The one owner of "does the transaction handed to + * {@link createRecordedTransactionScope} already hold a SQLite write lock" + * (its `ownsWriteLock` parameter) — every caller that cannot otherwise prove + * it structurally (i.e. is not itself inside a known `BEGIN IMMEDIATE` path) + * calls this instead of re-spelling the dialect comparison inline. PostgreSQL + * has no equivalent lock a schema-commit transaction could already be + * holding at this call, so `false` there is correct, not a stand-in for "not + * yet implemented". + */ +export function transactionOwnsSqliteWriteLock( + target: TransactionBackend, +): boolean { + return target.dialect === "sqlite"; +} + export function createRecordedTransactionScope( target: TransactionBackend, batchPointRead: BundleVerdictOf, diff --git a/packages/typegraph/src/store/recorded-capture/clock.ts b/packages/typegraph/src/store/recorded-capture/clock.ts index ce0e7b424..0be9b9a16 100644 --- a/packages/typegraph/src/store/recorded-capture/clock.ts +++ b/packages/typegraph/src/store/recorded-capture/clock.ts @@ -3,20 +3,22 @@ import { recordedRevisionOriginsMembers } from "../../backend/capabilities/bind" import { type RECORDED_REVISION_ORIGINS } from "../../backend/capabilities/bundle-registry"; import { type BundleVerdictOf } from "../../backend/capabilities/resolve"; import { - type FenceStatements, requireWriteFence, resolveFenceStatements, resolveWriteFencePlan, + type WriteFencePlan, } from "../../backend/capabilities/write-fence"; import { + assertFencedSnapshotIsFresh, + graphCommandCoordinationAcquisition, mintGraphCommandCoordination, - normalizeGraphCommandIsolation, + readGraphWriteFenceAcquisition, } from "../../backend/command-contract"; import { postgresFenceSql } from "../../backend/drizzle/postgres-fence-sql"; import type { GraphBackend, GraphCommandCoordination, - GraphCommandIsolation, + GraphWriteFenceAcquisition, } from "../../backend/types"; import { createRecordedInstant, @@ -24,12 +26,14 @@ import { RECORDED_MAX_REVISION, type RecordedInstant, } from "../../core/temporal"; -import { ConfigurationError } from "../../errors"; +import { ConfigurationError, TransactionConflictError } from "../../errors"; import { type SqlSchema } from "../../query/compiler/schema"; import { sql, type SqlFragment } from "../../query/sql-fragment"; import { asCompiledRowsSql } from "../../query/sql-intent"; import { canonicalizeDatabaseTimestamp, nowIso } from "../../utils/date"; import { generateId } from "../../utils/id"; +import { decodeRevisionColumn } from "../../utils/revision-column"; +import { isSerializationFailure } from "../../utils/sql-errors"; import { executeStatement } from "./guards"; type ClockRow = Readonly<{ recorded_at: unknown; revision: unknown }>; @@ -126,24 +130,24 @@ const UNCOORDINATED_GRAPH_WRITE_LOCK = Object.freeze({ function acquiredGraphWriteLock( target: Pick, graphId: string, - isolation: GraphCommandIsolation, + acquisition: GraphWriteFenceAcquisition, ): GraphWriteLock { return Object.freeze({ coordination: mintGraphCommandCoordination( target.commands, graphId, - isolation, + acquisition, ), }) as GraphWriteLock; } -/** Record an advisory lock acquired by a combined backend primitive. */ +/** Record a graph write fence acquired by a combined backend primitive. */ export function acquiredGraphWriteLockFromCombinedFence( target: Pick, graphId: string, - isolation: GraphCommandIsolation, + acquisition: GraphWriteFenceAcquisition, ): GraphWriteLock { - return acquiredGraphWriteLock(target, graphId, isolation); + return acquiredGraphWriteLock(target, graphId, acquisition); } /** @@ -169,8 +173,14 @@ export function uncapturedGraphWriteLock(): GraphWriteLock { * * Keyed weakly by the backend object: the delegate is created per transaction, * so memo lifetime equals lock lifetime. The recorded savepoint coordinator - * checkpoints and restores this memo with captured touches; manual savepoints - * remain outside the capture contract. + * checkpoints and restores this memo with captured touches. No other + * savepoint can sit between two writes of a Store-owned history transaction: + * its callback holds no raw SQL handle (`tx.sql` is withheld under capture, + * and `tx.backend` executes no statements). A `withRecordedTransaction` + * callback can open one through the caller's own handle; rolling such a + * savepoint back over TypeGraph writes is outside the capture contract, as + * the limitations guide states, because it would undo writes and fence + * evidence this memo and the captured touches still remember. * * Single-flight per graph: the memo stores the IN-FLIGHT acquisition * promise, not just completed acquisitions, so concurrent same-transaction @@ -179,7 +189,10 @@ export function uncapturedGraphWriteLock(): GraphWriteLock { * acquisition evicts its entry so a retry is not poisoned (in practice a * failed statement has aborted the Postgres transaction anyway). */ -export type RecordedGraphLockMemo = Map>; +export type RecordedGraphLockMemo = Map< + string, + Promise +>; export function createRecordedGraphLockMemo(): RecordedGraphLockMemo { return new Map(); @@ -198,11 +211,11 @@ const recordedGraphLockMemos = new WeakMap(); export function memoizeAcquiredRecordedGraphWriteLock( backend: object, graphId: string, - isolation: GraphCommandIsolation, + acquisition: GraphWriteFenceAcquisition, ): void { const memo = recordedGraphLockMemos.get(backend); if (memo === undefined || memo.has(graphId)) return; - memo.set(graphId, Promise.resolve(isolation)); + memo.set(graphId, Promise.resolve(acquisition)); } export function registerRecordedGraphLockMemo( @@ -212,72 +225,244 @@ export function registerRecordedGraphLockMemo( recordedGraphLockMemos.set(backend, memo); } -async function acquireRecordedGraphWriteLock( +/** The operation a stale-snapshot refusal of the graph write fence names. */ +const GRAPH_WRITE_FENCE_OPERATION = "per-graph write fence"; + +/** + * THE one owner of what a failed graph-write fence statement raises, shared by + * the portable acquisitions below and the fused schema + graph fence + * (`write-transaction.ts`). + * + * A deciding acquisition advances the graph's fence row, so a session at a + * snapshot isolation whose snapshot predates another decider's commit is + * refused by the engine with a serialization failure — on purpose: the + * decision that session would go on to make reads a graph it can no longer + * see. That refusal is reported as a {@link TransactionConflictError} carrying + * the driver error as `cause`, so the owner of the transaction recognizes it + * whichever driver raised it: `runRetriedUnit` re-runs a unit the library + * owns, and a caller that owns the transaction re-runs it from the top. Any + * other failure propagates unchanged. + */ +export async function acquiringGraphWriteFence( + target: object, + acquire: () => Promise, +): Promise { + try { + return await acquire(); + } catch (error) { + if (!isSerializationFailure(error, target)) throw error; + throw new TransactionConflictError( + { operation: GRAPH_WRITE_FENCE_OPERATION, attempts: 1 }, + { cause: error }, + ); + } +} + +type GraphWriteFenceTarget = Pick< + GraphBackend, + "capabilities" | "commands" | "dialect" | "execute" +>; + +type KeyedGraphWriteFence = Extract; + +async function executeGraphWriteFenceStatement( target: Pick, - fenceSql: FenceStatements, - graphId: string, -): Promise { - const rows = await target.execute< - Readonly<{ transaction_isolation: unknown }> - >( - asCompiledRowsSql( - fenceSql.acquireKeyedWithIsolation( - RECORDED_GRAPH_WRITE_ADVISORY_LOCK_NAMESPACE, - graphId, - ), + statement: SqlFragment, +): Promise { + const rows = await acquiringGraphWriteFence(target, () => + target.execute>>( + asCompiledRowsSql(statement), ), ); - return normalizeGraphCommandIsolation(rows[0]?.transaction_isolation); + return readGraphWriteFenceAcquisition(rows[0]); } -export async function lockRecordedGraphWrite( - target: Pick< - GraphBackend, - "capabilities" | "commands" | "dialect" | "execute" - >, - graphId: string, - memo?: RecordedGraphLockMemo, -): Promise { +/** + * The keyed fence a recorded-graph acquisition takes on `target`, or + * `undefined` when nothing is taken: a writer slot — or, under + * `caller-serialized`, the deployment's own serialization promise — can + * serialize ordinary row work, but neither is an advisory acquisition bound to + * this graph/port, and it cannot authorize the PostgreSQL convergence command. + */ +function keyedRecordedGraphFence( + target: Pick, + operation: string, +): KeyedGraphWriteFence | undefined { const plan = resolveWriteFencePlan(target); - const fence = requireWriteFence(plan, "recorded graph write", "keyed"); + const fence = requireWriteFence(plan, operation, "keyed"); switch (fence.kind) { case "engine-serialized": case "caller-serialized": { - // A writer slot — or, under `caller-serialized`, the deployment's own - // serialization promise — can serialize ordinary row work, but - // neither is an advisory acquisition bound to this graph/port. It - // cannot authorize the PostgreSQL convergence command. - return uncapturedGraphWriteLock(); + return undefined; } case "lock": case "row": { - break; + return fence; } default: { - fence satisfies never; + return fence satisfies never; } } - const effectiveMemo = memo ?? recordedGraphLockMemos.get(target); - if (effectiveMemo === undefined) { - const isolation = await acquireRecordedGraphWriteLock( +} + +/** + * Takes the per-graph write fence: the keyed exclusion and the session's + * isolation fact, and nothing else. Under an advisory `lock` plan the graph's + * fence row is left alone — a holder that only captures history, advances a + * revision or writes a claim-backed constraint decides nothing the row could + * protect, so it pays for no row write. A holder that goes on to make a + * lock-only decision takes {@link lockRecordedGraphDecision} instead, or on + * top of this. + */ +export async function lockRecordedGraphWrite( + target: GraphWriteFenceTarget, + graphId: string, + memo?: RecordedGraphLockMemo, +): Promise { + const fence = keyedRecordedGraphFence(target, "recorded graph write"); + if (fence === undefined) return uncapturedGraphWriteLock(); + const acquire = (): Promise => + executeGraphWriteFenceStatement( target, - fence.sql, - graphId, + fence.sql.acquireKeyedWithIsolation( + RECORDED_GRAPH_WRITE_ADVISORY_LOCK_NAMESPACE, + graphId, + ), ); - return acquiredGraphWriteLock(target, graphId, isolation); + const effectiveMemo = memo ?? recordedGraphLockMemos.get(target); + if (effectiveMemo === undefined) { + return acquiredGraphWriteLock(target, graphId, await acquire()); } let pending = effectiveMemo.get(graphId); if (pending === undefined) { - pending = acquireRecordedGraphWriteLock(target, fence.sql, graphId).catch( - (error: unknown) => { - effectiveMemo.delete(graphId); - throw error; - }, - ); + pending = acquire().catch((error: unknown) => { + effectiveMemo.delete(graphId); + throw error; + }); effectiveMemo.set(graphId, pending); } - const isolation = await pending; - return acquiredGraphWriteLock(target, graphId, isolation); + const acquisition = await pending; + return acquiredGraphWriteLock(target, graphId, acquisition); +} + +function advancedFenceRow( + target: Pick, + graphId: string, + lock: GraphWriteLock, +): boolean { + return ( + lock.coordination !== undefined && + graphCommandCoordinationAcquisition( + target.commands, + lock.coordination, + graphId, + ).fenceRow === "advanced" + ); +} + +/** + * The per-graph write fence of a holder about to make a LOCK-ONLY DECISION — + * an edge-acyclicity probe, a composition whole's parts closure, a match-key + * convergence: the keyed exclusion AND the advance of the graph's fence row. + * + * Every decider advances the row and only deciders do. That is what lets a + * decider at a snapshot isolation be trusted: its row write fails with a + * serialization failure when another decider committed after its snapshot. + * And it is why a decider at read committed must advance the row too — it is + * the "other decider" of someone else's stale snapshot. + * + * `held` is the fence evidence the caller already works under. Evidence that + * the row was advanced is returned as it is, at no statement. Evidence of the + * exclusion alone is upgraded by re-issuing the deciding acquisition, which + * re-enters the exclusion this transaction holds and advances the row behind + * it. With no `held`, this is the acquisition itself, in one statement. + * + * Two cases yield evidence with no advanced row: a serialized engine or + * deployment, which has no stale snapshot to detect, and an advisory fence + * naming no fences relation, on which + * {@link assertFencedSnapshotIsFresh} then refuses a snapshot isolation. A + * `held` lock carrying no coordination is a frame that holds no keyed fence — + * a serialized engine's, or a lock-free plan-time read; it is returned + * unchanged rather than fenced late, out of lock order. + */ +export async function lockRecordedGraphDecision( + target: GraphWriteFenceTarget, + graphId: string, + held?: GraphWriteLock, +): Promise { + // Decided before the plan is resolved: evidence with no coordination is a + // frame that holds no keyed fence, whatever its target could take. + if (held !== undefined && held.coordination === undefined) return held; + const fence = keyedRecordedGraphFence(target, "recorded graph write"); + if (fence === undefined) return held ?? uncapturedGraphWriteLock(); + if (held !== undefined && advancedFenceRow(target, graphId, held)) { + return held; + } + const memo = recordedGraphLockMemos.get(target); + const memoized = memo?.get(graphId); + if (memoized !== undefined) { + const lock = acquiredGraphWriteLock(target, graphId, await memoized); + if (advancedFenceRow(target, graphId, lock)) return lock; + } + const { acquireKeyedSnapshotFence } = fence.sql; + if (acquireKeyedSnapshotFence === undefined) { + return held ?? lockRecordedGraphWrite(target, graphId); + } + const pending = executeGraphWriteFenceStatement( + target, + acquireKeyedSnapshotFence( + RECORDED_GRAPH_WRITE_ADVISORY_LOCK_NAMESPACE, + graphId, + ), + ); + if (memo === undefined) { + return acquiredGraphWriteLock(target, graphId, await pending); + } + // The memo is what a later frame of this transaction reads, and what the + // recorded savepoint coordinator checkpoints: a rollback to a savepoint + // taken before this statement restores the entry it replaced, so the row + // advance that rollback undid is not remembered as done. + const memoizedPending = pending.catch((error: unknown) => { + if (memo.get(graphId) === memoizedPending) { + if (memoized === undefined) memo.delete(graphId); + else memo.set(graphId, memoized); + } + throw error; + }); + memo.set(graphId, memoizedPending); + return acquiredGraphWriteLock(target, graphId, await memoizedPending); +} + +/** + * THE gate of a decision the per-graph write fence alone protects — a read + * with no claim row behind it, such as the edge-acyclicity probe or a whole + * delete's parts closure. Called before that read, by every site that makes + * one. + * + * It does the two things such a decision needs, in order, so no site can do + * one without the other: advance the graph's fence row under `held` + * ({@link lockRecordedGraphDecision}), then require that this session observes + * the deciders that held the fence before it + * ({@link assertFencedSnapshotIsFresh}). + * + * @throws TransactionConflictError when another decider committed after this + * session's snapshot + * @throws ConfigurationError carrying `refusal.code` when the fence cannot + * show this session's snapshot to be fresh + */ +export async function fenceLockOnlyDecision( + target: GraphWriteFenceTarget, + graphId: string, + held: GraphWriteLock, + refusal: Readonly<{ code: string; subject: string }>, +): Promise { + const lock = await lockRecordedGraphDecision(target, graphId, held); + assertFencedSnapshotIsFresh( + target.commands, + graphId, + lock.coordination, + refusal, + ); } function failInvalidClock(value: unknown, cause?: unknown): never { @@ -293,10 +478,7 @@ function failInvalidClock(value: unknown, cause?: unknown): never { } function recordedClockRevision(value: unknown): number { - const revision = - typeof value === "bigint" ? Number(value) - : typeof value === "string" && /^\d+$/.test(value) ? Number(value) - : value; + const revision = decodeRevisionColumn(value); if ( typeof revision !== "number" || !Number.isSafeInteger(revision) || @@ -318,16 +500,40 @@ function recordedClockParts(row: ClockRow): RecordedClockParts | undefined { return { revision, recordedAt: recordedClockWallTime(row.recorded_at) }; } +const FIRST_RECORDED_REVISION = 1; + +function revisionAfter(clockRevision: number | undefined): number { + return clockRevision === undefined ? + FIRST_RECORDED_REVISION + : clockRevision + 1; +} + +/** + * The revision the graph's next recorded commit will be allocated — the + * committed clock plus one, or the first revision when the graph has no clock + * row yet. It is also the highest floor that can truthfully be stated over the + * graph's own history: nothing on this graph's axis exists at or above it, and + * everything the graph records afterwards lands at or above it. One owner for + * the allocation itself and for every reader that reasons about that floor. + */ +export async function readNextRecordedRevision( + target: Pick, + schema: SqlSchema, + graphId: string, +): Promise { + const clock = await readRecordedClockParts(target, schema, graphId); + return revisionAfter(clock?.revision); +} + function nextRecordedCommitParts( previous: RecordedClockParts | undefined, ): RecordedClockParts { const wallTime = nowIso(); - if (previous === undefined) { - return { revision: 1, recordedAt: wallTime }; - } + const revision = revisionAfter(previous?.revision); + if (previous === undefined) return { revision, recordedAt: wallTime }; return { - revision: previous.revision + 1, + revision, // Keep diagonal replay cumulative across backward clock corrections without // manufacturing a new millisecond for same-ms commits. Throughput can make // this component repeat, never run ahead of the greatest observed wall time. diff --git a/packages/typegraph/src/store/recorded-capture/flush.ts b/packages/typegraph/src/store/recorded-capture/flush.ts index dce20b3b5..2fe54a31c 100644 --- a/packages/typegraph/src/store/recorded-capture/flush.ts +++ b/packages/typegraph/src/store/recorded-capture/flush.ts @@ -9,12 +9,19 @@ import { import { RECORDED_MAX_REVISION } from "../../core/temporal"; import { IDENTITY_ASSERTION_COLUMNS } from "../../identity/historical-sql"; import { type IdentityAssertionStorageRow } from "../../identity/storage-types"; +import { + encodeIdentityTransitionRow, + IDENTITY_TRANSITION_COLUMNS, + type IdentityTransitionNote, +} from "../../identity/transition-log"; import { sqlValueList } from "../../query/compiler/predicate-utils"; import { type SqlSchema } from "../../query/compiler/schema"; import { sql, type SqlFragment } from "../../query/sql-fragment"; import { asCompiledRowsSql } from "../../query/sql-intent"; import { chunk } from "../../utils/array"; +import { generateId } from "../../utils/id"; import { isPresent } from "../../utils/presence"; +import { withIdentityTransitionRelationsPrecondition } from "../../utils/sql-errors"; import { getEdgeRowsByIds } from "../edge-fetch"; import { getNodeRowsByIds } from "../node-fetch"; import { allocateRecordedCommit } from "./clock"; @@ -25,6 +32,7 @@ import { insertRecordedNodeRows, recordedEdgeChunkSize, recordedIdentityAssertionChunkSize, + recordedIdentityTransitionChunkSize, type RecordedInsert, recordedNodeChunkSize, type RecordedOperation, @@ -269,6 +277,43 @@ export async function flushIdentityAssertions( } } +/** + * Flushes buffered identity-transition notes to the append-only transition + * log. Unlike the node/edge/identity-assertion relations, this relation has + * no prior open row to close and no `op` classification — a transition is + * pure history from the moment it exists, so this is a plain chunked INSERT. + */ +export async function flushIdentityTransitions( + target: TransactionBackend, + schema: SqlSchema, + graphId: string, + notes: readonly IdentityTransitionNote[], + revision: number, + recordedAt: string, +): Promise { + if (notes.length === 0) return; + const chunkSize = recordedIdentityTransitionChunkSize(target); + for (const noteChunk of chunk(notes, chunkSize)) { + const values = noteChunk.map((note) => + encodeIdentityTransitionRow(note, revision, recordedAt, generateId()), + ); + await withIdentityTransitionRelationsPrecondition( + executeStatement( + target, + sql` + INSERT INTO ${schema.identityTransitionsTable} (${IDENTITY_TRANSITION_COLUMNS}) + VALUES ${sql.join(values, sql`, `)} + `, + ), + [ + schema.tables.identityTransitions, + schema.tables.identityTransitionRetention, + ], + { dialect: target.dialect, surface: "capture-flush" }, + ); + } +} + async function closeOpenByKind( target: TransactionBackend, table: SqlFragment, diff --git a/packages/typegraph/src/store/recorded-capture/lineage.ts b/packages/typegraph/src/store/recorded-capture/lineage.ts index c1f28dfa8..c8cc1747d 100644 --- a/packages/typegraph/src/store/recorded-capture/lineage.ts +++ b/packages/typegraph/src/store/recorded-capture/lineage.ts @@ -387,6 +387,20 @@ async function evidencedRevisionCount( : decodeAggregateRevision(evidenced); } +/** + * THE two-armed "changed strictly after `sinceRevision`" predicate over one + * recorded relation's rows: a version opened after it, or a version closed + * after it. See the module doc for why the two arms cover every write shape. + */ +function changedAfterRevision(sinceRevision: number): SqlFragment { + return sql` + ( + recorded_from > ${sinceRevision} + OR (recorded_to > ${sinceRevision} AND recorded_to <> ${RECORDED_MAX_REVISION}) + ) + `; +} + /** * Every `(kind, id)` in one recorded relation with a change strictly after * `sinceRevision` — the shared query behind both the node and edge halves @@ -405,16 +419,45 @@ async function changedEntityKeys( SELECT DISTINCT kind, id FROM ${table} WHERE graph_id = ${graphId} - AND ( - recorded_from > ${sinceRevision} - OR (recorded_to > ${sinceRevision} AND recorded_to <> ${RECORDED_MAX_REVISION}) - ) + AND ${changedAfterRevision(sinceRevision)} ORDER BY kind, id `), ); return rows.map((row) => ({ kind: row.kind, id: row.id })); } +/** + * Whether any identity assertion of `graphId` changed strictly after `since` — + * the same two-armed predicate {@link changedEntityKeys} applies to nodes and + * edges, asked of the recorded identity-assertions relation. + * + * A `"keys"` delta names nodes and edges only. {@link evidencedRevisionCount} + * still counts an identity assertion as completeness evidence, deliberately: + * the merge diff that prunes by this source reads identity through its own + * path and needs the node/edge delta to stay bounded across an identity write. + * A caller holding only the key delta has no such second path, so a span this + * answers `true` for changed something that delta cannot name. A token this + * module did not mint has no revision to compare, and is reported as changed. + */ +async function recordedIdentityChangedSince( + store: RecordedLineageStore, + session: LineageSession, + since: EngineRevision, +): Promise { + const parsed = parseLineageRevision(since); + if (parsed === undefined) return true; + const rows = await session.execute>( + asCompiledRowsSql(sql` + SELECT 1 AS changed + FROM ${store.revisionSchema.recordedIdentityAssertionsTable} + WHERE graph_id = ${store.graphId} + AND ${changedAfterRevision(parsed.revision)} + LIMIT 1 + `), + ); + return rows.length > 0; +} + /** * Builds a `lineage` capability sourced from `store`'s own recorded * relations. Callers get this indirectly through {@link resolveLineage}; @@ -675,26 +718,86 @@ export function mintsOriginNamespacedAnchor( ); } +/** + * A selected lineage source, with the one thing its `"keys"` delta can leave + * out: `identityChangedSince` is present only on the source whose delta stays + * bounded across an identity write it does not name. + */ +type LineageSource = Readonly<{ + lineage: LineageMembers; + identityChangedSince?: ( + session: LineageSession, + since: EngineRevision, + ) => Promise; +}>; + /** * THE one owner of lineage source selection: the backend's own `lineage` * when it declares one, else the store's recorded-relations lineage when * it captures history, else the trigger-backed revision journal for a - * revision-tracked first-party SQL backend, else `undefined`. Every caller that wants a - * `lineage` — graph-merge's pruned diff among - * them — consults this function instead of re-deriving the choice. + * revision-tracked first-party SQL backend, else `undefined`. Both + * {@link resolveLineage} and {@link changesSinceIncludingIdentity} read the + * choice from here instead of re-deriving it. */ -export function resolveLineage( +function resolveLineageSource( store: RecordedLineageStore, -): LineageMembers | undefined { +): LineageSource | undefined { const backend = storeBackend(store); - if (backend.lineage !== undefined) return backend.lineage; - if (storeCaptureEnabled(store)) return recordedRelationsLineage(store); + if (backend.lineage !== undefined) return { lineage: backend.lineage }; + if (storeCaptureEnabled(store)) { + return { + lineage: recordedRelationsLineage(store), + identityChangedSince: (session, since) => + recordedIdentityChangedSince(store, session, since), + }; + } if ( store.revisionTrackingEnabled && store.revisionJournalEnabled !== false && isFirstPartyFactory(backend) ) { - return revisionJournalLineage(store); + return { lineage: revisionJournalLineage(store) }; } return undefined; } + +/** + * The lineage a store's graph-merge pruning and base tokens read: the source + * {@link resolveLineageSource} selects, or `undefined` when there is none. + * Its `changesSince` is a NODE AND EDGE delta — graph-merge's pruned diff + * reads identity through its own path, and relies on this staying bounded + * across an identity write. + */ +export function resolveLineage( + store: RecordedLineageStore, +): LineageMembers | undefined { + return resolveLineageSource(store)?.lineage; +} + +/** + * The delta `Store.changesSince` answers: the selected source's node and edge + * keys, or `unbounded` whenever an identity change the keys cannot name fell + * in the span. A caller of the public method has only the keys to go on, so an + * identity write must never read as "nothing changed". + * + * The identity probe runs after the key delta, so a write landing between the + * two can only turn a bounded answer into `unbounded`, never hide a change. + */ +export async function changesSinceIncludingIdentity( + store: RecordedLineageStore, + session: LineageSession, + since: EngineRevision, +): Promise { + const source = resolveLineageSource(store); + if (source === undefined) return UNBOUNDED_DELTA; + const delta = await source.lineage.changesSince( + session, + since, + store.graphId, + ); + if (delta.kind === "unbounded") return delta; + if (source.identityChangedSince === undefined) return delta; + return (await source.identityChangedSince(session, since)) ? + UNBOUNDED_DELTA + : delta; +} diff --git a/packages/typegraph/src/store/recorded-capture/relations.ts b/packages/typegraph/src/store/recorded-capture/relations.ts index 6c0246229..e6c82b598 100644 --- a/packages/typegraph/src/store/recorded-capture/relations.ts +++ b/packages/typegraph/src/store/recorded-capture/relations.ts @@ -7,12 +7,46 @@ import { import { RECORDED_MAX_REVISION } from "../../core/temporal"; import { ConfigurationError } from "../../errors"; import { type IdentityAssertionStorageRow } from "../../identity/storage-types"; +import { IDENTITY_TRANSITION_COLUMN_NAMES } from "../../identity/transition-log"; +import { type SqlSchema } from "../../query/compiler/schema"; import { sql, type SqlFragment } from "../../query/sql-fragment"; +import { asCompiledRowsSql } from "../../query/sql-intent"; import { generateId } from "../../utils/id"; import { executeStatement } from "./guards"; export type RecordedOperation = "create" | "update" | "delete"; +/** + * Whether TypeGraph captures recorded history for `graphId`, read as a fact + * of the database on `session` — the one owner of that question for a write + * no Store drives (a standalone schema commit), which has no `history` + * option to consult. + * + * The evidence is a recorded node row for the graph: every TypeGraph-captured + * write lands one, and nothing else does. The recorded clock is not + * evidence — revision tracking without history advances the same clock row. + * A graph that captures history but has not captured a node yet owns no + * identity membership or assertion a schema commit could change, so reading + * it as not capturing loses nothing. Engine-native recorded time never + * populates these relations, so its graphs read as not capturing, matching + * the transition log's own refusal under that ownership. + */ +export async function graphCapturesRecordedHistory( + session: Pick, + schema: SqlSchema, + graphId: string, +): Promise { + const rows = await session.execute>( + asCompiledRowsSql(sql` + SELECT 1 AS captured + FROM ${schema.recordedNodesTable} + WHERE graph_id = ${graphId} + LIMIT 1 + `), + ); + return rows.length > 0; +} + export type RecordedInsert = Readonly<{ row: Row; operation: RecordedOperation; @@ -304,6 +338,12 @@ export function recordedIdentityAssertionChunkSize( return recordedChunkSize(target, RECORDED_IDENTITY_ASSERTION_COLUMNS.length); } +export function recordedIdentityTransitionChunkSize( + target: TransactionBackend, +): number { + return recordedChunkSize(target, IDENTITY_TRANSITION_COLUMN_NAMES.length); +} + export function insertRecordedNodeRows( target: TransactionBackend, table: SqlFragment, diff --git a/packages/typegraph/src/store/recursive-cte.ts b/packages/typegraph/src/store/recursive-cte.ts index d7dc0c0a1..d19c61f67 100644 --- a/packages/typegraph/src/store/recursive-cte.ts +++ b/packages/typegraph/src/store/recursive-cte.ts @@ -13,24 +13,30 @@ import { type SqlSchema, } from "../query/compiler/schema"; import { + compileEdgeMembershipFilters, compileTemporalFilter, currentReadInstant, + type EdgeKindMembership, } from "../query/compiler/temporal"; import { type DialectAdapter } from "../query/dialect/types"; import { sql, type SqlFragment } from "../query/sql-fragment"; +import { + type AcyclicRelationMember, + type ProposedRelationEdge, +} from "./acyclicity"; import { type TraversalDirection } from "./algorithms/types"; import { edgeOrderColumnName, type EdgeReadWindow } from "./neighbors"; /** A node the walk starts from, named by its (kind, id) identity. */ export type ReachableSeed = Readonly<{ kind: string; id: string }>; -type BuildReachableCteOptions = Readonly<{ +/** + * The row-visibility inputs every reachable CTE shares, however it is bounded: + * the graph, the temporal coordinate, the recorded relation, and the engine + * verdict. Neither the walk's bounds nor its source set live here. + */ +type ReachableCteFiltersCore = Readonly<{ graphId: string; - edgeKinds: readonly string[]; - maxHops: number; - direction: TraversalDirection; - cyclePolicy: RecursiveCyclePolicy; - includePath: boolean; /** * Temporal mode applied to both nodes and edges along the traversal. * Callers that want the pre-temporal behavior (soft-delete only) should @@ -54,8 +60,22 @@ type BuildReachableCteOptions = Readonly<{ recursiveTraversal: RecursiveTraversalVerdict; /** Operation label echoed in the refusal's `details.operation`. */ operation: string; - edgeWindows?: Readonly>; -}> & +}>; + +/** + * {@link ReachableCteFiltersCore} plus the three members only a HOP-BOUNDED + * walk can state, and its one-or-many source set. An exhaustive walk has no + * depth ceiling to cap, no path to emit, and no cycle policy to apply (its + * visited set subsumes all three), so it does not take this type: stating the + * absence structurally keeps an exhaustive caller from being asked for a + * `maxHops` that would be silently ignored. + */ +type ReachableCteCore = ReachableCteFiltersCore & + Readonly<{ + maxHops: number; + cyclePolicy: RecursiveCyclePolicy; + includePath: boolean; + }> & ( | Readonly<{ source: ReachableSeed; sources?: never }> | Readonly<{ source?: never; sources: readonly ReachableSeed[] }> @@ -65,22 +85,63 @@ function compileSeedFilter(seed: ReachableSeed): SqlFragment { return sql`(n.kind = ${seed.kind} AND n.id = ${seed.id})`; } +type BuildReachableCteOptions = ReachableCteCore & + Readonly<{ + edgeKinds: readonly string[]; + direction: TraversalDirection; + edgeWindows?: Readonly>; + }>; + +type PreparedReachableCte = Readonly<{ + baseCase: SqlFragment; + recursiveColumns: readonly SqlFragment[]; + recursiveWhere: readonly SqlFragment[]; + /** + * The edge-row visibility predicates (graph, kind, temporal) the recursive + * WHERE opens with — also the filter a pre-ranked edge relation applies + * before its window, so both read the same rows. + */ + edgeWhere: readonly SqlFragment[]; + forceWorktableOuterJoinOrder: boolean; + schema: SqlSchema; +}>; + +type ReachableCteFilters = Readonly<{ + edgeWhere: readonly SqlFragment[]; + nodeTemporalFilter: SqlFragment; + schema: SqlSchema; + forceWorktableOuterJoinOrder: boolean; +}>; + /** - * Builds the `reachable(id, kind, depth[, path][, origin_kind, origin_id])` - * walk. Every step is keyed on the (kind, id) pair: node rows are keyed by - * (kind, id), so two nodes of different kinds may share an id, and a step - * keyed on the id alone would follow the other node's edges or read reaching - * it as a revisit. Multi-seed walks (`sources`) tag each row with the seed it - * descends from. + * Everything a reachable-CTE's base case and recursive WHERE clauses need + * that does not depend on direction OR on how the walk is bounded: the + * edge-kind filter (evaluated against whatever superset of kinds the caller's + * shape requires), the node temporal filter and the edge row filter, the + * recorded-schema swap, and the dialect's worktable join-order fact. + * + * The edge row filter is the read's validity window, unless the walk is over + * a membership relation (`edgeMembership`): then it is that relation's + * membership at the read's coordinate ({@link compileEdgeMembershipFilters}), + * because an owned part is reached through the rows that bind it, not through + * the rows still valid. + * + * Shared by the hop-bounded preparation ({@link prepareReachableCte}, used by + * {@link buildReachableCte}) and by the + * exhaustive, set-semantics one + * ({@link prepareExhaustiveReachableCte}, used by + * {@link buildExhaustiveDirectedReachableCte}), so no traversal can differ + * from another in WHICH rows it is allowed to see — only in how it terminates. */ -export function buildReachableCte( - options: BuildReachableCteOptions, -): SqlFragment { +function prepareReachableFilters( + options: ReachableCteFiltersCore, + edgeKindsForFilter: readonly string[], + edgeMembership?: readonly EdgeKindMembership[], +): ReachableCteFilters { assertRecursiveTraversal(options.recursiveTraversal, options.operation); - const trackPath = options.cyclePolicy === "prevent" || options.includePath; const edgeKindFilter = compileKindFilter( sql.raw("e.kind"), - options.edgeKinds, + edgeKindsForFilter, ); const currentTimestamp = options.currentTimestamp ?? currentReadInstant(); const nodeTemporalFilter = compileTemporalFilter({ @@ -91,14 +152,26 @@ export function buildReachableCte( currentTimestamp, recordedReadBinding: options.recordedReadBinding, }); - const edgeTemporalFilter = compileTemporalFilter({ - mode: options.temporalMode, - asOf: options.asOf, - recordedAsOf: options.recordedAsOf, - tableAlias: "e", - currentTimestamp, - recordedReadBinding: options.recordedReadBinding, - }); + const edgeRowFilters = + edgeMembership === undefined ? + [ + compileTemporalFilter({ + mode: options.temporalMode, + asOf: options.asOf, + recordedAsOf: options.recordedAsOf, + tableAlias: "e", + currentTimestamp, + recordedReadBinding: options.recordedReadBinding, + }), + ] + : compileEdgeMembershipFilters({ + members: edgeMembership, + tableAlias: "e", + mode: options.temporalMode, + asOf: options.asOf, + recordedAsOf: options.recordedAsOf, + recordedReadBinding: options.recordedReadBinding, + }); // Derive the read schema from the same `recordedAsOf` that drives the temporal // filters above: when a recorded pin is set the node/edge sources become the // recorded relations, matching the `recorded_from/to` interval predicate. One @@ -109,10 +182,38 @@ export function buildReachableCte( options.recordedReadBinding, "recorded-recursive-cte", ); - const edgeWindows = Object.entries(options.edgeWindows ?? {}).filter( - (entry): entry is [string, EdgeReadWindow] => entry[1] !== undefined, - ); + return { + edgeWhere: [ + sql`e.graph_id = ${options.graphId}`, + edgeKindFilter, + ...edgeRowFilters, + ], + nodeTemporalFilter, + schema, + forceWorktableOuterJoinOrder: + options.dialect.capabilities.forceRecursiveWorktableOuterJoinOrder, + }; +} + +/** + * The HOP-BOUNDED preparation: a `depth` column capped at `maxHops`, plus the + * path tracking `cyclePolicy: "prevent"` / `includePath` ask for. Termination + * is numeric, which is what lets the recursive term be `UNION ALL` (one row + * per distinct path) and what makes `includePath`/cycle prevention + * expressible at all. + */ +function prepareReachableCte( + options: ReachableCteCore, + edgeKindsForFilter: readonly string[], +): PreparedReachableCte { + const { + edgeWhere, + nodeTemporalFilter, + schema, + forceWorktableOuterJoinOrder, + } = prepareReachableFilters(options, edgeKindsForFilter); + const trackPath = options.cyclePolicy === "prevent" || options.includePath; const pathToken = compileKindQualifiedPathToken("n"); const initialPath = trackPath ? options.dialect.initializePath(pathToken) : undefined; @@ -154,29 +255,84 @@ export function buildReachableCte( recursiveColumns.push(sql`r.origin_kind`, sql`r.origin_id`); const recursiveWhere: SqlFragment[] = [ - sql`e.graph_id = ${options.graphId}`, - edgeKindFilter, - edgeTemporalFilter, + ...edgeWhere, nodeTemporalFilter, sql`r.depth < ${options.maxHops}`, ]; if (cycleCheck !== undefined) recursiveWhere.push(cycleCheck); - const forceWorktableOuterJoinOrder = - options.dialect.capabilities.forceRecursiveWorktableOuterJoinOrder; - - const usesOrientedEdges = edgeWindows.length > 0; - const recursiveCase = compileRecursiveBranch({ + return { + baseCase, recursiveColumns, - whereClauses: recursiveWhere, - direction: usesOrientedEdges ? "out" : options.direction, + recursiveWhere, + edgeWhere, forceWorktableOuterJoinOrder, schema, + }; +} + +/** + * The EXHAUSTIVE preparation: `(id, kind)` and nothing else — no `depth`, no + * `path` — so the recursive term can be `UNION` and the visited set itself is + * the bound. This is the same device the acyclicity probe uses + * ({@link buildEdgeAcyclicityProbe}): set semantics on the frontier's full + * column list is what makes an unbounded recursion terminate on a finite + * graph with no depth ceiling and no cycle check, and it is why a `depth` + * column must NOT be added back here — a per-path depth would make the same + * node distinct once per path length and turn the fixpoint into an open walk. + */ +function prepareExhaustiveReachableCte( + options: BuildExhaustiveDirectedReachableCteOptions, + edgeKindsForFilter: readonly string[], +): PreparedReachableCte { + const { + edgeWhere, + nodeTemporalFilter, + schema, + forceWorktableOuterJoinOrder, + } = prepareReachableFilters( + options, + edgeKindsForFilter, + options.edgeMembership, + ); + + return { + baseCase: sql`SELECT n.id, n.kind FROM ${schema.nodesTable} n WHERE n.graph_id = ${options.graphId} AND ${compileSeedFilter(options.source)} AND ${nodeTemporalFilter}`, + recursiveColumns: [sql`n.id`, sql`n.kind`], + recursiveWhere: [...edgeWhere, nodeTemporalFilter], + edgeWhere, + forceWorktableOuterJoinOrder, + schema, + }; +} + +/** + * The pre-ranked edge relation a windowed walk expands through. Each window + * keeps only the newest N edges per source endpoint, oriented uniformly as + * `typegraph_source_*` / `typegraph_target_*`, so the recursive term walks it + * "out" whatever direction each kind was requested in. + */ +const WINDOWED_EDGES_CTE = "typegraph_windowed_edges"; + +export function buildReachableCte( + options: BuildReachableCteOptions, +): SqlFragment { + const prepared = prepareReachableCte(options, options.edgeKinds); + const windowed = Object.values(options.edgeWindows ?? {}).some( + (window) => window !== undefined, + ); + + const recursiveCase = compileRecursiveBranch({ + recursiveColumns: prepared.recursiveColumns, + whereClauses: prepared.recursiveWhere, + direction: windowed ? "out" : options.direction, + forceWorktableOuterJoinOrder: prepared.forceWorktableOuterJoinOrder, + schema: prepared.schema, edgesTable: - usesOrientedEdges ? - sql.identifier("typegraph_windowed_edges") - : schema.edgesTable, - ...(usesOrientedEdges && { + windowed ? + sql.identifier(WINDOWED_EDGES_CTE) + : prepared.schema.edgesTable, + ...(windowed && { joinKindField: "typegraph_source_kind", joinField: "typegraph_source_id", targetField: "typegraph_target_id", @@ -184,25 +340,94 @@ export function buildReachableCte( }), }); - const windowedEdges = - usesOrientedEdges ? - buildWindowedEdgesCte( - schema.edgesTable, - options.direction, - options.edgeKinds.map((kind) => [kind, options.edgeWindows?.[kind]]), - sql.join( - [ - sql`e.graph_id = ${options.graphId}`, - edgeKindFilter, - edgeTemporalFilter, - ], - sql` AND `, - ), - ) - : undefined; - return windowedEdges === undefined ? - sql`WITH RECURSIVE reachable AS (${baseCase} UNION ALL ${recursiveCase})` - : sql`WITH RECURSIVE typegraph_windowed_edges AS (${windowedEdges}), reachable AS (${baseCase} UNION ALL ${recursiveCase})`; + if (!windowed) { + return sql`WITH RECURSIVE reachable AS (${prepared.baseCase} UNION ALL ${recursiveCase})`; + } + const windowedEdges = buildWindowedEdgesCte( + prepared.schema.edgesTable, + options.direction, + options.edgeKinds.map((kind) => [kind, options.edgeWindows?.[kind]]), + sql.join([...prepared.edgeWhere], sql` AND `), + ); + return sql`WITH RECURSIVE ${sql.raw(WINDOWED_EDGES_CTE)} AS (${windowedEdges}), reachable AS (${prepared.baseCase} UNION ALL ${recursiveCase})`; +} + +type BuildExhaustiveDirectedReachableCteOptions = ReachableCteFiltersCore & + Readonly<{ + /** + * The node the walk starts from. The walk is keyed by `(kind, id)` end to + * end — seeded from this node, and each hop following an edge from the + * frontier row's own `(kind, id)` — so a node of another kind that shares + * an id is never mistaken for a member of the closure. + */ + source: ReachableSeed; + /** Edge kinds walked in the "out" direction (`e.from_id = r.id`). */ + outEdgeKinds: readonly string[]; + /** Edge kinds walked in the "in" direction (`e.to_id = r.id`). */ + inEdgeKinds: readonly string[]; + /** + * The membership rule of every walked edge kind. The closure follows the + * rows that bind a part to its whole at the read's instant, never the + * read's validity window: an ended row of an `"allRows"` kind still binds, + * and a read that widens visibility (`includeEnded`) does not re-attach an + * ended `"openRows"` row. + */ + edgeMembership: readonly EdgeKindMembership[]; + }>; + +/** + * A reachable CTE whose recursive term walks two edge-kind groups in two + * different, FIXED directions, bounded by its visited set rather than by a hop + * ceiling. Every hop tries both groups against the current frontier row, + * unioned within the SAME recursive term via an OR on the join condition, + * never as two separate recursive terms (PostgreSQL refuses more than one + * self-reference in a recursive CTE, even split across `UNION` branches). + * + * Two fixed directions are what let a composition closure cross a relation + * that mixes `part -> whole` and `whole -> part` (`has_*`) realizing edges + * across levels of the same tree, without walking `direction: "both"` — which + * would also climb from a mid-tree root to its ancestors and re-descend into + * siblings. A uniform `edgeKinds`+`direction` traversal + * ({@link buildReachableCte}) cannot express "these kinds forward, those kinds + * reversed" in one term. + * + * `UNION`, never `UNION ALL`, over a frontier of exactly `(id, kind)` — the + * device the acyclicity probe uses + * ({@link buildEdgeAcyclicityProbe}): set semantics on the frontier's whole + * column list makes the recursion reach a fixpoint on any finite graph, so + * there is no depth ceiling to exceed and no cycle check to carry. A cyclic + * relation terminates too (a revisited node adds no new row), which is what + * lets the caller drop `cyclePolicy` rather than choose one. + * + * Exists for the one reader whose correctness IS completeness: + * `subgraph({ composition: true })` returns a whole plus its owned unit, and + * a truncated prefix of a part tree is not an export unit — reloading it + * would silently drop the tail of every deep subtree, and a required part cut + * off from its whole cannot even be recreated. Every other traversal is a + * breadth CHOICE the caller states in hops, and keeps + * {@link buildReachableCte}. + */ +export function buildExhaustiveDirectedReachableCte( + options: BuildExhaustiveDirectedReachableCteOptions, +): SqlFragment { + const prepared = prepareExhaustiveReachableCte(options, [ + ...options.outEdgeKinds, + ...options.inEdgeKinds, + ]); + + const recursiveCase = buildDirectedGroupsBranch({ + recursiveColumns: prepared.recursiveColumns, + whereClauses: prepared.recursiveWhere, + outEdgeKinds: options.outEdgeKinds, + inEdgeKinds: options.inEdgeKinds, + forceWorktableOuterJoinOrder: prepared.forceWorktableOuterJoinOrder, + schema: prepared.schema, + }); + + // The explicit `(id, kind)` column list is what the `UNION` dedupes on, and + // it names the columns `included_ids` reads back (the same device + // `ancestry(origin_key, node_kind, node_id)` uses in the acyclicity probe). + return sql`WITH RECURSIVE reachable(id, kind) AS (${prepared.baseCase} UNION ${recursiveCase})`; } type CompileRecursiveBranchOptions = Readonly<{ @@ -262,6 +487,38 @@ function compileRecursiveBranch( } } +type RecursiveFrontierBranchOptions = Readonly<{ + selectClause: SqlFragment; + /** How an edge row matches the frontier row `r`. */ + joinCondition: SqlFragment; + /** The nodes-table join that resolves the matched row's far endpoint. */ + nodeJoin: SqlFragment; + whereClauses: readonly SqlFragment[]; + forceWorktableOuterJoinOrder: boolean; + /** The edge relation `e` reads: the edges table or a pre-ranked window of it. */ + edgesTable: SqlFragment; +}>; + +/** + * THE recursive term every frontier traversal emits: the one owner of the + * `forceWorktableOuterJoinOrder` fork, so each branch builder supplies only + * its own `joinCondition` and `nodeJoin`. + * + * A dialect that forces worktable-outer join order cannot take the frontier + * join as a `JOIN ... ON`; the condition moves into the `WHERE` list behind a + * `CROSS JOIN` instead, which is semantically the same predicate and leaves + * the planner the order it requires. + */ +function recursiveFrontierBranch( + options: RecursiveFrontierBranchOptions, +): SqlFragment { + if (options.forceWorktableOuterJoinOrder) { + const allWhere = [...options.whereClauses, options.joinCondition]; + return sql`${options.selectClause} FROM reachable r CROSS JOIN ${options.edgesTable} e ${options.nodeJoin} WHERE ${sql.join(allWhere, sql` AND `)}`; + } + return sql`${options.selectClause} FROM reachable r JOIN ${options.edgesTable} e ON ${options.joinCondition} ${options.nodeJoin} WHERE ${sql.join([...options.whereClauses], sql` AND `)}`; +} + type DirectionalBranchOptions = Readonly<{ selectClause: SqlFragment; whereClauses: readonly SqlFragment[]; @@ -279,14 +536,14 @@ function buildDirectionalBranch( ): SqlFragment { const nodeJoin = sql`JOIN ${options.schema.nodesTable} n ON n.graph_id = e.graph_id AND n.id = e.${sql.raw(options.targetField)} AND n.kind = e.${sql.raw(options.targetKindField)}`; - const worktableJoin = sql`e.${sql.raw(options.joinKindField)} = r.kind AND e.${sql.raw(options.joinField)} = r.id`; - - if (options.forceWorktableOuterJoinOrder) { - const allWhere = [...options.whereClauses, worktableJoin]; - return sql`${options.selectClause} FROM reachable r CROSS JOIN ${options.edgesTable} e ${nodeJoin} WHERE ${sql.join(allWhere, sql` AND `)}`; - } - - return sql`${options.selectClause} FROM reachable r JOIN ${options.edgesTable} e ON ${worktableJoin} ${nodeJoin} WHERE ${sql.join([...options.whereClauses], sql` AND `)}`; + return recursiveFrontierBranch({ + selectClause: options.selectClause, + joinCondition: sql`e.${sql.raw(options.joinKindField)} = r.kind AND e.${sql.raw(options.joinField)} = r.id`, + nodeJoin, + whereClauses: options.whereClauses, + forceWorktableOuterJoinOrder: options.forceWorktableOuterJoinOrder, + edgesTable: options.edgesTable, + }); } type BidirectionalBranchOptions = Readonly<{ @@ -303,14 +560,15 @@ function buildBidirectionalBranch( // PostgreSQL rejects multiple non-recursive terms, so both directions are // folded into a single UNION ALL branch via an OR on the join condition. const nodeJoin = sql`JOIN ${options.schema.nodesTable} n ON n.graph_id = e.graph_id AND ((e.to_kind = r.kind AND e.to_id = r.id AND n.id = e.from_id AND n.kind = e.from_kind) OR (e.from_kind = r.kind AND e.from_id = r.id AND n.id = e.to_id AND n.kind = e.to_kind))`; - const worktableJoin = sql`((e.from_kind = r.kind AND e.from_id = r.id) OR (e.to_kind = r.kind AND e.to_id = r.id))`; - - if (options.forceWorktableOuterJoinOrder) { - const allWhere = [...options.whereClauses, worktableJoin]; - return sql`${options.selectClause} FROM reachable r CROSS JOIN ${options.edgesTable} e ${nodeJoin} WHERE ${sql.join(allWhere, sql` AND `)}`; - } - return sql`${options.selectClause} FROM reachable r JOIN ${options.edgesTable} e ON ${worktableJoin} ${nodeJoin} WHERE ${sql.join([...options.whereClauses], sql` AND `)}`; + return recursiveFrontierBranch({ + selectClause: options.selectClause, + joinCondition: sql`((e.from_kind = r.kind AND e.from_id = r.id) OR (e.to_kind = r.kind AND e.to_id = r.id))`, + nodeJoin, + whereClauses: options.whereClauses, + forceWorktableOuterJoinOrder: options.forceWorktableOuterJoinOrder, + edgesTable: options.edgesTable, + }); } export function buildWindowedEdgesCte( @@ -345,3 +603,474 @@ export function buildWindowedEdgesCte( }); return sql.join(branches, sql` UNION ALL `); } + +type DirectedGroupsBranchOptions = Readonly<{ + recursiveColumns: readonly SqlFragment[]; + whereClauses: readonly SqlFragment[]; + outEdgeKinds: readonly string[]; + inEdgeKinds: readonly string[]; + forceWorktableOuterJoinOrder: boolean; + schema: SqlSchema; +}>; + +/** + * The directed-groups counterpart of {@link buildBidirectionalBranch}: an + * edge row matches this frontier row either by starting from it (`e.from_kind + * = r.kind AND e.from_id = r.id`), restricted to `outEdgeKinds`, or by ending + * at it (`e.to_kind = r.kind AND e.to_id = r.id`), restricted to + * `inEdgeKinds` — never both regardless of kind, which is what + * `buildBidirectionalBranch` does instead. Both arms match the frontier + * row's KIND as well as its id: node ids are unique per kind only, and the + * composition closure this serves is keyed by `(kind, id)` like `parts()` and + * the cascade. An edge kind present in + * neither group can never match (`compileKindFilter([])` compiles to + * `1 = 0`), so a caller may pass one empty group for a uniform-orientation + * relation and get exactly the single-direction shape. + */ +function buildDirectedGroupsBranch( + options: DirectedGroupsBranchOptions, +): SqlFragment { + const selectClause = sql`SELECT ${sql.join([...options.recursiveColumns], sql`, `)}`; + const outKindFilter = compileKindFilter( + sql.raw("e.kind"), + options.outEdgeKinds, + ); + const inKindFilter = compileKindFilter( + sql.raw("e.kind"), + options.inEdgeKinds, + ); + const joinCondition = sql`((e.from_id = r.id AND e.from_kind = r.kind AND (${outKindFilter})) OR (e.to_id = r.id AND e.to_kind = r.kind AND (${inKindFilter})))`; + const nodeJoin = sql`JOIN ${options.schema.nodesTable} n ON n.graph_id = e.graph_id AND ((e.from_id = r.id AND e.from_kind = r.kind AND (${outKindFilter}) AND n.id = e.to_id AND n.kind = e.to_kind) OR (e.to_id = r.id AND e.to_kind = r.kind AND (${inKindFilter}) AND n.id = e.from_id AND n.kind = e.from_kind))`; + + return recursiveFrontierBranch({ + selectClause, + joinCondition, + nodeJoin, + whereClauses: options.whereClauses, + forceWorktableOuterJoinOrder: options.forceWorktableOuterJoinOrder, + edgesTable: options.schema.edgesTable, + }); +} + +// ============================================================ +// Edge-acyclicity probe +// ============================================================ + +/** + * The row source `buildEdgeAcyclicityProbe`'s `seed` CTE reads from, and — + * critically — whether `ancestry` may hop THROUGH those rows on top of the + * relation's live edges: + * + * - `"proposed"` — edges the calling write path has already made visible to + * the probing transaction (an insert earlier in the SAME transaction), OR + * a single row. A single row never needs a seed-hop: the question this + * probe answers is "does `to` already reach `from`", and a row cannot help + * answer that about ITSELF by being hopped through as an intermediate + * step. **Contract:** a caller passing more than one `"proposed"` row + * thereby asserts those rows are already inserted in the probing + * transaction (import and resurrection satisfy this; single create + * proposes exactly one row). + * - `"unwritten"` — rows NOT YET written anywhere: a batch probed before its + * first write (`assertUnwrittenEdgeRelationsAcyclic`, + * `src/store/acyclicity.ts`) and the graph-merge plan-time preview + * (`readProposedEdgeAcyclicityViolations`). The walk is the `"proposed"` + * one — live edges only, joined directly, so it stays an index seek — but + * the closing select differs: it returns one `(origin_key, reached_key)` + * row for every `targets` row whose `from` an origin's `to` reaches. The + * caller composes those reaches with the unwritten edges in memory, which + * is what finds a cycle unwritten rows close through each other without the + * walk hopping through rows that do not exist. Origins (`edges`) and + * `targets` are separate row lists so a caller can send them in slices that + * fit the engine's bind budget and still collect every origin/target pair. + * - `"relation"` — every live edge of the relation (the audit and + * schema-tightening forms). `ancestry` joins `typegraph_edges` directly, + * same as `"proposed"`. + */ +export type AcyclicityProbeSeed = + | Readonly<{ kind: "proposed"; edges: readonly ProposedRelationEdge[] }> + | Readonly<{ + kind: "unwritten"; + edges: readonly ProposedRelationEdge[]; + targets: readonly ProposedRelationEdge[]; + }> + | Readonly<{ kind: "relation" }>; + +/** Bound parameters one `seed` row of a row-list form renders. */ +export const ACYCLICITY_SEED_ROW_PARAM_COUNT = 5; +/** Bound parameters one `targets` row of the `"unwritten"` form renders. */ +export const ACYCLICITY_TARGET_ROW_PARAM_COUNT = 3; + +function kindKeys(members: readonly AcyclicRelationMember[]): { + forward: readonly string[]; + reversed: readonly string[]; + all: readonly string[]; +} { + const forward = members + .filter((member) => !member.reversed) + .map((member) => member.edgeKind); + const reversed = members + .filter((member) => member.reversed) + .map((member) => member.edgeKind); + return { forward, reversed, all: [...forward, ...reversed] }; +} + +/** + * Which stored rows are in the relation now, each member under its own rule + * ({@link compileEdgeMembershipFilters}): the one spelling every reader of + * stored rows below appends, so the seed and the walk cannot disagree about + * which rows are in it. A relation whose members all count every row reads + * `deleted_at IS NULL` alone, so every standalone relation's statement stays + * byte-identical. Rows the caller supplies (`proposedSeedRow`, + * `buildAcyclicityTargets`) were already judged by the same rule + * (`acyclicRelationCountsRow`, `src/store/acyclicity.ts`) before they reach a + * statement. + */ +function relationMembershipFilters( + members: readonly AcyclicRelationMember[], +): readonly SqlFragment[] { + return compileEdgeMembershipFilters({ + members, + tableAlias: "e", + mode: "current", + }); +} + +function reversedForEdgeKind( + members: readonly AcyclicRelationMember[], + edgeKind: string, +): boolean { + return members.some( + (member) => member.edgeKind === edgeKind && member.reversed, + ); +} + +/** {@link orientedEndpointColumns}'s return shape: the row's "part" (`match`) and "whole" (`next`) endpoint columns. */ +type OrientedEndpointColumns = Readonly<{ + matchKind: SqlFragment; + matchId: SqlFragment; + nextKind: SqlFragment; + nextId: SqlFragment; +}>; + +/** + * The ONE owner of "which stored endpoint (`from` or `to`) is the relation's + * part (`match`) and which is the whole (`next`), for a table-aliased edges + * row." Every member forward — the only shape a standalone `acyclic: true` + * relation ever produces — is a plain column pair with no `CASE`, + * byte-identical to a hand-written `SELECT` (the benchmark's + * index-only-scan assumption depends on this). A relation with a reversed + * member instead needs a `CASE` + * on the row's own `kind` to pick the pair per row. + * + * `buildAcyclicitySeed`'s `"relation"` form, `buildLiveEdgeCandidates`, and + * `buildAcyclicityAncestryStepDirect`'s next-node projection all call this + * instead of re-spelling the `CASE` — see AGENTS.md "one predicate, one + * owner". `buildAcyclicityAncestryStepDirect`'s JOIN predicate is the one + * caller that CANNOT use `matchKind`/`matchId` here: a `CASE`-wrapped + * equality is not sargable, and that predicate must stay an OR of two + * plain-column arms for SQLite's OR-optimization / PostgreSQL's `BitmapOr` + * to seek both `typegraph_edges_from_idx` and `typegraph_edges_to_idx` (see + * that function's own docblock). `proposedSeedRow` answers the identical + * question at the single-edge, JS-value level via {@link reversedForEdgeKind} + * rather than a `SqlFragment` — both trace back to `member.reversed` on + * `members` with no independent re-derivation of the decision. + */ +function orientedEndpointColumns( + members: readonly AcyclicRelationMember[], + alias: string, +): OrientedEndpointColumns { + const { forward, all } = kindKeys(members); + const fromKind = sql.raw(`${alias}.from_kind`); + const fromId = sql.raw(`${alias}.from_id`); + const toKind = sql.raw(`${alias}.to_kind`); + const toId = sql.raw(`${alias}.to_id`); + + if (forward.length === all.length) { + return { + matchKind: fromKind, + matchId: fromId, + nextKind: toKind, + nextId: toId, + }; + } + + const isForwardRow = compileKindFilter(sql.raw(`${alias}.kind`), forward); + return { + matchKind: sql`CASE WHEN ${isForwardRow} THEN ${fromKind} ELSE ${toKind} END`, + matchId: sql`CASE WHEN ${isForwardRow} THEN ${fromId} ELSE ${toId} END`, + nextKind: sql`CASE WHEN ${isForwardRow} THEN ${toKind} ELSE ${fromKind} END`, + nextId: sql`CASE WHEN ${isForwardRow} THEN ${toId} ELSE ${fromId} END`, + }; +} + +/** A proposed edge's endpoints in the relation's part->whole orientation. */ +function orientProposedEdge( + edge: ProposedRelationEdge, + members: readonly AcyclicRelationMember[], +): Readonly<{ + fromKind: string; + fromId: string; + toKind: string; + toId: string; +}> { + return reversedForEdgeKind(members, edge.edgeKind) ? + { + fromKind: edge.toKind, + fromId: edge.toId, + toKind: edge.fromKind, + toId: edge.fromId, + } + : { + fromKind: edge.fromKind, + fromId: edge.fromId, + toKind: edge.toKind, + toId: edge.toId, + }; +} + +/** One literal `seed` row for the `"proposed"` form, oriented part->whole. */ +function proposedSeedRow( + edge: ProposedRelationEdge, + members: readonly AcyclicRelationMember[], +): SqlFragment { + const { fromKind, fromId, toKind, toId } = orientProposedEdge(edge, members); + return sql`(CAST(${edge.edgeId} AS TEXT), CAST(${fromKind} AS TEXT), CAST(${fromId} AS TEXT), CAST(${toKind} AS TEXT), CAST(${toId} AS TEXT))`; +} + +/** + * The `seed` CTE body, oriented endpoints throughout: a `VALUES` row list + * for the row-list forms (`"proposed"` and `"unwritten"` — identical shape, since each names rows the caller supplies rather than + * reading the table), or the relation's own live edges for the `"relation"` (audit) + * form. + */ +function buildAcyclicitySeed( + seed: AcyclicityProbeSeed, + members: readonly AcyclicRelationMember[], + graphId: string, + schema: SqlSchema, +): SqlFragment { + if (seed.kind !== "relation") { + // A `VALUES` row list, never `SELECT ... UNION ALL SELECT ...`: a batch + // create can propose thousands of origins in one probe, and SQLite caps + // a compound SELECT at `SQLITE_LIMIT_COMPOUND_SELECT` (500 terms by + // default) — a limit `VALUES` is not subject to. + const rows = seed.edges.map((edge) => proposedSeedRow(edge, members)); + return sql`seed(origin_key, from_kind, from_id, to_kind, to_id) AS (VALUES ${sql.join(rows, sql`, `)})`; + } + const { all } = kindKeys(members); + const edgeKindFilter = compileKindFilter(sql.raw("e.kind"), all); + const { matchKind, matchId, nextKind, nextId } = orientedEndpointColumns( + members, + "e", + ); + const rowFilters = [ + sql`e.graph_id = ${graphId}`, + edgeKindFilter, + ...relationMembershipFilters(members), + ]; + return sql`seed(origin_key, from_kind, from_id, to_kind, to_id) AS (SELECT e.id, ${matchKind}, ${matchId}, ${nextKind}, ${nextId} FROM ${schema.edgesTable} e WHERE ${sql.join(rowFilters, sql` AND `)})`; +} + +/** + * The `"unwritten"` form's `targets` CTE: one row per unwritten edge whose + * oriented `from` the closing select tests each origin's ancestry against. + */ +function buildAcyclicityTargets( + targets: readonly ProposedRelationEdge[], + members: readonly AcyclicRelationMember[], +): SqlFragment { + const rows = targets.map((edge) => { + const { fromKind, fromId } = orientProposedEdge(edge, members); + return sql`(CAST(${edge.edgeId} AS TEXT), CAST(${fromKind} AS TEXT), CAST(${fromId} AS TEXT))`; + }); + return sql`targets(reached_key, from_kind, from_id) AS (VALUES ${sql.join(rows, sql`, `)})`; +} + +/** What the `ancestry` step needs beyond the seed it starts from. */ +type AcyclicityProbeBodyOptions = Readonly<{ + members: readonly AcyclicRelationMember[]; + graphId: string; + schema: SqlSchema; + dialect: DialectAdapter; + forceWorktableOuterJoinOrder: boolean; + excludedEdgeIds: readonly string[]; +}>; + +/** + * The `ancestry` recursive term, the same for every seed form: one hop + * DIRECTLY against `typegraph_edges`. A compound CTE between `ancestry` and + * the edges table could not be flattened by SQLite's query flattener (rule + * 17d) and would be materialized — the entire relation copied into an + * ephemeral table on every probe — where this shape is an index seek. + * + * `excludedEdgeIds` drops stored rows a caller knows will be gone when its + * own rows land (a merge plan's edge deletions). They travel as ONE packed + * bound value through the dialect's `inListParameter` seam, so their number + * never counts against the engine's bind budget. + * + * The common case (no `reversed` member — the only shape a standalone + * `acyclic: true` relation ever produces) is a plain equality join on `from_kind`/`from_id`, seekable by + * `typegraph_edges_from_idx`. `forceWorktableOuterJoinOrder` moves the join + * field predicate into the `WHERE` clause behind a `CROSS JOIN`, the same fork + * {@link recursiveFrontierBranch} owns above — every other predicate (`graph_id`, the + * edge-kind filter, `deleted_at`) always lives in `WHERE` regardless. + * + * A relation with a `reversed` member (the composition relation's mixed + * orientation — a standalone `acyclic: true` relation never produces one, but + * the type and this code path are exercised by the mixed-orientation fixture) is still ONE join, never a compound: an + * OR of two index-seekable arms, one per orientation — + * `(e.kind IN (forward) AND e.from_kind = a.node_kind AND e.from_id = + * a.node_id) OR (e.kind IN (reversed) AND e.to_kind = a.node_kind AND + * e.to_id = a.node_id)` — with the "next node" projected by a `CASE` on + * which arm matched. This is what lets SQLite's OR-optimization and + * PostgreSQL's `BitmapOr` seek both `typegraph_edges_from_idx` and + * `typegraph_edges_to_idx` in the same step, mirroring + * `buildBidirectionalBranch`'s two-direction device above. + */ +function buildAcyclicityAncestryStepDirect( + options: AcyclicityProbeBodyOptions, +): SqlFragment { + const { members, graphId, schema, forceWorktableOuterJoinOrder } = options; + const { forward, reversed, all } = kindKeys(members); + const commonWhere = [ + sql`e.graph_id = ${graphId}`, + ...relationMembershipFilters(members), + ...(options.excludedEdgeIds.length === 0 ? + [] + : [ + options.dialect.inListParameter( + sql.raw("e.id"), + sql`${options.dialect.packListValue(options.excludedEdgeIds)}`, + { negated: true, elementType: undefined }, + ), + ]), + ]; + // The "next node" projection is shared via `orientedEndpointColumns`. The + // JOIN predicate below is NOT: see that function's docblock for why a + // CASE-wrapped equality can't stand in for the OR-of-arms shape here. + const { nextKind, nextId } = orientedEndpointColumns(members, "e"); + + const { joinPredicate, whereClauses } = + reversed.length === 0 ? + { + joinPredicate: sql`e.from_kind = a.node_kind AND e.from_id = a.node_id`, + whereClauses: [ + ...commonWhere, + compileKindFilter(sql.raw("e.kind"), all), + ], + } + : { + joinPredicate: mixedOrientationJoinPredicate(forward, reversed), + whereClauses: commonWhere, + }; + const selectClause = sql`SELECT a.origin_key, ${nextKind}, ${nextId} FROM ancestry a`; + if (forceWorktableOuterJoinOrder) { + return sql`${selectClause} CROSS JOIN ${schema.edgesTable} e WHERE ${sql.join([...whereClauses, joinPredicate], sql` AND `)}`; + } + return sql`${selectClause} JOIN ${schema.edgesTable} e ON ${joinPredicate} WHERE ${sql.join(whereClauses, sql` AND `)}`; +} + +/** + * The mixed-orientation ancestry JOIN predicate. A `CASE`-wrapped equality + * (`CASE WHEN ... END = a.node_kind`) is not sargable, so it stays a + * hand-written OR of two plain-column, index-seekable arms rather than + * routing through {@link orientedEndpointColumns}'s `matchKind`/`matchId` — + * that's what lets SQLite's OR-optimization and PostgreSQL's `BitmapOr` seek + * both `typegraph_edges_from_idx` and `typegraph_edges_to_idx` in one step + * (see {@link buildAcyclicityAncestryStepDirect}'s docblock). + */ +function mixedOrientationJoinPredicate( + forward: readonly string[], + reversed: readonly string[], +): SqlFragment { + const forwardFilter = compileKindFilter(sql.raw("e.kind"), forward); + const reversedFilter = compileKindFilter(sql.raw("e.kind"), reversed); + const forwardArm = sql`(${forwardFilter} AND e.from_kind = a.node_kind AND e.from_id = a.node_id)`; + const reversedArm = sql`(${reversedFilter} AND e.to_kind = a.node_kind AND e.to_id = a.node_id)`; + return sql`(${forwardArm} OR ${reversedArm})`; +} + +/** + * Builds the exhaustive, set-semantics reachability probe the acyclicity + * check runs: "does `from` lie in the reflexive-transitive + * closure of `to`" over one acyclic relation's live edges. + * + * Deliberately a sibling export in this file rather than a new emitter: the + * seed forms are the ONLY difference between the write-path probe, the audit + * reader, and the merge plan-time preview, so all are built by this one + * function and cannot answer the question differently. + * + * `UNION`, never `UNION ALL`, on the `ancestry` term: set semantics on + * `(origin_key, node_kind, node_id)` is what makes an unbounded recursion + * terminate on a finite graph with no path tracking and no depth bound — + * `MAX_EXPLICIT_RECURSIVE_DEPTH` does not apply to this probe. + * + * Exactly ONE literal `WITH RECURSIVE` occurs in this function (the return + * below) regardless of which seed form runs, so + * `tests/recursive-traversal-inventory.test.ts`'s emission-site inventory + * still counts exactly one site here. + * + * Every literal seed column is `CAST(... AS TEXT)`: PostgreSQL cannot infer + * the type of a bare bound parameter in a `SELECT` list with no surrounding + * column context. + */ +export function buildEdgeAcyclicityProbe( + options: Readonly<{ + graphId: string; + members: readonly AcyclicRelationMember[]; + seed: AcyclicityProbeSeed; + dialect: DialectAdapter; + schema: SqlSchema; + recursiveTraversal: RecursiveTraversalVerdict; + operation: string; + /** Stored edges to leave out of the walk; see {@link buildAcyclicityAncestryStepDirect}. */ + excludedEdgeIds?: readonly string[]; + }>, +): SqlFragment { + assertRecursiveTraversal(options.recursiveTraversal, options.operation); + + const { seed, members } = options; + const seedCte = buildAcyclicitySeed( + seed, + members, + options.graphId, + options.schema, + ); + const anchor = sql`SELECT s.origin_key, s.to_kind, s.to_id FROM seed s`; + const ancestryStep = buildAcyclicityAncestryStepDirect({ + members, + graphId: options.graphId, + schema: options.schema, + dialect: options.dialect, + forceWorktableOuterJoinOrder: + options.dialect.capabilities.forceRecursiveWorktableOuterJoinOrder, + excludedEdgeIds: options.excludedEdgeIds ?? [], + }); + const ancestry = sql`ancestry(origin_key, node_kind, node_id) AS (${anchor} UNION ${ancestryStep})`; + + const body = sql`${seedCte}, ${ancestry}${acyclicityProbeClosing(seed, members)}`; + return sql`WITH RECURSIVE ${body}`; +} + +/** + * What a probe reads off its finished walk: for the `"unwritten"` form the + * `targets` CTE and every origin/target reach; otherwise the origins whose + * own `from` their `to` reaches. + */ +function acyclicityProbeClosing( + seed: AcyclicityProbeSeed, + members: readonly AcyclicRelationMember[], +): SqlFragment { + if (seed.kind === "unwritten") { + const targets = buildAcyclicityTargets(seed.targets, members); + return sql`, ${targets} SELECT DISTINCT a.origin_key, t.reached_key FROM ancestry a JOIN targets t ON t.from_kind = a.node_kind AND t.from_id = a.node_id`; + } + const closingSelect = sql` SELECT DISTINCT a.origin_key FROM ancestry a JOIN seed s ON s.origin_key = a.origin_key AND s.from_kind = a.node_kind AND s.from_id = a.node_id`; + // A single-row `"proposed"` seed has only one possible `origin_key`, so it + // stops at the first witness: both engines pipeline a recursive CTE, and a + // `LIMIT 1` short-circuits the walk instead of running to fixpoint. Several + // origins must return every offending one so a refusal can name the edge. + return seed.kind === "proposed" && seed.edges.length === 1 ? + sql`${closingSelect} LIMIT 1` + : closingSelect; +} diff --git a/packages/typegraph/src/store/registered-kinds.ts b/packages/typegraph/src/store/registered-kinds.ts new file mode 100644 index 000000000..741fe0dd9 --- /dev/null +++ b/packages/typegraph/src/store/registered-kinds.ts @@ -0,0 +1,53 @@ +/** + * "Is this kind registered on the graph" for every read that names kinds in + * its options. + * + * A kind list is compiled into a kind filter, so an unregistered name does + * not fail on its own: it matches no row, and the read answers with an + * ordinary, silently incomplete result. Each read that accepts kind names + * therefore refuses an unregistered one before it compiles anything, through + * these functions, so `neighbors`, the heterogeneous edge reads, + * `subgraph` and the graph algorithms cannot disagree about what counts as + * registered or how the refusal reads. + * + * Own-key lookups on purpose: a kind literally named `toString` must not + * resolve to an inherited member. + */ +import { type GraphDef } from "../core/define-graph"; +import { type NodeRegistration } from "../core/types"; +import { KindNotFoundError } from "../errors"; + +/** @throws KindNotFoundError when any of `kinds` is not a registered node kind. */ +export function assertRegisteredNodeKinds( + graph: GraphDef, + kinds: readonly string[], +): void { + for (const kind of kinds) { + if (Object.hasOwn(graph.nodes, kind)) continue; + throw new KindNotFoundError(kind, "node", { graphId: graph.id }); + } +} + +/** @throws KindNotFoundError when any of `kinds` is not a registered edge kind. */ +export function assertRegisteredEdgeKinds( + graph: GraphDef, + kinds: readonly string[], +): void { + for (const kind of kinds) { + if (Object.hasOwn(graph.edges, kind)) continue; + throw new KindNotFoundError(kind, "edge", { graphId: graph.id }); + } +} + +/** @throws KindNotFoundError when `kind` is not a registered node kind. */ +export function requireNodeRegistration( + graph: GraphDef, + kind: string, +): NodeRegistration { + const registration = + Object.hasOwn(graph.nodes, kind) ? graph.nodes[kind] : undefined; + if (registration === undefined) { + throw new KindNotFoundError(kind, "node", { graphId: graph.id }); + } + return registration; +} diff --git a/packages/typegraph/src/store/row-fetch.ts b/packages/typegraph/src/store/row-fetch.ts index 468cb7fcf..d98698444 100644 --- a/packages/typegraph/src/store/row-fetch.ts +++ b/packages/typegraph/src/store/row-fetch.ts @@ -1,3 +1,12 @@ +import { + type EdgeRow, + type FindEdgesByKindParams, + type FindNodesByKindParams, + type GraphReadBackend, + type NodeRow, +} from "../backend/types"; +import { requireDefined } from "../utils/presence"; + type RowWithId = Readonly<{ id: string }>; export type RowFetchPolicy = Readonly<{ @@ -32,3 +41,55 @@ export async function getRowsByIds( } return rowsById; } + +/** + * Every row `findNodesByKind` returns for `params`, one keyset page (ordered + * by id) at a time. A short page is the last; no read follows it. + */ +export async function* pageNodesByKind( + backend: Pick, + params: Omit, + pageSize: number, +): AsyncGenerator { + let after: string | undefined; + for (;;) { + const rows = await backend.findNodesByKind({ + ...params, + orderBy: "id", + limit: pageSize, + ...(after === undefined ? {} : { after }), + }); + yield rows; + if (rows.length < pageSize) return; + after = requireDefined( + rows.at(-1), + "findNodesByKind returned a full page with no last row", + ).id; + } +} + +/** + * Every row `findEdgesByKind` returns for `params`, one keyset page (ordered + * by id) at a time. A short page is the last; no read follows it. + */ +export async function* pageEdgesByKind( + backend: Pick, + params: Omit, + pageSize: number, +): AsyncGenerator { + let after: string | undefined; + for (;;) { + const rows = await backend.findEdgesByKind({ + ...params, + orderBy: "id", + limit: pageSize, + ...(after === undefined ? {} : { after }), + }); + yield rows; + if (rows.length < pageSize) return; + after = requireDefined( + rows.at(-1), + "findEdgesByKind returned a full page with no last row", + ).id; + } +} diff --git a/packages/typegraph/src/store/runtime-port.ts b/packages/typegraph/src/store/runtime-port.ts index 063fa3fe1..fb24ac74e 100644 --- a/packages/typegraph/src/store/runtime-port.ts +++ b/packages/typegraph/src/store/runtime-port.ts @@ -1,4 +1,7 @@ -import { type UNIQUE_SIDECAR_BATCH } from "../backend/capabilities/bundle-registry"; +import { + type BATCH_POINT_READ, + type UNIQUE_SIDECAR_BATCH, +} from "../backend/capabilities/bundle-registry"; import { type BundleVerdictOf } from "../backend/capabilities/resolve"; import { type BackendIdentity, @@ -23,6 +26,13 @@ import { type NodeType, } from "../core/types"; import { ConfigurationError } from "../errors"; +import { type IdentityServiceContext } from "../identity/service-types"; +import { + type IdentityDecisionProvenance, + type IdentityRestoreBaseline, + type IdentityTransitionCursor, + type IdentityTransitionTransfer, +} from "../identity/transition-log"; import { type IdentityReadFacade } from "../identity/types"; import { type InitialQueryBuilder } from "../query/builder"; import { type RecordedReadBinding } from "../query/compiler/schema"; @@ -30,11 +40,14 @@ import type { EvolutionPlan } from "../schema/evolution-plan"; import { typeGraphGlobalSymbol } from "../utils/global-symbol"; import { requireDefined } from "../utils/presence"; import { type InternalGraphAlgorithms } from "./algorithms"; +import { type NodeDeletePolicy } from "./operations/node-write-pipeline"; import type { Store } from "./store"; import { type InternalSubgraphOptions, - type SubgraphProject, + type SubgraphCompositionSelection, + type SubgraphProjectFor, type SubgraphResult, + type SubgraphResultEdgeKinds, } from "./subgraph"; import { type Edge, @@ -43,6 +56,7 @@ import { type RecordedScanOptions, type RecordedScanPage, TRANSACTION_RUNTIME, + type TransactionRuntime, } from "./types"; export const STORE_RUNTIME: unique symbol = @@ -82,21 +96,30 @@ export type StoreRuntime = Readonly<{ captureEnabled?: boolean; /** * @internal The `uniqueSidecarBatch` bundle's verdict, resolved once at - * store construction against `backend` (ruling B8 spec item 2) and exposed + * store construction against `backend` and exposed * here so a Store-owned view (provenance's fact close/reopen) can build a * {@link file://./claims/node-claims.ts NodeClaimContext} without re-minting * a second verdict for the same backend — the same reason `backend` itself * is exposed here rather than reconstructed. * - * Optional at this boundary — a `StoreRuntime`-shaped value is a - * contravariant (externally-authorable) position, so a new REQUIRED member - * here would be a breaking change (`scripts/api-surface-compat.ts`). - * Required after resolution instead, the same pattern + * Optional at this boundary, required after resolution — the same pattern * `CompileQueryOptions.recursiveTraversal` uses: the one real producer * (`store.ts`'s constructor) always populates it, and the one real - * consumer (`provenance/index.ts`) asserts it with `requireDefined`. + * consumer (`provenance/index.ts`) asserts it with `requireDefined`. The + * member stays optional at this boundary so the consumer's `requireDefined` + * assertion stays truthful. */ uniqueSidecarBatch?: BundleVerdictOf | undefined; + /** + * @internal The `batchPointRead` bundle's verdict, minted once at store + * construction and exposed for the same reason `uniqueSidecarBatch` is: a + * Store-owned view (provenance's support computation, which reads the whole + * rows its required parts hang from) must not re-resolve a second verdict for + * the same backend. Bound per read against the object that read runs on, so a + * transaction target implementing less than the store's backend falls back to + * the per-id read the bundle declares. + */ + batchPointRead: BundleVerdictOf; /** * @internal The backend this Store's queries actually execute through for * `target` — the Store's own backend when `target` is omitted. @@ -147,17 +170,64 @@ export type StoreRuntime = Readonly<{ subgraphAtCoordinate: < const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: InternalSubgraphOptions, - ) => Promise>; + options: InternalSubgraphOptions, + ) => Promise, P>>; algorithmsAtCoordinate: ( coordinate: ReadCoordinate, ) => InternalGraphAlgorithms; identityAtCoordinate: (coordinate: ReadCoordinate) => IdentityReadFacade; + /** + * @internal The full identity service context this Store builds writes and + * reads against — reached by the transition-log/replay module functions + * (`pruneIdentityTransitions`, and `store.identity.replay` / + * `transitionsOf`), which are plain functions over + * `IdentityServiceContext` like every other identity algorithm, rather + * than Store methods. Throws when the graph never declared `identity: {}`, + * the same guard `identityAtCoordinate` applies. + */ + identityContext: () => IdentityServiceContext; rebuildIdentityClosure: () => Promise; validateIdentity: () => Promise; + /** + * Deletes one node under an explicit {@link NodeDeletePolicy}, going through + * `executeNodeDelete` — the SAME entry point (fused-atomic-or-portable + * routing included) the public collection facade uses — against `target` + * directly. The public collection `delete(id, { expect })` takes no policy + * by design (a merge-only flag does not belong on it, the `bulkInsert` + * precedent), so a caller that needs a non-default policy reaches an + * internal port instead. + * + * This Store-scoped variant builds its OWN operation context — an + * immediate (unbuffered) hook runner and `attempt: 1` — so it is correct + * only for a caller managing its own transaction directly against the raw + * backend, or calling against the root backend with no enclosing + * transaction at all, OUTSIDE any `store.transaction` callback: nothing + * here is aware of a `store.transaction` in progress, so a caller invoking + * this INSIDE one would report `onOperationEnd` for the delete immediately, + * even if that outer transaction later rolls back. A caller already inside + * a `store.transaction` callback MUST use + * {@link transactionDeleteNodeWithPolicy} instead, which reaches that + * transaction's own buffered hook runner and attempt — every production + * caller (merge apply) does this today. + * + * `target` accepts the root {@link GraphBackend} itself, not only a + * `TransactionBackend`, on purpose: `transactionDeleteNodeWithPolicy` is + * always transaction-scoped, and a transaction-scoped backend never + * exposes the fused atomic delete command (see `executeNodeDelete`'s + * `resolveAtomicNodeDeleteBatchExecutor` call) — so calling THIS port + * directly against the root backend is the only way, in production or in a + * test, to exercise the routing decision between the fused and portable + * delete paths at all. + */ + deleteNodeWithPolicy: ( + target: GraphBackend | TransactionBackend, + work: Readonly<{ kind: string; id: string }>, + policy?: NodeDeletePolicy, + ) => Promise; /** * Validates one final resolved node write set, then clears the affected * nodes' claim rows so its upserts may take their approved keys in any order, @@ -344,6 +414,45 @@ export type StoreRuntime = Readonly<{ >, references: readonly Readonly<{ kind: string; id: string }>[], ) => Promise; + /** + * The identity half of an import update that states a node's `validTo`: + * refuses an end an identity assertion would outlive and notes the + * `window-end` transition, through the owner every store update runs + * (`applyIdentityWindowEnd`). A no-op for a graph without identity or a + * write that states no end. + * + * @throws {IdentityEndpointValidityError} when the end would strand an + * assertion's window. + */ + applyImportedNodeWindowEnd: ( + target: Readonly< + BackendIdentity & + GraphEntityReadBackend & + SchemaReadBackend & + QueryExecutionBackend & + SqlCompilationBackend & + RawQueryExecutionBackend & + Pick + >, + input: Readonly<{ kind: string; id: string; validTo?: string }>, + ) => Promise; + /** + * Detaches a node import purges AFTER `foldImportedIdentityNodes` + * already folded it into identity for this attempt's batch — see + * `assertImportedRequiredPartsAttached` (`src/interchange/import.ts`). + */ + detachDeletedImportedIdentityNode: ( + target: Readonly< + BackendIdentity & + GraphEntityReadBackend & + SchemaReadBackend & + QueryExecutionBackend & + SqlCompilationBackend & + RawQueryExecutionBackend & + Pick + >, + reference: Readonly<{ kind: string; id: string }>, + ) => Promise; importIdentityAssertionsAtTarget: ( target: Readonly< BackendIdentity & @@ -365,6 +474,73 @@ export type StoreRuntime = Readonly<{ }>[], mode: "state" | "archival", ) => Promise>; + /** + * @internal Reads one bounded page of a graph's ARCHIVAL identity + * transitions, ordered oldest first — the archival export's sole reader, + * mirroring `readIdentityAssertionPageAtTarget` above. + */ + readIdentityTransitionPageAtTarget: ( + target: GraphBackend | TransactionBackend, + options: Readonly<{ after?: IdentityTransitionCursor; limit: number }>, + ) => Promise< + Readonly<{ + transitions: readonly IdentityTransitionTransfer[]; + nextAfter?: IdentityTransitionCursor; + done: boolean; + }> + >; + /** + * @internal Reads a graph's identity transition-retention watermark for + * archival export; `{ prunedBeforeRevision: 0, ... }` when nothing has been + * pruned. + */ + identityTransitionRetentionAtTarget: ( + target: GraphBackend | TransactionBackend, + ) => Promise>; + /** + * @internal What an archival transition restore must know about this graph + * before the import's first write: whether it has identity history of its + * own, and its recorded-revision floor. Read once per import and handed to + * `importIdentityTransitionsAtTarget`. + */ + readIdentityRestoreBaselineAtTarget: ( + target: Readonly< + BackendIdentity & + GraphEntityReadBackend & + SchemaReadBackend & + QueryExecutionBackend & + SqlCompilationBackend & + RawQueryExecutionBackend & + Pick + >, + ) => Promise; + /** + * @internal Restores archival identity transitions inside an import + * transaction. `carriedWatermark` is the source graph's own retention + * watermark from the archival payload, used only when `transitions` is + * empty (see `importIdentityTransitionsIntoTarget`). `baseline` is the + * destination as it stood before the import began. + */ + importIdentityTransitionsAtTarget: ( + target: Readonly< + BackendIdentity & + GraphEntityReadBackend & + SchemaReadBackend & + QueryExecutionBackend & + SqlCompilationBackend & + RawQueryExecutionBackend & + Pick + >, + transitions: readonly IdentityTransitionTransfer[], + carriedWatermark: number | undefined, + baseline: IdentityRestoreBaseline, + ) => Promise>; + /** + * `decision` is the governing merge decision, when the apply runs under one: + * every identity transition the call causes carries it, so a fold a merged + * node create triggered is attributed to the merge rather than filed as an + * anonymous `fold`. `undefined` for an apply with no governing decision. + */ applyIdentityMergeAtTarget: ( target: GraphBackend | TransactionBackend, retractions: readonly Readonly<{ @@ -385,6 +561,7 @@ export type StoreRuntime = Readonly<{ validTo?: string | undefined; endedBy?: Readonly<{ kind: string; id: string }> | undefined; }>[], + decision?: IdentityDecisionProvenance, ) => Promise>; /** * Proves the identity classes of `seeds` carry no contradiction in the state @@ -465,18 +642,20 @@ export function storeQueryBackend( } type TransactionRuntimePort = Readonly<{ - [TRANSACTION_RUNTIME]?: Readonly<{ - backend: TransactionBackend; - runNodeOperationHooks: TransactionNodeOperationHookRunner; - }>; + [TRANSACTION_RUNTIME]?: TransactionRuntime; }>; -type TransactionNodeOperationHookRunner = ( - operation: "create" | "update" | "delete", - kind: string, - id: string, - fn: () => Promise, -) => Promise; +/** + * A node delete bound to the transaction it is invoked from — see + * {@link transactionDeleteNodeWithPolicy}. Exported so a caller threading this + * seam through its own call stack (merge apply) names ONE type rather than + * redeclaring an identical structural alias that could drift from this port's + * actual shape. + */ +export type TransactionDeleteNodeWithPolicy = ( + work: Readonly<{ kind: string; id: string }>, + policy?: NodeDeletePolicy, +) => Promise; const transactionStoreOwners = new WeakMap(); const evolvedTransactionOriginalOwners = new WeakMap(); @@ -544,28 +723,63 @@ export async function runInTransactionContext< } } -/** Returns the full backend for privileged transaction-bound internals. */ -export function transactionBackend( +function requireTransactionRuntime( transaction: TransactionRuntimePort, -): TransactionBackend { +): TransactionRuntime { const runtime = transaction[TRANSACTION_RUNTIME]; if (runtime === undefined) { throw new TypeError( "Cannot access this transaction's runtime port. The transaction may come from an incompatible TypeGraph version.", ); } - return runtime.backend; + return runtime; } -/** Returns the hook runner paired with a transaction's internal backend. */ -export function transactionNodeOperationHookRunner( +/** Returns the full backend for privileged transaction-bound internals. */ +export function transactionBackend( transaction: TransactionRuntimePort, -): TransactionNodeOperationHookRunner { - const runtime = transaction[TRANSACTION_RUNTIME]; - if (runtime === undefined) { - throw new TypeError( - "Cannot access this transaction's runtime port. The transaction may come from an incompatible TypeGraph version.", - ); - } - return runtime.runNodeOperationHooks; +): TransactionBackend { + return requireTransactionRuntime(transaction).backend; +} + +/** + * Soft-deletes one node under an explicit {@link NodeDeletePolicy} through + * THIS transaction's own node-operation context — the buffered hook runner + * and attempt number `#buildTransactionContext` already built for this + * transaction's `nodes`/`edges`, not a freshly-minted immediate-hook context + * scoped to the outer Store. Reaching the outer Store's own hook runner from + * inside a caller-opened transaction would report `onOperationEnd` for a + * delete the instant it runs even when the enclosing transaction later rolls + * back, which is why this delete-behavior-carrying escape hatch is + * transaction-scoped rather than Store-scoped: the public collection + * `delete(id, { expect })` takes no policy by design (a merge-only flag does + * not belong on it, the `bulkInsert` precedent), so a caller that needs a + * non-default policy — today, merge apply — reaches this internal port + * instead. + */ +export function transactionDeleteNodeWithPolicy( + transaction: TransactionRuntimePort, + work: Readonly<{ kind: string; id: string }>, + policy?: NodeDeletePolicy, +): Promise { + return requireTransactionRuntime(transaction).deleteNodeWithPolicy( + work, + policy, + ); +} + +/** + * Revives one soft-deleted node with its stored props through THIS + * transaction's own node-operation context — the in-frame revive owner + * (`executeNodeRevive`), so the revival re-takes its uniqueness claims, + * re-syncs its projections and re-enters identity as a restore exactly as the + * write path owes, under the transaction's buffered hook runner. The inverse + * of a soft delete through {@link transactionDeleteNodeWithPolicy}; today's + * caller is provenance's currency reopen. + */ +export function transactionReviveNode( + transaction: TransactionRuntimePort, + work: Readonly<{ kind: string; id: string }>, +): Promise { + return requireTransactionRuntime(transaction).reviveNode(work); } diff --git a/packages/typegraph/src/store/search-facade.ts b/packages/typegraph/src/store/search-facade.ts index b9aa69efa..b36d4ea6a 100644 --- a/packages/typegraph/src/store/search-facade.ts +++ b/packages/typegraph/src/store/search-facade.ts @@ -19,6 +19,8 @@ import { type GraphBackend } from "../backend/types"; import { type GraphDef, type NodeKinds } from "../core/define-graph"; import { type NodeRegistration, type NodeType } from "../core/types"; import { KindNotFoundError } from "../errors"; +import { type PolymorphicNodeType } from "../ontology/types"; +import { type DefaultAliasExpansionAxis } from "../query/builder/alias-expansion"; import { type QueryBuilder } from "../query/builder/query-builder"; import { type KindRegistry } from "../registry/kind-registry"; import { @@ -39,20 +41,63 @@ import { } from "./search"; import { type Node } from "./types"; +/** + * The option shape of a call that takes the search default or states + * `"exact"`: the hit stays the searched kind's own type. + */ +type ExactExpansion = Readonly<{ + expansion?: "exact" | undefined; +}>; + +/** + * States the call's expansion axis as a type parameter, so the hit type can + * follow it. Intersected with the option type rather than threaded through + * it: the axis is a fact about one call, not about a reusable options value. + * The key is required, so a call that omits the option or passes `undefined` + * never lands here: it takes the exact overload. An options value typed by + * the library's own option type, whose `expansion` is optional and either + * axis, matches neither and takes the last overload, which types the hit as + * an unpinned axis would. + */ +type StatedExpansion = Readonly<{ + expansion: E; +}>; + +/** + * The node type a hit carries for a kind searched under axis `E`, matching + * the `from()` alias over the same kind and stated axis: + * + * - `"exact"` — the kind's own type. + * - `"subclasses"` — {@link PolymorphicNodeType} unconditionally, as + * `from(kind, alias, { expansion: "subclasses" })` types its alias: the + * kind's properties, with the `kind` discriminant and `NodeId` brand + * widened whether or not the compile-time ontology declares a subclass, so + * a subclass a persisted extension added is typed soundly. + * - an axis the call site does not pin to one literal — widened too, the + * conservative reading. + */ +type SearchNodeType = + [E] extends ["exact"] ? N : PolymorphicNodeType; + /** * Resolves the hit's `node` type. Compile-time kinds keep their - * narrowed `Node`; kinds outside `G` (added via graph extension through - * `store.evolve()`, or string variables the type system can't see) + * `Node`, narrowed or widened by the stated expansion axis (see + * {@link SearchNodeType}); kinds outside `G` (added via graph extension + * through `store.evolve()`, or string variables the type system can't see) * widen to the base `Node` so callers don't need a cast. * * This is the same shape as `getNodeCollection` — the dynamic form * works for any registered kind, and the type narrows when (and only * when) the literal is statically known. */ -type ResolveNode = +type ResolveNode< + G extends GraphDef, + K extends string, + E extends DefaultAliasExpansionAxis, +> = K extends NodeKinds ? G["nodes"][K] extends NodeRegistration ? - Node + Node> : Node : Node; @@ -139,17 +184,34 @@ export class StoreSearch { * configured with) and resolves the matching node IDs back to typed * `Node` objects. */ - async fulltext( + fulltext< + K extends string, + E extends DefaultAliasExpansionAxis = DefaultAliasExpansionAxis, + >( + nodeKind: K, + options: FulltextSearchOptions> & StatedExpansion, + ): Promise>[]>; + fulltext( + nodeKind: K, + options: FulltextSearchOptions> & ExactExpansion, + ): Promise>[]>; + fulltext( nodeKind: K, options: FulltextSearchOptions>, - ): Promise>[]> { + ): Promise< + readonly FulltextSearchHit>[] + >; + async fulltext( + nodeKind: string, + // Contravariance: the overloads narrow the accessor callback to the + // kind, which is wider than the base instantiation the core helpers take. + options: unknown, + ): Promise { this.#assertKindRegistered(nodeKind); - return executeFulltextSearch>( + return executeFulltextSearch( this.#context, nodeKind, - // Contravariance: the narrowed accessor callback is intentionally - // wider than the base instantiation the core helpers take. - options as unknown as FulltextSearchOptions, + options as FulltextSearchOptions, ); } @@ -164,17 +226,34 @@ export class StoreSearch { * Pure vector — no fulltext leg, no fusion. For combined * vector+fulltext ranking, use `hybrid`. */ - async vector( + vector< + K extends string, + E extends DefaultAliasExpansionAxis = DefaultAliasExpansionAxis, + >( + nodeKind: K, + options: VectorSearchOptions> & StatedExpansion, + ): Promise>[]>; + vector( + nodeKind: K, + options: VectorSearchOptions> & ExactExpansion, + ): Promise>[]>; + vector( nodeKind: K, options: VectorSearchOptions>, - ): Promise>[]> { + ): Promise< + readonly VectorSearchHit>[] + >; + async vector( + nodeKind: string, + // Contravariance: the overloads narrow the accessor callback to the + // kind, which is wider than the base instantiation the core helpers take. + options: unknown, + ): Promise { this.#assertKindRegistered(nodeKind); - return executeVectorSearch>( + return executeVectorSearch( this.#context, nodeKind, - // Contravariance: the narrowed accessor callback is intentionally - // wider than the base instantiation the core helpers take. - options as unknown as VectorSearchOptions, + options as VectorSearchOptions, ); } @@ -187,17 +266,34 @@ export class StoreSearch { * over-fetch is 4× `limit` from each source — tune via `vector.k` / * `fulltext.k` for higher-recall corpora. */ - async hybrid( + hybrid< + K extends string, + E extends DefaultAliasExpansionAxis = DefaultAliasExpansionAxis, + >( + nodeKind: K, + options: HybridSearchOptions> & StatedExpansion, + ): Promise>[]>; + hybrid( + nodeKind: K, + options: HybridSearchOptions> & ExactExpansion, + ): Promise>[]>; + hybrid( nodeKind: K, options: HybridSearchOptions>, - ): Promise>[]> { + ): Promise< + readonly HybridSearchHit>[] + >; + async hybrid( + nodeKind: string, + // Contravariance: the overloads narrow the accessor callback to the + // kind, which is wider than the base instantiation the core helpers take. + options: unknown, + ): Promise { this.#assertKindRegistered(nodeKind); - return executeHybridSearch>( + return executeHybridSearch( this.#context, nodeKind, - // Contravariance: the narrowed accessor callback is intentionally - // wider than the base instantiation the core helpers take. - options as unknown as HybridSearchOptions, + options as HybridSearchOptions, ); } diff --git a/packages/typegraph/src/store/search.ts b/packages/typegraph/src/store/search.ts index 760ec2818..215802930 100644 --- a/packages/typegraph/src/store/search.ts +++ b/packages/typegraph/src/store/search.ts @@ -30,12 +30,22 @@ import { DEFAULT_RRF_WEIGHT, type HybridFusionOptions, } from "../query/ast"; +import { + assertNoRemovedExpansionOption, + assertPermittedExpansionAxis, + DEFAULT_ALIAS_EXPANSION_AXES, + type DefaultAliasExpansionAxis, + expandKindsForAxis, + INDEPENDENT_ALIAS_TYPING, + SEARCH_EXPANSION_DEFAULT, +} from "../query/builder/alias-expansion"; import { type QueryBuilder } from "../query/builder/query-builder"; import { type NodeAccessor } from "../query/builder/types"; import { validateHybridFusionOptions } from "../query/builder/validation"; import { type FulltextStrategy } from "../query/dialect/fulltext-strategy"; import { assertVectorMinScore } from "../query/dialect/vector-strategy"; import { type Predicate } from "../query/predicates"; +import { schemaIntrospectorFor } from "../query/schema-introspector"; import { sql, type SqlFragment } from "../query/sql-fragment"; import { type KindRegistry } from "../registry/kind-registry"; import { compareCodePoints } from "../utils/compare"; @@ -84,7 +94,7 @@ export type HybridSearchHit = Readonly<{ /** * Scope options shared by every facade search leg. * - * `where` and `includeSubClasses` compile into the search statement's + * `where` and `expansion` compile into the search statement's * candidate set (a subquery produced by the store's own query compiler), so * filtering happens INSIDE the engine's top-k — never by post-filtering a * ranked list. `offset` is rank-relative pagination: the engine fetches @@ -103,12 +113,22 @@ export type SearchScopeOptions = Readonly<{ /** Rows to skip after ranking (rank-relative pagination). */ offset?: number; /** - * Expand the searched kind to include its `subClassOf` descendants. - * Vector legs search each declaring kind's storage and merge by score; - * kinds that don't declare the embedding field are skipped (mirroring - * the query builder). Requires a query-capable store. + * The searched kind's expansion axis, spelled with the same option name + * the query builder uses. `"exact"` (the DEFAULT here — unlike the query + * builder, `search()` is not polymorphic by default) searches the named + * kind alone; `"subclasses"` expands to its `subClassOf`/`equivalentTo` + * descendants. Vector legs search each declaring kind's storage and merge + * by score; kinds that omit the embedding field hold no vector there and + * are skipped. A search is refused rather than silently under-returned when + * a searched kind would leave its text unindexed (no `searchable()` field, + * or one redeclared untagged) or declares the embedding field with a tag + * that differs from the subsuming declaration — the same kinds the query + * builder refuses, decided by the same schema introspector. + * `"subclasses"` requires a query-capable store. `"narrower"` is not an + * axis this facade offers. The facade types each hit by the axis stated + * here, as a `from()` alias over the kind would be. */ - includeSubClasses?: boolean; + expansion?: DefaultAliasExpansionAxis | undefined; }>; export type FulltextSearchOptions = @@ -224,7 +244,7 @@ type StoreSearchContext = Readonly<{ * Builds a fresh query for candidate compilation (`store.query()`, the * same seam collection `find({ where })` uses). Optional so a bare * context still supports unscoped searches; `where` / - * `includeSubClasses` throw without it. + * `expansion: "subclasses"` throw without it. */ createQuery?: () => QueryBuilder; /** @@ -271,9 +291,19 @@ function buildKindCandidates( { capability: "search", graphId: ctx.graphId }, ); } + // Pinned exact-kind, defense in depth: `resolveSearchKinds` has already + // expanded the kind set and this runs PER resolved kind, so + // `backend.fulltextSearch({ nodeKind: kind, ... })` below already scopes + // the physical search to `kind` regardless of what this candidate + // subquery's `from()` widens to — inheriting the query-builder default + // here would not currently change any result (tests/polymorphic-default.test.ts + // documents this), but keeping it explicit is what would make an un-opted + // `search()` silently polymorphic if that outer scoping ever changed + // (search()'s own `expansion` option stays the one and only axis + // for this facade). const chain = ctx .createQuery() - .from(nodeKind, SEARCH_CANDIDATE_ALIAS) + .from(nodeKind, SEARCH_CANDIDATE_ALIAS, { expansion: "exact" }) .whereNode(SEARCH_CANDIDATE_ALIAS, where); const compiled = chain .select( @@ -286,20 +316,88 @@ function buildKindCandidates( /** * The kinds one search call spans: the kind itself, plus its `subClassOf` * descendants when requested. + * + * Refuses a value outside this facade's axis set through the vocabulary's own + * owner, so a stated axis `search()` cannot honor — `"narrower"`, or a + * misspelling a JavaScript caller reached — is named rather than coerced to + * `"exact"`. The same owner refuses the removed `includeSubClasses` option. + * The query builder refuses the identical values; one predicate, one owner. + * The axis-to-kind-list mapping itself is likewise the shared owner's + * (`expandKindsForAxis`), not re-spelled here. A hit's type under + * `"subclasses"` is `PolymorphicNodeType` whatever the compile-time graph + * declares, so a subclass a persisted extension added is admitted, not + * refused. */ function resolveSearchKinds( ctx: StoreSearchContext, nodeKind: string, - includeSubClasses: boolean | undefined, + options: SearchScopeOptions, ): readonly string[] { - if (includeSubClasses !== true) return [nodeKind]; - if (ctx.createQuery === undefined) { + assertNoRemovedExpansionOption(options, "search"); + const axis = options.expansion ?? SEARCH_EXPANSION_DEFAULT; + assertPermittedExpansionAxis(axis, DEFAULT_ALIAS_EXPANSION_AXES, "search"); + if (axis === "subclasses" && ctx.createQuery === undefined) { throw new ConfigurationError( - "search with includeSubClasses requires a query-capable store", + 'search with expansion: "subclasses" requires a query-capable store', { capability: "search", graphId: ctx.graphId }, ); } - return ctx.registry.expandSubClasses(nodeKind); + return expandKindsForAxis( + axis, + nodeKind, + ctx.registry, + INDEPENDENT_ALIAS_TYPING, + ); +} + +/** + * Refuses a fulltext search in which some searched kind would leave its text + * unindexed, exactly as `$fulltext.matches()` refuses the same alias. The + * decision is the schema introspector's (`kindsWithoutSearchableField`), asked + * of every resolved kind list — an exact search included, since a kind with no + * `searchable()` field returns nothing from the index whatever it holds — so + * the facade and the builder cannot drift. + */ +function assertFulltextKindsCoverRows( + ctx: StoreSearchContext, + nodeKind: string, + kinds: readonly string[], +): void { + const lackingKinds = schemaIntrospectorFor( + ctx.registry, + ).kindsWithoutSearchableField(kinds); + if (lackingKinds.length === 0) return; + throw new ConfigurationError( + `fulltext search over "${nodeKind}" would silently miss rows ` + + `of ${formatKindList(lackingKinds)}: ` + + `${lackingKinds.length === 1 ? "it has" : "they have"} no field ` + + `declared with searchable() that the index covers (an untagged ` + + `redeclaration of a searchable field does not count).`, + { + code: "SEARCH_EXPANSION_KIND_UNINDEXED", + capability: "fulltext", + graphId: ctx.graphId, + kinds: lackingKinds, + }, + { + suggestion: + 'Declare the field with searchable() on the listed kinds, search the kinds separately, or pass expansion: "exact" for a kind that declares one.', + }, + ); +} + +function formatKindList(kinds: readonly string[]): string { + return kinds.map((kind) => `"${kind}"`).join(", "); +} + +function resolveFulltextKinds( + ctx: StoreSearchContext, + nodeKind: string, + options: SearchScopeOptions, +): readonly string[] { + const kinds = resolveSearchKinds(ctx, nodeKind, options); + assertFulltextKindsCoverRows(ctx, nodeKind, kinds); + return kinds; } function assertSearchOffset(offset: number | undefined, label: string): void { @@ -462,7 +560,7 @@ export async function executeFulltextSearch( language: options.language, }); - const kinds = resolveSearchKinds(ctx, nodeKind, options.includeSubClasses); + const kinds = resolveFulltextKinds(ctx, nodeKind, options); const offset = options.offset ?? 0; const singleKind = kinds.length === 1; @@ -558,7 +656,7 @@ export async function executeVectorSearch( ctx, nodeKind, options.fieldPath, - options.includeSubClasses, + resolveSearchKinds(ctx, nodeKind, options), "vectorSearch", ); for (const { slot } of searchKinds) { @@ -642,27 +740,65 @@ export async function executeVectorSearch( return hits; } +/** + * Refuses a vector expansion in which a kind declares the embedding field with + * a different `embedding()` tag than the kind it subsumes under: its rows hold + * a vector no index covers (or an index the others do not share), so skipping + * it would silently under-return. The decision is the schema introspector's + * (`kindsDivergingFromSubsumingDeclaration`), the one the query builder makes + * before it trusts a subsuming declaration. A kind that omits the field holds + * no vector there and is skipped. + */ +function assertVectorKindsCoverRows( + ctx: StoreSearchContext, + nodeKind: string, + kinds: readonly string[], + fieldPath: string, + label: string, +): void { + const divergingKinds = schemaIntrospectorFor( + ctx.registry, + ).kindsDivergingFromSubsumingDeclaration(kinds, fieldPath); + if (divergingKinds.length === 0) return; + throw new ConfigurationError( + `${label}: ${formatKindList(divergingKinds)} expanded from ` + + `"${nodeKind}" declare${divergingKinds.length === 1 ? "s" : ""} ` + + `"${fieldPath}" with an embedding() tag that differs from the ` + + `subsuming declaration, so their vectors are not indexed with the ` + + `rest and the search would silently miss them.`, + { + code: "SEARCH_EXPANSION_KIND_UNINDEXED", + capability: "vector", + graphId: ctx.graphId, + kinds: divergingKinds, + }, + { + suggestion: + 'Declare the field identically on the listed kinds (embedding() on each), or pass expansion: "exact".', + }, + ); +} + /** One vector-search target: a kind and its resolved embedding slot. */ type VectorSearchKind = Readonly<{ kind: string; slot: ResolvedSearchSlot }>; /** - * Resolves the kinds a vector search spans, keeping only kinds that - * declare the embedding field (mirroring the query builder, which skips - * non-declaring kinds instead of referencing a table that was never - * created). Enforces one shared declared metric across the expansion: - * scores from different metrics cannot be merged into one ranking, and a - * per-call metric override cannot bridge the gap either (each kind's - * storage is built for — and validated against — its declared metric), so - * mixed declared metrics are unsupported for cross-kind ranking. + * Narrows a search's resolved kinds to the ones a vector search spans: only + * kinds that declare the embedding field (a kind that omits it holds no vector there, + * and has no table to reference). Enforces one shared declared metric across + * the expansion: scores from different metrics cannot be merged into one + * ranking, and a per-call metric override cannot bridge the gap either (each + * kind's storage is built for — and validated against — its declared metric), + * so mixed declared metrics are unsupported for cross-kind ranking. */ function resolveVectorSearchKinds( ctx: StoreSearchContext, nodeKind: string, fieldPath: string, - includeSubClasses: boolean | undefined, + kinds: readonly string[], label: string, ): readonly VectorSearchKind[] { - const kinds = resolveSearchKinds(ctx, nodeKind, includeSubClasses); + assertVectorKindsCoverRows(ctx, nodeKind, kinds, fieldPath, label); const resolved: VectorSearchKind[] = []; for (const kind of kinds) { const slot = tryResolveSearchSlot(ctx, kind, fieldPath); @@ -777,18 +913,13 @@ export async function executeHybridSearch( const fulltextK = options.fulltext.k ?? pageLimit * overFetchMultiplier; // The fulltext half spans every expanded kind; the vector half only the - // kinds that declare the embedding field (mirroring the query builder's - // treatment of non-declaring kinds). - const fulltextKinds = resolveSearchKinds( - ctx, - nodeKind, - options.includeSubClasses, - ); + // kinds that declare the embedding field. + const fulltextKinds = resolveFulltextKinds(ctx, nodeKind, options); const vectorKinds = resolveVectorSearchKinds( ctx, nodeKind, options.vector.fieldPath, - options.includeSubClasses, + fulltextKinds, "hybridSearch.vector", ); for (const { slot } of vectorKinds) { diff --git a/packages/typegraph/src/store/store-analysis.ts b/packages/typegraph/src/store/store-analysis.ts index 4a729a62a..6c5bfb045 100644 --- a/packages/typegraph/src/store/store-analysis.ts +++ b/packages/typegraph/src/store/store-analysis.ts @@ -25,6 +25,7 @@ import { import { sql, type SqlFragment } from "../query/sql-fragment"; import { asCompiledRowsSql } from "../query/sql-intent"; import { sortedReplacer } from "../schema/canonical"; +import { activeSchemaHash } from "../schema/manager"; import { serializeSchemaProperties } from "../schema/serializer"; import type { JsonSchema } from "../schema/types"; import { chunk } from "../utils/array"; @@ -270,6 +271,8 @@ async function schemaCoordinateFor( ctx: AnalysisContext, ): Promise { const active = await ctx.backend.getActiveSchema(ctx.graphId); + const activeHash = + active === undefined ? undefined : await activeSchemaHash(active); const declaration = ctx.introspect(); if ( declaration.schemaVersion !== undefined && @@ -283,13 +286,13 @@ async function schemaCoordinateFor( } if ( declaration.schemaHash !== undefined && - active !== undefined && - active.schema_hash !== declaration.schemaHash + activeHash !== undefined && + activeHash !== declaration.schemaHash ) { throw new SchemaMismatchError({ graphId: ctx.graphId, expectedHash: declaration.schemaHash, - actualHash: active.schema_hash, + actualHash: activeHash, }); } const declarationShape = { @@ -308,10 +311,7 @@ async function schemaCoordinateFor( const schemaFence = await sha256Hex( JSON.stringify( { - active: - active === undefined ? undefined : ( - [active.version, active.schema_hash] - ), + active: active === undefined ? undefined : [active.version, activeHash], declaration: declarationShape, }, sortedReplacer, @@ -320,7 +320,7 @@ async function schemaCoordinateFor( ); return { ...(active === undefined ? {} : { schemaVersion: active.version }), - ...(active === undefined ? {} : { schemaHash: active.schema_hash }), + ...(activeHash === undefined ? {} : { schemaHash: activeHash }), schemaFence, }; } diff --git a/packages/typegraph/src/store/store-view.ts b/packages/typegraph/src/store/store-view.ts index ef816c605..5b3a52a9a 100644 --- a/packages/typegraph/src/store/store-view.ts +++ b/packages/typegraph/src/store/store-view.ts @@ -70,17 +70,24 @@ import { type RECORDED_POINT_READ_NAMES, } from "./collection-surface"; import { + type InternalReadParams, withCoordinate, withValidCoordinate, } from "./collections/temporal-read-params"; +import { + type NodeByIdReader, + readNodeByReference, +} from "./node-reference-read"; import { storeRuntime } from "./runtime-port"; import { type StoreSearch } from "./search-facade"; import { type Store, type ViewIdentityAccess } from "./store"; import { type InternalSubgraphOptions, + type SubgraphCompositionSelection, type SubgraphOptions, - type SubgraphProject, + type SubgraphProjectFor, type SubgraphResult, + type SubgraphResultEdgeKinds, } from "./subgraph"; import { type BulkFindEdgesFromParams, @@ -90,7 +97,9 @@ import { type DynamicStoreViewEdgeCollection, type EdgeBulkFindEndpointOptions, type EdgeCollection, + type GraphNodeForKinds, type GraphNodeReference, + type Node, type NodeCollection, type NodeCurrentReads, type RecordedStoreViewEdgeCollection, @@ -127,9 +136,10 @@ export type StoreViewSubgraphOptions< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined = undefined, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, > = Omit< - SubgraphOptions, + SubgraphOptions, "temporalMode" | "asOf" | "recordedAsOf" >; @@ -262,6 +272,65 @@ const NON_KIND_KEYS: ReadonlySet = new Set([ "toJSON", ]); +/** The option keys a read states its own temporal coordinate with. */ +const STATED_COORDINATE_KEYS = [ + "temporalMode", + "asOf", + "recordedAsOf", +] as const; + +/** + * Refuses a read that states its own temporal coordinate. + * + * The view's option types omit the temporal keys, but an untyped caller (or + * a cast) can still state them. Answering at the pin anyway would drop a + * stated value without a word, and honoring it would break the seal, so the + * read is refused — the same answer `view.query().temporal(...)` gives, on a + * `current` view and an `asOf` view alike. `stated` is whatever argument sits + * where the live collection would take its options. + * + * @throws ConfigurationError (`STORE_VIEW_SEALED_COORDINATE`) + */ +function assertNoStatedCoordinate( + method: string, + stated: unknown, + coordinate: ReadCoordinate, +): void { + if (typeof stated !== "object" || stated === null) return; + const statedKeys = STATED_COORDINATE_KEYS.filter( + (key) => (stated as Readonly>)[key] !== undefined, + ); + if (statedKeys.length === 0) return; + throw new ConfigurationError( + `'${method}' on a StoreView does not accept ${statedKeys.map((key) => `\`${key}\``).join(", ")} — the view's ` + + `temporal coordinate (${describeCoordinate(coordinate)}) is sealed. ` + + `Read at another coordinate through the live Store or store.view(...).`, + { + code: "STORE_VIEW_SEALED_COORDINATE", + method, + stated: statedKeys, + ...coordinateContext(coordinate), + }, + ); +} + +/** + * A view read's options with the view's coordinate applied: the one place a + * pinned read merges the two, so no read can spread the pin over a + * coordinate its caller stated ({@link assertNoStatedCoordinate}). + * + * @throws ConfigurationError (`STORE_VIEW_SEALED_COORDINATE`) + */ +function pinnedOptions( + method: string, + options: Options | undefined, + coordinate: ReadCoordinate, + pin: Pin, +): Options & Pin { + assertNoStatedCoordinate(method, options, coordinate); + return { ...options, ...pin } as Options & Pin; +} + /** * Returns a function that refuses a write or unsupported-read method on a * read-only view with a descriptive error, rather than silently ignoring @@ -491,10 +560,22 @@ function pinnedNodeCollection( Method in (typeof NODE_TEMPORAL_READ_NAMES)[number] ]: StoreViewNodeCollection[Method]; }> = { - getById: (id) => live.getById(id, temporal), - getByIds: (ids) => live.getByIds(ids, temporal), - find: (filter) => live.find(filter, temporal), - count: () => live.count(temporal), + getById: async (id, stated?: unknown) => { + assertNoStatedCoordinate("getById", stated, coordinate); + return live.getById(id, temporal); + }, + getByIds: async (ids, stated?: unknown) => { + assertNoStatedCoordinate("getByIds", stated, coordinate); + return live.getByIds(ids, temporal); + }, + find: async (filter, stated?: unknown) => { + assertNoStatedCoordinate("find", stated, coordinate); + return live.find(filter, temporal); + }, + count: async (stated?: unknown) => { + assertNoStatedCoordinate("count", stated, coordinate); + return live.count(temporal); + }, }; return readOnlyCollectionProxy( reads, @@ -515,18 +596,45 @@ function pinnedEdgeCollection( Method in (typeof EDGE_TEMPORAL_READ_NAMES)[number] ]: StoreViewEdgeCollection[Method]; }> = { - getById: (id) => live.getById(id, temporal), - getByIds: (ids) => live.getByIds(ids, temporal), - find: (filter) => live.find(filter, temporal), - count: (filter) => live.count(filter, temporal), - findFrom: (from) => live.findFrom(from, temporal), - findTo: (to) => live.findTo(to, temporal), - bulkFindFrom: (froms, options) => - live.bulkFindFrom(froms, { ...options, ...temporal }), - bulkFindTo: (tos, options) => - live.bulkFindTo(tos, { ...options, ...temporal }), - findByEndpoints: (from, to, options) => - live.findByEndpoints(from, to, options, temporal), + getById: async (id, stated?: unknown) => { + assertNoStatedCoordinate("getById", stated, coordinate); + return live.getById(id, temporal); + }, + getByIds: async (ids, stated?: unknown) => { + assertNoStatedCoordinate("getByIds", stated, coordinate); + return live.getByIds(ids, temporal); + }, + find: async (filter, stated?: unknown) => { + assertNoStatedCoordinate("find", stated, coordinate); + return live.find(filter, temporal); + }, + count: async (filter, stated?: unknown) => { + assertNoStatedCoordinate("count", stated, coordinate); + return live.count(filter, temporal); + }, + findFrom: async (from, stated?: unknown) => { + assertNoStatedCoordinate("findFrom", stated, coordinate); + return live.findFrom(from, temporal); + }, + findTo: async (to, stated?: unknown) => { + assertNoStatedCoordinate("findTo", stated, coordinate); + return live.findTo(to, temporal); + }, + bulkFindFrom: async (froms, options) => + live.bulkFindFrom( + froms, + pinnedOptions("bulkFindFrom", options, coordinate, temporal), + ), + bulkFindTo: async (tos, options) => + live.bulkFindTo( + tos, + pinnedOptions("bulkFindTo", options, coordinate, temporal), + ), + findByEndpoints: async (from, to, options, stated?: unknown) => { + assertNoStatedCoordinate("findByEndpoints", options, coordinate); + assertNoStatedCoordinate("findByEndpoints", stated, coordinate); + return live.findByEndpoints(from, to, options, temporal); + }, }; return readOnlyCollectionProxy(reads, live, coordinate, "edge"); } @@ -619,12 +727,18 @@ function recordedNodeCollection( Method in (typeof RECORDED_POINT_READ_NAMES)[number] ]: RecordedStoreViewNodeCollection[Method]; }> = { - getById: (id) => - storeRuntime(store).recordedNodeGetById(kind, id, coordinate), - getByIds: (ids) => - storeRuntime(store).recordedNodeGetByIds(kind, ids, coordinate), - scan: (options) => - storeRuntime(store).recordedNodeScan(kind, coordinate, options), + getById: async (id, stated?: unknown) => { + assertNoStatedCoordinate("getById", stated, coordinate); + return storeRuntime(store).recordedNodeGetById(kind, id, coordinate); + }, + getByIds: async (ids, stated?: unknown) => { + assertNoStatedCoordinate("getByIds", stated, coordinate); + return storeRuntime(store).recordedNodeGetByIds(kind, ids, coordinate); + }, + scan: async (options) => { + assertNoStatedCoordinate("scan", options, coordinate); + return storeRuntime(store).recordedNodeScan(kind, coordinate, options); + }, }; return recordedCollectionProxy(reads, live, coordinate, "node"); } @@ -649,12 +763,18 @@ function recordedEdgeCollection( NodeType >[Method]; }> = { - getById: (id) => - storeRuntime(store).recordedEdgeGetById(kind, id, coordinate), - getByIds: (ids) => - storeRuntime(store).recordedEdgeGetByIds(kind, ids, coordinate), - scan: (options) => - storeRuntime(store).recordedEdgeScan(kind, coordinate, options), + getById: async (id, stated?: unknown) => { + assertNoStatedCoordinate("getById", stated, coordinate); + return storeRuntime(store).recordedEdgeGetById(kind, id, coordinate); + }, + getByIds: async (ids, stated?: unknown) => { + assertNoStatedCoordinate("getByIds", stated, coordinate); + return storeRuntime(store).recordedEdgeGetByIds(kind, ids, coordinate); + }, + scan: async (options) => { + assertNoStatedCoordinate("scan", options, coordinate); + return storeRuntime(store).recordedEdgeScan(kind, coordinate, options); + }, }; return recordedCollectionProxy(reads, live, coordinate, "edge"); } @@ -846,6 +966,19 @@ abstract class CoordinatePinnedView { }); } + /** This view's read options with its coordinate applied ({@link pinnedOptions}). */ + #pinned( + method: string, + options: Options | undefined, + ): Options & InternalReadParams { + return pinnedOptions( + method, + options, + this.coordinate, + withCoordinate(this.coordinate), + ); + } + /** The temporal mode this view reads in. */ get mode(): TemporalMode { return this.coordinate.valid.mode; @@ -865,6 +998,29 @@ abstract class CoordinatePinnedView { return storeRuntime(this.store).sealedQuery(this.coordinate); } + /** + * Reads the node a `{ kind, id }` reference names at this view's + * coordinate, mirroring `Store.getNodeByRef`. + */ + getNodeByRef>( + reference: Readonly<{ kind: K; id: string }>, + ): Promise | undefined>; + getNodeByRef( + reference: Readonly<{ kind: string; id: string }>, + ): Promise> | Node | undefined>; + getNodeByRef( + reference: Readonly<{ kind: string; id: string }>, + ): Promise { + return readNodeByReference( + this.store.graph, + (kind) => (this.nodes as Readonly>)[kind], + reference, + ); + } + + /** The view's pinned node collections, keyed by kind. */ + abstract get nodes(): object; + protected internalAlgorithms(): InternalGraphAlgorithms { this.#internalAlgorithms ??= storeRuntime( this.store, @@ -892,128 +1048,128 @@ abstract class CoordinatePinnedView { } /** Extracts a subgraph at this view's pinned coordinate. */ - subgraph< + async subgraph< const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: StoreViewSubgraphOptions, - ): Promise> { - const internalOptions = { - ...options, - ...withCoordinate(this.coordinate), - } as InternalSubgraphOptions; + options: StoreViewSubgraphOptions, + ): Promise, P>> { + const internalOptions = this.#pinned( + "subgraph", + options, + ) as InternalSubgraphOptions; return storeRuntime(this.store).subgraphAtCoordinate(root, internalOptions); } /** Shortest path between two nodes at this view's pinned coordinate. */ - shortestPath( + async shortestPath( from: NodeIdentifier, to: NodeIdentifier, options: StoreViewShortestPathOptions, ): Promise { - return this.internalAlgorithms().shortestPath(from, to, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().shortestPath( + from, + to, + this.#pinned("shortestPath", options), + ); } /** Minimum-total-weight path at this view's pinned coordinate. */ - weightedShortestPath( + async weightedShortestPath( from: NodeIdentifier, to: NodeIdentifier, options: StoreViewWeightedShortestPathOptions, ): Promise { - return this.internalAlgorithms().weightedShortestPath(from, to, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().weightedShortestPath( + from, + to, + this.#pinned("weightedShortestPath", options), + ); } /** Nodes reachable from `from` at this view's pinned coordinate. */ - reachable( + async reachable( from: NodeIdentifier, options: StoreViewReachableOptions, ): Promise { - return this.internalAlgorithms().reachable(from, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().reachable( + from, + this.#pinned("reachable", options), + ); } /** Whether `to` is reachable from `from` at this view's pinned coordinate. */ - canReach( + async canReach( from: NodeIdentifier, to: NodeIdentifier, options: StoreViewCanReachOptions, ): Promise { - return this.internalAlgorithms().canReach(from, to, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().canReach( + from, + to, + this.#pinned("canReach", options), + ); } /** The k-hop neighborhood of `node` at this view's pinned coordinate. */ - neighbors( + async neighbors( node: NodeIdentifier, options: StoreViewNeighborsOptions, ): Promise { - return this.internalAlgorithms().neighbors(node, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().neighbors( + node, + this.#pinned("neighbors", options), + ); } /** Counts active edges incident to `node` at this view's pinned coordinate. */ - degree( + async degree( node: NodeIdentifier, options?: StoreViewDegreeOptions, ): Promise { - return this.internalAlgorithms().degree(node, { - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().degree( + node, + this.#pinned("degree", options), + ); } /** Deterministic label-propagation memberships at this view's coordinate. */ - labelPropagation( + async labelPropagation( options: StoreViewLabelPropagationOptions, ): Promise { - return this.internalAlgorithms().labelPropagation({ - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().labelPropagation( + this.#pinned("labelPropagation", options), + ); } /** Exact WCC memberships at this view's pinned coordinate. */ - weaklyConnectedComponents( + async weaklyConnectedComponents( options: StoreViewWeaklyConnectedComponentsOptions, ): Promise { - return this.internalAlgorithms().weaklyConnectedComponents({ - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().weaklyConnectedComponents( + this.#pinned("weaklyConnectedComponents", options), + ); } /** Global PageRank scores at this view's pinned coordinate. */ - pageRank( + async pageRank( options: StoreViewPageRankOptions, ): Promise { - return this.internalAlgorithms().pageRank({ - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().pageRank( + this.#pinned("pageRank", options), + ); } /** Personalized PageRank scores at this view's pinned coordinate. */ - personalizedPageRank( + async personalizedPageRank( options: StoreViewPersonalizedPageRankOptions, ): Promise { - return this.internalAlgorithms().personalizedPageRank({ - ...options, - ...withCoordinate(this.coordinate), - }); + return this.internalAlgorithms().personalizedPageRank( + this.#pinned("personalizedPageRank", options), + ); } } @@ -1089,25 +1245,35 @@ class StoreViewImplementation< } /** Heterogeneous multi-kind edge read pinned to this view's coordinate. */ - bulkFindEdgesFrom>( + async bulkFindEdgesFrom>( params: BulkFindEdgesFromParams, options?: Omit, ): Promise[]> { - return this.store.bulkFindEdgesFrom(params, { - ...options, - ...withValidCoordinate(this.coordinate), - }); + return this.store.bulkFindEdgesFrom( + params, + pinnedOptions( + "bulkFindEdgesFrom", + options, + this.coordinate, + withValidCoordinate(this.coordinate), + ), + ); } /** Inbound multi-kind edge read pinned to this view's coordinate. */ - bulkFindEdgesTo>( + async bulkFindEdgesTo>( params: BulkFindEdgesToParams, options?: Omit, ): Promise[]> { - return this.store.bulkFindEdgesTo(params, { - ...options, - ...withValidCoordinate(this.coordinate), - }); + return this.store.bulkFindEdgesTo( + params, + pinnedOptions( + "bulkFindEdgesTo", + options, + this.coordinate, + withValidCoordinate(this.coordinate), + ), + ); } /** diff --git a/packages/typegraph/src/store/store.ts b/packages/typegraph/src/store/store.ts index c2dc21a41..5b63ed5a2 100644 --- a/packages/typegraph/src/store/store.ts +++ b/packages/typegraph/src/store/store.ts @@ -32,6 +32,7 @@ import { requireRecordedTime, } from "../backend/capabilities/recorded-time"; import { + capturesTypeGraphRecordedHistory, isEngineNativeRecordedReadBinding, type RecordedTimeOwnership, resolveRecordedTimeOwnership, @@ -69,7 +70,7 @@ import { POSTGRES_ROW_MAPPER_CONFIG, SQLITE_ROW_MAPPER_CONFIG, } from "../backend/row-mappers"; -import { countSchemaKindRows } from "../backend/schema-kind-emptiness"; +import { schemaKindHasRows } from "../backend/schema-kind-emptiness"; import { type AdapterBackend, type BackendCapabilities, @@ -113,8 +114,8 @@ import { asRecordedInstant, createEngineRecordedInstant, type ReadCoordinate, + recordedDiagonalCoordinate, type RecordedInstant, - recordedInstantWallTime, resolveReadCoordinate, withRecordedCoordinate, } from "../core/temporal"; @@ -152,23 +153,29 @@ import { mergeGraphExtension } from "../graph-extension/merge"; import { planRemovals, stripGraphExtension } from "../graph-extension/remove"; import { refuseEngineNativeRecordedIdentityRead } from "../identity/historical-sql"; import { - ensureIdentitySchemaStorage, identityKindCascadeNeeded, identitySchemaCommitPreflight, inspectAdoptedIdentityStorage, } from "../identity/schema-transition"; import { applyIdentityChangesForContext, + applyIdentityFold, assertAffectedIdentityClassesConsistent, + assertIdentityFoldable, createIdentityFacade, createIdentityReadFacade, detachIdentityForNode, foldIdentityForCreatedNodes, + type IdentityFoldPlan, type IdentityImportSummary, type IdentityRebuildContext, type IdentityServiceContext, type IdentityTransferAssertion, + type IdentityTransitionCursor, + type IdentityTransitionTransfer, importIdentityAssertionsIntoTarget, + importIdentityTransitionsIntoTarget, + isIdentityTransitionRestore, liveNodeKindsSharingIds, loadAssertionsByIds, loadCurrentStructuralClasses, @@ -177,14 +184,23 @@ import { readIdentityAssertionsByIdsAtTarget, readIdentityAssertionsForInterchange, readIdentityAssertionsTouchingAtTarget, + readIdentityRestoreBaseline, + readIdentityTransitionPageForInterchange, + readTransitionRetentionDetails, rebuildIdentityClosureForContext, refKey, removeIdentityKindsForContext, requireNodeValidityEndCompatible, toTransferAssertion, + toTransitionTransfer, validateIdentityForContext, } from "../identity/service"; import { type IdentityTarget } from "../identity/sql-target"; +import { + type IdentityDecisionProvenance, + type IdentityRestoreBaseline, + identityTransitionLogUnavailableError, +} from "../identity/transition-log"; import type { IdentityFacade, IdentityNode, @@ -209,6 +225,11 @@ import { type OneStatementBatchResults, type QueryCoordinateState, } from "../query/builder"; +import { + bindCompileTimeAliasTyping, + type DefaultAliasExpansionAxis, + resolveDefaultAliasExpansion, +} from "../query/builder/alias-expansion"; import type { BatchOnceOptions } from "../query/builder/one-statement-batch"; import { createEngineRecordedReadBinding, @@ -235,22 +256,30 @@ import { prepareEvolutionPlan, } from "../schema/evolution-plan"; import { + activeSchemaHash, applyDeprecatedKinds, commitNewSchemaVersion, commitNewSchemaVersionIfKindsEmpty, commitNewSchemaVersionWithPreflight, - ensureSchema as ensureSchemaImpl, + composeSchemaCommitPreflight, + ensureIdentityStorageOnAdoptedBaseSchema, + ensureSchemaInternal as ensureSchemaImpl, getSchemaChanges, loadActiveSchemaWithBootstrap, loadAndMergeGraphExtensionDocument, loadAndVerifyGraph, parseSerializedSchema, + planSchemaCommitPreflight, requiresMigration as requiresMigrationImpl, type SchemaManagerOptions, type SchemaValidationResult, } from "../schema/manager"; import { type SchemaDiff } from "../schema/migration"; -import { serializeSchema } from "../schema/serializer"; +import { + serializeSchema, + serializeSchemaPreservingUnknownFields, +} from "../schema/serializer"; +import { prepareStoreVerbSchemaTighteningPreflight } from "../schema/tightening-preflight"; import { type SerializedSchema } from "../schema/types"; import { nowIso, validityWindowContainsInstant } from "../utils/date"; import { generateId } from "../utils/id"; @@ -261,11 +290,16 @@ import { type GraphAlgorithms, type InternalGraphAlgorithms, } from "./algorithms"; -import { applyResolvedNodeClaims } from "./claims/resolved-node-claims"; +import { + applyResolvedNodeClaims, + type ResolvedNodeRelease, + type ResolvedNodeUpsert, +} from "./claims/resolved-node-claims"; import { type ConstraintFenceViolation, verifyConstraintFences as verifyConstraintFencesImpl, } from "./claims/verify"; +import { resolveClearGraphCommand } from "./clear-graph-command"; import { createEdgeCollectionsProxy, createNodeCollectionsProxy, @@ -316,7 +350,9 @@ import { type NeighborResult, readNeighbors, } from "./neighbors"; +import { readNodeByReference } from "./node-reference-read"; import { + applyIdentityWindowEnd, type EdgeOperationContext, edgeUpsertDirtyCheck, executeEdgeBulkGetOrCreateByEndpoints, @@ -339,27 +375,33 @@ import { executeNodeCreateNoReturnBatch, executeNodeDelete, executeNodeDeleteBatch, + executeNodeDeletePlan, executeNodeFindByConstraint, executeNodeGetOrCreateByConstraint, executeNodeHardDelete, + executeNodeReparent, + executeNodeReparentBatch, executeNodeReplacementBatch, executeNodeResolvedMutationSet, + executeNodeRevive, executeNodeSetUpdate, executeNodeUpdate, executeNodeUpsertUpdateBatch, + type IdentityWindowEndContext, lockSchemaVersionForStoreWrite, type NodeOperationContext, nodeUpsertDirtyCheck, prepareNodeReplacement, } from "./operations"; +import { nodeKindOwesCompositionEdge } from "./operations/atomic-mutation-program"; import { batchRefusalDetails, batchRefusalSuffix, diagnoseFusedSchemaFenceNoRow, + refuseReadOnlyTransactionWrite, resolveBatchWriteVerdict, runInWriteTransaction, withPreAcquiredTransactionSchemaFenceLease, - withTransactionSchemaFenceLease, withWriteTransactionSession, type WriteTransactionContext, } from "./operations/write-transaction"; @@ -369,6 +411,7 @@ import { assertRecordedCaptureTransactionIsolation, assertRevisionTrackableBackend, beginPreCommitHookAttempt, + changesSinceIncludingIdentity, createMutationWitness, createRecordedBackend, createRecordedTransactionScope, @@ -386,6 +429,7 @@ import { settleTransactionPreCommitHook, throwHistoryUnsafeSqlAccess, throwRevisionTrackingUnsafeSqlAccess, + transactionOwnsSqliteWriteLock, withRecordedFlushObserver, withRecordedNodeMutationTarget, withRecordedRelationsPrecondition, @@ -395,6 +439,11 @@ import { createRecordedReadService, type RecordedReadService, } from "./recorded-read-service"; +import { + assertRegisteredEdgeKinds, + assertRegisteredNodeKinds, + requireNodeRegistration, +} from "./registered-kinds"; import { rowToEdge, rowToNode } from "./row-mappers"; import { bindEvolvedTransactionStore, @@ -418,12 +467,16 @@ import { } from "./store-view"; import { createSubgraphRead, + executeSessionSubgraph, executeSubgraph, type InternalSubgraphOptions, + type SubgraphCompositionSelection, type SubgraphOptions, type SubgraphProject, + type SubgraphProjectFor, type SubgraphRead, type SubgraphResult, + type SubgraphResultEdgeKinds, } from "./subgraph"; import { createTransactionReceiptRecorder, @@ -441,6 +494,7 @@ import { type BulkFindRuntimeEdgesFromParams, type BulkFindRuntimeEdgesFromResult, type BulkOperationHookContext, + type CompositionCascadedPart, type DynamicEdgeCollection, type DynamicNodeCollection, type Edge, @@ -448,7 +502,9 @@ import { type GraphEdgeCollections, type GraphEdgeForKinds, type GraphNodeCollections, + type GraphNodeForKinds, type GraphNodeReference, + type GraphNodeReferenceInput, type HeterogeneousNodeUpsertInput, type HeterogeneousNodeUpsertResult, type HistoryStoreOptions, @@ -459,7 +515,9 @@ import { type MeasurableHistoryTransactionContext, type MeasurableTransactionContext, type Node, + type NodeReferenceLookup, type OperationHookContext, + type OperationOutcomeFacts, type QueryHookContext, type QueryOptions, type RecordedHeterogeneousNodeWriteBatch, @@ -476,6 +534,7 @@ import { TRANSACTION_RUNTIME, type TransactionContext, type TransactionOutcome, + type TransactionRuntime, type UnboundLiveStoreOptions, type WorkingCopyOptions, } from "./types"; @@ -636,8 +695,26 @@ type OperationHookRunner = ( ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ) => Promise; +/** + * THE one place a start context becomes an end context: the facts the + * operation learned while it ran, folded on. `onOperationStart` always sees + * the bare context — the facts do not exist yet — so the immediate and + * buffered runners share this fold rather than each re-spelling the spread + * and drifting on which hook gets the enriched object. + */ +function operationEndContext( + ctx: OperationHookContext, + result: T, + operationFacts: + ((result: T) => OperationOutcomeFacts | undefined) | undefined, +): OperationHookContext { + const facts = operationFacts?.(result); + return facts === undefined ? ctx : { ...ctx, ...facts }; +} + type BulkOperationHookRunner = >( ctx: BulkOperationHookContext, fn: () => Promise, @@ -689,11 +766,14 @@ function transactionOutcome( recordedByGraph: RecordedFlushInstants | undefined, graphId: string, ): TransactionOutcome { - const recorded = recordedByGraph?.get(graphId); + const flushed = recordedByGraph?.get(graphId); + if (flushed !== undefined) { + recorder.recordIdentityTransitions(flushed.identityTransitions); + } return { result, receipt: recorder.snapshot( - recorded === undefined ? undefined : asRecordedInstant(recorded), + flushed === undefined ? undefined : asRecordedInstant(flushed.recordedAt), ), }; } @@ -795,6 +875,7 @@ type StoreCore = Readonly<{ ) => RuntimeEdgeKind>>; getNodeCollection: NodeCollectionLookup; getNodeCollectionOrThrow: RequiredNodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; getEdgeCollection: EdgeCollectionLookup; getEdgeCollectionOrThrow: RequiredEdgeCollectionLookup; getNodePropsSchema: (kind: string) => z.ZodObject | undefined; @@ -836,11 +917,11 @@ type StoreCore = Readonly<{ options?: BatchOnceOptions, ) => Promise>; neighbors?: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ) => Promise[]>; countNeighbors?: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ) => Promise; bulkFindEdgesFrom: >( @@ -861,11 +942,12 @@ type StoreCore = Readonly<{ subgraph: < const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: SubgraphOptions, - ) => Promise>; + options: SubgraphOptions, + ) => Promise, P>>; clear: ( options?: Readonly<{ preserveContributionMaterializations?: boolean }>, ) => Promise; @@ -939,11 +1021,11 @@ type StoreTransactions = Readonly<{ /** Builds cold reads that {@link Store.batchOnce} can compose in one statement. */ export type BatchReadBuilder = Readonly<{ neighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ) => CompiledOneStatementRead[]>; countNeighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ) => CompiledOneStatementRead; subgraph: < @@ -967,21 +1049,22 @@ type TransactionReadMethods = Readonly<{ options?: BatchOnceOptions, ) => Promise>; neighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ) => Promise[]>; countNeighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ) => Promise; subgraph: < const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: SubgraphOptions, - ) => Promise>; + options: SubgraphOptions, + ) => Promise, P>>; }>; type AddedStoreReadsBoundary = Readonly<{ @@ -994,11 +1077,11 @@ type AddedStoreReadsBoundary = Readonly<{ options?: BatchOnceOptions, ) => Promise>; neighbors?: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ) => Promise[]>; countNeighbors?: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ) => Promise; lineageRevisionNow?: () => Promise; @@ -1249,10 +1332,10 @@ async function assertEvolvedSchemaRequiredKindsEmpty( graphId: string, classification: ReturnType, ): Promise { - const counts = await Promise.all( + const populated = await Promise.all( classification.requireEmpty.map(async (entry) => ({ entry, - count: await countSchemaKindRows(backend, graphId, { + hasRows: await schemaKindHasRows(backend, graphId, { entity: entry.entity, kind: entry.kindName, rows: "nonDeleted", @@ -1260,7 +1343,7 @@ async function assertEvolvedSchemaRequiredKindsEmpty( })), ); const nonEmpty = new Set( - counts.filter(({ count }) => count > 0).map(({ entry }) => entry), + populated.filter(({ hasRows }) => hasRows).map(({ entry }) => entry), ); const error = buildIncompatibleChangeError(classification, nonEmpty, graphId); if (error !== undefined) throw error; @@ -1348,6 +1431,7 @@ class StoreImplementation { #schemaMetadata: StoreSchemaMetadata; readonly #runtimeKindOwner = Object.freeze({}); readonly #defaultTraversalExpansion: TraversalExpansion; + readonly #defaultExpansion: DefaultAliasExpansionAxis; // Stored verbatim so `evolve()` can construct the next Store with // identical options. Reconstructing from the individual private // fields would silently drop any future StoreOptions field a @@ -1459,8 +1543,10 @@ class StoreImplementation { // capture (construction below, recordedNow/revisionNow, and the two // transaction-commit sites). this.#requestedHistory = requestedHistory; - this.#captureEnabled = - requestedHistory && this.#recordedTimeOwnership === "typegraph-relations"; + this.#captureEnabled = capturesTypeGraphRecordedHistory( + requestedHistory, + backend, + ); this.#engineNativeHistory = requestedHistory && this.#recordedTimeOwnership === "engine-native"; this.#revisionTrackingEnabled = @@ -1533,9 +1619,14 @@ class StoreImplementation { mapRecordedEdgeRow: createEdgeRowMapper(rowMapperConfig), }); this.#registry = buildKindRegistry(graph); + bindCompileTimeAliasTyping(this.#registry, stripGraphExtension(graph)); this.#hooks = options?.hooks ?? {}; this.#defaultTraversalExpansion = options?.queryDefaults?.traversalExpansion ?? "inverse"; + this.#defaultExpansion = resolveDefaultAliasExpansion( + options?.queryDefaults?.expansion, + "queryDefaults", + ); this.#options = options; this.#schemaMetadata = schemaMetadata ?? UNKNOWN_SCHEMA_METADATA; this[STORE_RUNTIME] = { @@ -1544,6 +1635,7 @@ class StoreImplementation { captureEnabled: this.#captureEnabled, recordedReadBinding: this.#recordedReadBinding, uniqueSidecarBatch: this.#uniqueSidecarBatch, + batchPointRead: this.#batchPointRead, // The query path's own construction, not a second spelling of it: a // caller that could only rebuild this object could not observe the one // the queries actually run on. @@ -1568,44 +1660,22 @@ class StoreImplementation { this.algorithmsAtCoordinate(coordinate), identityAtCoordinate: (coordinate) => this.identityAtCoordinate(coordinate), + identityContext: () => { + this.#requireIdentityEnabled(); + return this.#identityContext(this.#backend); + }, rebuildIdentityClosure: () => this.rebuildIdentityClosure(), validateIdentity: () => this.validateIdentity(), + deleteNodeWithPolicy: (target, work, policy) => + executeNodeDelete( + this.#createNodeOperationContext(), + work.kind, + work.id, + target, + { policy }, + ), applyResolvedNodeUniqueness: async (target, writes, apply) => { - const upserts = writes.upserts.map((upsert) => { - if (!hasOwnKey(this.#graph.nodes, upsert.kind)) { - throw new KindNotFoundError(upsert.kind, "node", { - graphId: this.graphId, - }); - } - const registration = this.#graph.nodes[upsert.kind]; - if (registration === undefined) { - throw new KindNotFoundError(upsert.kind, "node", { - graphId: this.graphId, - }); - } - return { - ...upsert, - constraints: registration.unique ?? [], - }; - }); - // Which kinds a release is worth CLEARING for: the clear exists so the - // set's upserts can take keys the set is giving back, and only a - // uniqueness declaration produces a key another node could take. - const constrainedKinds = new Set( - Object.entries(this.#graph.nodes) - .filter( - ([, registration]) => (registration.unique ?? []).length > 0, - ) - .map(([kind]) => kind), - ); - const releases = writes.releases.filter((release) => { - if (!Object.hasOwn(this.#graph.nodes, release.kind)) { - throw new KindNotFoundError(release.kind, "node", { - graphId: this.graphId, - }); - } - return constrainedKinds.has(release.kind); - }); + const { upserts, releases } = this.#resolvedNodeClaimWrites(writes); if ( upserts.every((upsert) => upsert.constraints.length === 0) && releases.length === 0 @@ -1708,10 +1778,37 @@ class StoreImplementation { this.lockIdentityImportTarget(target), foldImportedIdentityNodes: (target, references) => this.foldImportedIdentityNodes(target, references), + applyImportedNodeWindowEnd: (target, input) => + applyIdentityWindowEnd(this.#identityWindowEndContext(), target, input), + detachDeletedImportedIdentityNode: (target, reference) => + this.detachDeletedImportedIdentityNode(target, reference), importIdentityAssertionsAtTarget: (target, assertions, mode) => this.importIdentityAssertionsAtTarget(target, assertions, mode), - applyIdentityMergeAtTarget: (target, retractions, assertions) => - this.applyIdentityMergeAtTarget(target, retractions, assertions), + readIdentityTransitionPageAtTarget: (target, options) => + this.readIdentityTransitionPageAtTarget(target, options), + identityTransitionRetentionAtTarget: (target) => + this.identityTransitionRetentionAtTarget(target), + readIdentityRestoreBaselineAtTarget: (target) => + this.readIdentityRestoreBaselineAtTarget(target), + importIdentityTransitionsAtTarget: ( + target, + transitions, + watermark, + baseline, + ) => + this.importIdentityTransitionsAtTarget( + target, + transitions, + watermark, + baseline, + ), + applyIdentityMergeAtTarget: (target, retractions, assertions, decision) => + this.applyIdentityMergeAtTarget( + target, + retractions, + assertions, + decision, + ), assertIdentityClassesConsistentAtTarget: (target, seeds) => this.assertIdentityClassesConsistentAtTarget(target, seeds), }; @@ -1776,6 +1873,44 @@ class StoreImplementation { ); } + /** + * The resolved write set as the claim layer reads it: every upsert carrying + * its kind's registered unique constraints, and the releases narrowed to the + * kinds whose declarations produce a key another node could take. + */ + #resolvedNodeClaimWrites( + writes: Readonly<{ + upserts: readonly Readonly<{ + kind: string; + id: string; + props: Readonly>; + }>[]; + releases: readonly Readonly<{ kind: string; id: string }>[]; + }>, + ): Readonly<{ + upserts: readonly ResolvedNodeUpsert[]; + releases: readonly ResolvedNodeRelease[]; + }> { + const upserts = writes.upserts.map((upsert) => ({ + ...upsert, + constraints: + requireNodeRegistration(this.#graph, upsert.kind).unique ?? [], + })); + // Which kinds a release is worth CLEARING for: the clear exists so the + // set's upserts can take keys the set is giving back, and only a + // uniqueness declaration produces a key another node could take. + const constrainedKinds = new Set( + Object.entries(this.#graph.nodes) + .filter(([, registration]) => (registration.unique ?? []).length > 0) + .map(([kind]) => kind), + ); + const releases = writes.releases.filter((release) => { + assertRegisteredNodeKinds(this.#graph, [release.kind]); + return constrainedKinds.has(release.kind); + }); + return { upserts, releases }; + } + /** @internal Builds the identity read facade for a pinned StoreView. */ identityAtCoordinate(coordinate: ReadCoordinate): IdentityReadFacade { this.#requireIdentityEnabled(); @@ -1842,6 +1977,12 @@ class StoreImplementation { await identitySchemaCommitPreflight(this.#identityContext(this.#backend), { enablement: false, provisionDerivedRelations, + // The schema-commit target is a raw transaction no capture session is + // bound to yet, so a history store binds one for the preflight's own + // ledger touches and transition notes. + ...(this.#captureEnabled ? + { capture: { source: "store", batchPointRead: this.#batchPointRead } } + : {}), })(target); } @@ -1873,7 +2014,7 @@ class StoreImplementation { target, this.#batchPointRead, this.#sqlSchema(), - target.dialect === "sqlite", + transactionOwnsSqliteWriteLock(target), ); await removeIdentityKindsForContext( this.#identityContext(scope.backend), @@ -1963,7 +2104,14 @@ class StoreImplementation { : lockIdentityGraph(target, this.graphId); } - /** @internal Restores same-id folding after the ops-layer import bypass. */ + /** + * @internal Restores same-id folding after the ops-layer import bypass. + * + * Always `"fold"`: import never resurrects a tombstone (a soft-deleted node + * is not updatable through it — see `src/interchange/import.ts`), so every + * reference this reaches is a genuine first materialization, never a + * restore. + */ foldImportedIdentityNodes( target: IdentityTarget, references: readonly Readonly<{ kind: string; id: string }>[], @@ -1980,6 +2128,33 @@ class StoreImplementation { }, target, references, + "fold", + ); + } + + /** + * @internal Detaches a node import purges after folding it into + * identity — a required composition part `assertImportedRequiredPartsAttached` + * (`src/interchange/import.ts`) refuses AFTER `foldImportedIdentityNodes` + * already ran for this attempt's batch. Always `"hard"`: the row this + * reaches was created and purged within the SAME import, exactly the + * `executeNodeHardDelete` shape (`src/store/operations/node-operations.ts`) + * `identity.detachDeleted` already serves for the ordinary write path. + */ + detachDeletedImportedIdentityNode( + target: IdentityTarget, + reference: Readonly<{ kind: string; id: string }>, + ): Promise { + if (this.#graph.identity === undefined) return Promise.resolve(); + return detachIdentityForNode( + { + graphId: this.graphId, + sameIdAcrossKinds: this.#graph.identity.sameIdAcrossKinds, + schema: this.#sqlSchema(), + }, + target, + reference, + "hard", ); } @@ -2010,6 +2185,107 @@ class StoreImplementation { ); } + /** @internal Reads one bounded page of a graph's ARCHIVAL identity transitions, oldest first. */ + async readIdentityTransitionPageAtTarget( + target: GraphBackend | TransactionBackend, + options: Readonly<{ after?: IdentityTransitionCursor; limit: number }>, + ): Promise< + Readonly<{ + transitions: readonly IdentityTransitionTransfer[]; + nextAfter?: IdentityTransitionCursor; + done: boolean; + }> + > { + if (this.#graph.identity === undefined) { + return { transitions: [], done: true }; + } + const page = await readIdentityTransitionPageForInterchange( + target, + this.#sqlSchema(), + this.graphId, + options, + ); + return { + transitions: page.transitions.map((row) => toTransitionTransfer(row)), + ...(page.nextAfter === undefined ? {} : { nextAfter: page.nextAfter }), + done: page.done, + }; + } + + /** @internal Reads a graph's identity transition-retention watermark for archival export. */ + identityTransitionRetentionAtTarget( + target: GraphBackend | TransactionBackend, + ): ReturnType { + if (this.#graph.identity === undefined) { + return Promise.resolve({ prunedBeforeRevision: 0, prunedAt: nowIso() }); + } + return readTransitionRetentionDetails( + target, + this.#sqlSchema(), + this.graphId, + ); + } + + /** + * Refuses an archival transition restore this graph cannot hold: an + * identity-disabled graph has no transition log, and a history-off graph + * has nowhere for `transitionsOf` / `replay` to read restored rows back + * from, so restoring them would write data the store's own API can never + * surface. `importGraph` / `importGraphStream` (`interchange/import.ts`) + * already refuse this UPFRONT, before any node or edge write; this is the + * BACKSTOP every port method of the restore passes through. + */ + #requireIdentityTransitionRestore(): void { + if (this.#graph.identity === undefined) { + throw new ConfigurationError( + "Cannot import identity transitions into an identity-disabled graph.", + { + code: "IDENTITY_IMPORT_REQUIRES_PROFILE", + graphId: this.graphId, + }, + ); + } + if (!this.#captureEnabled) { + throw identityTransitionLogUnavailableError( + this.graphId, + this.#recordedTimeOwnership, + ); + } + } + + /** @internal Reads the destination facts an archival transition restore decides on. */ + readIdentityRestoreBaselineAtTarget( + target: IdentityTarget, + ): Promise { + this.#requireIdentityTransitionRestore(); + return readIdentityRestoreBaseline( + { graphId: this.graphId, schema: this.#sqlSchema() }, + target, + ); + } + + /** @internal Restores archival identity transitions inside an import transaction. */ + importIdentityTransitionsAtTarget( + target: IdentityTarget, + transitions: readonly IdentityTransitionTransfer[], + carriedWatermark: number | undefined, + baseline: IdentityRestoreBaseline, + ): ReturnType { + if ( + !isIdentityTransitionRestore(transitions.length > 0, carriedWatermark) + ) { + return Promise.resolve({ created: 0, watermark: undefined }); + } + this.#requireIdentityTransitionRestore(); + return importIdentityTransitionsIntoTarget( + { graphId: this.graphId, schema: this.#sqlSchema() }, + target, + transitions, + carriedWatermark, + baseline, + ); + } + /** * @internal Post-write identity validation for a graph-merge commit: proves * the identity classes the merge touched carry no contradiction in the state @@ -2034,6 +2310,7 @@ class StoreImplementation { target: GraphBackend | TransactionBackend, retractions: readonly IdentityTransferAssertion[], assertions: readonly IdentityTransferAssertion[], + decision?: IdentityDecisionProvenance, ): Promise> { if (retractions.length === 0 && assertions.length === 0) { return Promise.resolve({ created: 0, retracted: 0 }); @@ -2048,6 +2325,7 @@ class StoreImplementation { this.#identityContext(target), retractions, assertions, + decision, ); } @@ -2374,6 +2652,29 @@ class StoreImplementation { ] as unknown as DynamicNodeCollection; } + /** Resolve node lookups through the supplied context's collections, as {@link #edgeCollectionAccess} does for edges. */ + #nodeCollectionAccess(collections: GraphNodeCollections): Readonly<{ + getNodeCollection: NodeCollectionLookup; + getNodeByRef: NodeReferenceLookup; + }> { + const getNodeCollection = ( + kind: K, + ): DynamicNodeCollection | undefined => + this.#resolveDynamicNodeCollection(collections, kind); + const getNodeByRef = ( + reference: Readonly<{ kind: string; id: string }>, + ): Promise => + readNodeByReference( + this.#graph, + (kind) => getNodeCollection(kind), + reference, + ); + return { + getNodeCollection, + getNodeByRef: getNodeByRef as NodeReferenceLookup, + }; + } + /** * Returns the node collection for the given kind, or undefined if the kind * is not registered in this graph. @@ -2407,13 +2708,26 @@ class StoreImplementation { ): DynamicNodeCollection; getNodeCollectionOrThrow(kindOrToken: string | RuntimeNodeKind): unknown { const kind = this.#resolveRuntimeKind(kindOrToken, "node"); - const collection = this.#resolveDynamicNodeCollection(this.nodes, kind); - if (collection === undefined) { - throw new KindNotFoundError(kind, "node", { - graphId: this.graphId, - }); - } - return collection; + assertRegisteredNodeKinds(this.#graph, [kind]); + return this.#resolveDynamicNodeCollection(this.nodes, kind); + } + + /** + * Reads the node a `{ kind, id }` reference names, such as an edge's + * `{ kind: edge.fromKind, id: edge.fromId }`; see {@link NodeReferenceLookup} + * for the result typing. Returns `undefined` when the node does not exist + * under that kind and throws `KindNotFoundError` for an unregistered kind. + */ + getNodeByRef>( + reference: Readonly<{ kind: K; id: string }>, + ): Promise | undefined>; + getNodeByRef( + reference: Readonly<{ kind: string; id: string }>, + ): Promise> | Node | undefined>; + getNodeByRef( + reference: Readonly<{ kind: string; id: string }>, + ): Promise { + return this.#nodeCollectionAccess(this.nodes).getNodeByRef(reference); } /** @@ -2557,6 +2871,7 @@ class StoreImplementation { introspect(): SchemaIntrospection { return introspectSchema(this.#graph, { graphId: this.graphId, + registry: this.#registry, schemaVersion: this.#schemaMetadata.schemaVersion, schemaHash: this.#schemaMetadata.schemaHash, }); @@ -2693,12 +3008,25 @@ class StoreImplementation { upsertDirtyCheck: (kind, id, existingProps, inputProps) => nodeUpsertDirtyCheck(ctx, kind, id, existingProps, inputProps), }), - executeDelete: (kind, id, backend) => - executeNodeDelete(ctx, kind, id, backend), + // `nodes..reparent(...)` is THE surface that moves a part, so it + // is the one that states `onIncumbent: "replace"`; every get-or-create + // path reaches the same write plan with `"refuse"`. + executeReparent: (kind, id, options, backend) => + executeNodeReparent(ctx, kind, id, options, backend, { + onIncumbent: "replace", + }), + executeReparentBatch: (kind, items, backend) => + executeNodeReparentBatch(ctx, kind, items, backend, { + onIncumbent: "replace", + }), + executeDelete: (kind, id, backend, expect) => + executeNodeDelete(ctx, kind, id, backend, { expect }), executeDeleteBatch: (kind, ids, backend) => executeNodeDeleteBatch(ctx, kind, ids, backend), executeHardDelete: (kind, id, backend) => executeNodeHardDelete(ctx, kind, id, backend), + executeDeletePlan: (kind, id, backend) => + executeNodeDeletePlan(ctx, kind, id, backend), temporalRowMatcher: (options) => this.#temporalRowMatcher(options), createQuery: () => this.query(), executeGetOrCreateByConstraint: ( @@ -2767,8 +3095,8 @@ class StoreImplementation { maybeRefreshStatisticsAfterBulk: (rowCount) => this.#maybeRefreshStatisticsAfterBulk(rowCount), executeCreate: (input, backend) => executeEdgeCreate(ctx, input, backend), - executeCreateBatch: (inputs, backend) => - executeEdgeCreateBatch(ctx, inputs, backend), + executeCreateBatch: (inputs, backend, options) => + executeEdgeCreateBatch(ctx, inputs, backend, options), executeCreateNoReturnBatch: (inputs, backend) => executeEdgeCreateNoReturnBatch(ctx, inputs, backend), executeUpdate: (input, backend) => executeEdgeUpdate(ctx, input, backend), @@ -3016,14 +3344,12 @@ class StoreImplementation { * increments. */ asOfRecorded(recordedAsOf: RecordedInstant): RecordedStoreView { - const validCoordinate = resolveReadCoordinate( - "asOf", - recordedInstantWallTime(recordedAsOf), - "Use await store.recordedNow() as the anchor, or asRecordedInstant(value) only for an instant previously read from recordedNow().", - ); return new RecordedStoreView( this, - withRecordedCoordinate(validCoordinate, recordedAsOf), + recordedDiagonalCoordinate( + recordedAsOf, + "Use await store.recordedNow() as the anchor, or asRecordedInstant(value) only for an instant previously read from recordedNow().", + ), ); } @@ -3098,6 +3424,30 @@ class StoreImplementation { return createEngineRecordedInstant(revision.revision, revision.recordedAt); } + /** + * The engine-native counterpart to a capture flush's + * {@link RecordedFlushInstants}: one entry, for this store's one graph. + * The ONE owner of that shape on this path, called by both transaction + * sites that stamp `TransactionReceipt.recorded` under engine-native + * ownership, so neither can spell the map differently from the other. + * `identityTransitions` is a measured `0`: the identity transition log is + * written only by a TypeGraph capture session's flush, and engine-native + * ownership runs none. + */ + async #engineRecordedFlushInstants( + session: RecordedTimeSession, + ): Promise { + return new Map([ + [ + this.graphId, + { + recordedAt: await this.#engineRecordedInstant(session), + identityTransitions: 0, + }, + ], + ]); + } + /** * Returns the durable graph revision used by graph branching. It is undefined * until the first successful tracked write, which is itself a stable initial @@ -3166,14 +3516,13 @@ class StoreImplementation { /** * Lists entity keys changed since a lineage revision. History-enabled * stores use recorded relations; bundled SQLite and PostgreSQL live stores - * with revision tracking use the entity-key journal. Unknown backends, - * identity-only changes, and revisions with incomplete journal evidence - * return `unbounded`. + * with revision tracking use the entity-key journal. Unknown backends, a + * span in which an identity assertion changed, and revisions with + * incomplete evidence return `unbounded`: the keys name nodes and edges + * only, so an identity write is never reported as an exact delta. */ async changesSince(revision: EngineRevision): Promise { - const lineage = resolveLineage(this); - if (lineage === undefined) return { kind: "unbounded" }; - return lineage.changesSince(this.#backend, revision, this.graphId); + return changesSinceIncludingIdentity(this, this.#backend, revision); } /** @@ -3411,7 +3760,7 @@ class StoreImplementation { /** Reads hydrated adjacent nodes and their connecting edges in one statement. */ async neighbors>( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ): Promise[]> { this.#assertNeighborKinds(source, options.edges); @@ -3420,7 +3769,7 @@ class StoreImplementation { /** Counts visible relationships to adjacent nodes without hydrating them. */ async countNeighbors>( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ): Promise { this.#assertNeighborKinds(source, options.edges); @@ -3441,21 +3790,11 @@ class StoreImplementation { } #assertNeighborKinds( - source: GraphNodeReference, + source: GraphNodeReferenceInput, edgeKinds: readonly EdgeKinds[], ): void { - if (!Object.hasOwn(this.#graph.nodes, source.kind)) { - throw new KindNotFoundError(source.kind, "node", { - graphId: this.graphId, - }); - } - for (const edgeKind of edgeKinds) { - if (!Object.hasOwn(this.#graph.edges, edgeKind)) { - throw new KindNotFoundError(edgeKind, "edge", { - graphId: this.graphId, - }); - } - } + assertRegisteredNodeKinds(this.#graph, [source.kind]); + assertRegisteredEdgeKinds(this.#graph, edgeKinds); } /** @@ -3493,23 +3832,16 @@ class StoreImplementation { options?: EdgeBulkFindEndpointOptions, ): Promise[]> { const operation = side === "from" ? "bulkFindEdgesFrom" : "bulkFindEdgesTo"; - const sources: readonly GraphNodeReference[] = params.sources.flatMap( - (group) => { - if (!Object.hasOwn(this.#graph.nodes, group.kind)) { - throw new KindNotFoundError(group.kind, "node", { - graphId: this.graphId, - }); - } - return group.ids.map((id) => ({ kind: group.kind, id })); - }, + assertRegisteredNodeKinds( + this.#graph, + params.sources.map((group) => group.kind), ); - for (const edgeKind of params.edgeKinds) { - if (!Object.hasOwn(this.#graph.edges, edgeKind)) { - throw new KindNotFoundError(edgeKind, "edge", { - graphId: this.graphId, - }); - } - } + assertRegisteredEdgeKinds(this.#graph, params.edgeKinds); + // The kinds are registered (asserted above); each id is the caller's own + // pairing, echoed back as the result's reference. + const sources = params.sources.flatMap((group) => + group.ids.map((id) => ({ kind: group.kind, id })), + ) as readonly GraphNodeReference[]; if (sources.length === 0 || params.edgeKinds.length === 0) { return sources.map((source) => ({ source, edges: [] })); @@ -3664,11 +3996,12 @@ class StoreImplementation { async subgraph< const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: SubgraphOptions, - ): Promise> { + options: SubgraphOptions, + ): Promise, P>> { // The public surface is valid-time only (`recordedAsOf` is typed `never`). // Guard JS callers who bypass the type so a leaked recorded pin can't // silently switch this read onto the recorded relation; recorded subgraph @@ -3686,11 +4019,44 @@ class StoreImplementation { >( root: GraphNodeReference, options: SubgraphOptions, - backend: GraphBackend | TransactionBackend = this.#baseBackend, - attempt = 1, + backend: GraphBackend | TransactionBackend, + attempt: number, ): SubgraphRead { this.#assertPublicSubgraphOptions(options); - return createSubgraphRead({ + return createSubgraphRead( + this.#sessionSubgraphParams(root, options, backend, attempt), + ); + } + + #executeSessionSubgraph< + const EK extends EdgeKinds, + const NK extends NodeKinds = NodeKinds, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, + >( + root: GraphNodeReference, + options: SubgraphOptions, + txBackend: TransactionBackend, + attempt: number, + ): Promise, P>> { + this.#assertPublicSubgraphOptions(options); + return executeSessionSubgraph( + this.#sessionSubgraphParams(root, options, txBackend, attempt), + ); + } + + #sessionSubgraphParams< + const EK extends EdgeKinds, + const NK extends NodeKinds, + const P extends SubgraphProjectFor | undefined, + const C extends SubgraphCompositionSelection | undefined, + >( + root: GraphNodeReference, + options: InternalSubgraphOptions, + backend: GraphBackend | TransactionBackend, + attempt?: number, + ) { + return { graph: this.#graph, graphId: this.graphId, root, @@ -3698,8 +4064,9 @@ class StoreImplementation { dialect: getDialect(backend.dialect), schema: this.#schema, recordedReadBinding: this.#recordedReadBinding, + registry: this.#registry, options, - }); + }; } #assertPublicSubgraphOptions(options: unknown): void { @@ -3723,11 +4090,12 @@ class StoreImplementation { subgraphAtCoordinate< const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: InternalSubgraphOptions, - ): Promise> { + options: InternalSubgraphOptions, + ): Promise, P>> { const coordinate = resolveReadCoordinate( options.temporalMode ?? this.#graph.defaults.temporalMode, options.asOf, @@ -3736,19 +4104,16 @@ class StoreImplementation { options.recordedAsOf === undefined ? coordinate : withRecordedCoordinate(coordinate, options.recordedAsOf); - return executeSubgraph({ - graph: this.#graph, - graphId: this.graphId, - root, - backend: this.#recordedReads.backendForCoordinate( - readCoordinate, - "recorded-subgraph", + return executeSubgraph( + this.#sessionSubgraphParams( + root, + options, + this.#recordedReads.backendForCoordinate( + readCoordinate, + "recorded-subgraph", + ), ), - dialect: getDialect(this.#backend.dialect), - schema: this.#schema, - recordedReadBinding: this.#recordedReadBinding, - options, - }); + ); } /** @@ -4017,13 +4382,14 @@ class StoreImplementation { // // A schema-version fence is acquired and validated at every managed write // when this Store came from schema reconciliation. A TypeGraph-owned Store - // transaction on a bundled backend is the narrow exception: its first - // managed write acquires the fence and leases that success to later - // managed writes. Such a callback has neither a nested transaction runner - // nor a native SQL handle, so no savepoint can predate that acquisition; - // adapter-native and caller-adopted transactions retain the per-write - // check because their raw SQL can roll back a savepoint and release a - // PostgreSQL row lock acquired after it. + // transaction on a bundled backend whose callback receives no `tx.sql` is + // the narrow exception: its first managed write acquires the fence and + // leases that success to later managed writes. Such a callback has + // neither a nested transaction runner nor a raw SQL handle, so no + // savepoint can predate that acquisition; a callback holding `tx.sql`, and + // a caller-adopted transaction, retain the per-write check because their + // raw SQL can roll back a savepoint and release a PostgreSQL row lock + // acquired after it. // Snapshot-isolated transactions retain their normal serialization-failure // semantics rather than validating a stale version after a concurrent // commit. The separate recorded graph lock is still taken at each write @@ -4120,7 +4486,7 @@ class StoreImplementation { receiptRecorder, runBulkHooks, frame.attempt, - backendOptions.accessMode !== "read_only", + backendOptions.accessMode === "read_only", receiptRequested, ), async (activeContext) => { @@ -4131,17 +4497,6 @@ class StoreImplementation { } }, ); - const invokeWithSchemaFenceLease = (): Promise => - this.#adapterBackend === undefined ? - withTransactionSchemaFenceLease( - { - graphId: this.graphId, - schemaVersion: this.#schemaMetadata.schemaVersion, - }, - writeTarget, - invokeTransaction, - ) - : invokeTransaction(); const output = await withWriteTransactionSession( writeTarget, { @@ -4151,7 +4506,10 @@ class StoreImplementation { revisionTrackingEnabled: this.#revisionTrackingEnabled, revisionSchema: this.#sqlSchema(), }, - invokeWithSchemaFenceLease, + this.#exposedTransactionSql(nativeTransaction) === undefined ? + "reusable" + : "per-write", + invokeTransaction, ); // Fires the pre-commit hook here, once the write session has advanced // the revision clock, unless the recorded-capture backend claimed it @@ -4171,9 +4529,8 @@ class StoreImplementation { // or an empty-body `transactionWithReceipt()` neither takes the // extra round trip nor stamps an instant nothing earned. if (mutationWitness?.mutated === true) { - recordedByGraph = new Map([ - [this.graphId, await this.#engineRecordedInstant(txBackend)], - ]); + recordedByGraph = + await this.#engineRecordedFlushInstants(txBackend); } return output; }; @@ -4544,9 +4901,7 @@ class StoreImplementation { // (undefined when nothing was captured or requested) into `receipt.recorded`. const recordedByGraph = mutationWitness?.mutated === true ? - new Map([ - [this.graphId, await this.#engineRecordedInstant(txBackend)], - ]) + await this.#engineRecordedFlushInstants(txBackend) : await scope.flush(); return transactionOutcome( result, @@ -4580,51 +4935,85 @@ class StoreImplementation { /** * Decorates a receipt-enabled transaction `context` with `measure`. - * Attribution is structural: `measure` wraps the context's *own* (already - * outer-recording) collections a second time with a fresh scope recorder, so a - * write through the scoped context counts in the scope and — via the inner - * wrapper it delegates to — the outer receipt, while a write through the outer - * `context` never reaches the scope recorder. This makes overlapping/concurrent - * measures safe by construction (each holds its own scope recorder) and lets - * scopes nest: the scoped context is itself decorated, so `scoped.measure(...)` - * chains one more wrapper. The scoped context's dynamic collection lookups resolve - * against the scope-wrapped map too, so dynamic-kind writes are attributed like - * `scoped.nodes.`. The scope receipt's `recorded` is always undefined — - * the recorded instant is a whole-transaction flush concern. + * + * Attribution is structural: a scope is one more RECORDER, and the scoped + * context's write surface is rebuilt against the whole recorder CHAIN (every + * recorder already covering `context`, plus the scope's), then wrapped once + * per recorder in it. A write through the scoped context therefore counts in + * the scope and in every receipt enclosing it, while a write through the + * outer `context` never reaches the scope recorder. Overlapping and + * concurrent measures are safe by construction — each holds its own + * recorder, and no dynamic "currently measuring" state exists to leak + * between them — and scopes nest: the scoped context is itself decorated, so + * `scoped.measure(...)` extends the chain by one. The scoped context's + * dynamic collection lookups resolve against the scope's own map too, so + * dynamic-kind writes are attributed like `scoped.nodes.`. The scope + * receipt's `recorded` is always undefined — the recorded instant is a + * whole-transaction flush concern. + * + * Rebuilding the surface rather than wrapping the outer collections a second + * time is what carries the chain to facts a collection wrapper cannot see + * from outside: a node delete's composition `cascadedParts` exist only + * inside the operation, and reach their receipts through the OPERATION + * CONTEXT the collection's operations were bound to + * (`NodeOperationContext.recordCascadedParts`). Wrapping alone would count a + * measured delete in the scope's write counters while leaving the parts it + * cascaded out of that scope's receipt. `TRANSACTION_RUNTIME`'s internal + * delete port is rebound to the scope's context for the same reason — it + * reaches `executeNodeDelete` directly, so inheriting the outer port would + * reopen exactly that hole for the one caller that uses it. */ #attachMeasure( context: AdapterTransactionContext, + buildSurface: ( + recorders: readonly TransactionReceiptRecorder[], + ) => TransactionWriteSurface, + recorders: readonly TransactionReceiptRecorder[], ): MeasurableAdapterTransactionContext { const measure: ScopedMeasure< MeasurableAdapterTransactionContext > = async (fn) => { const scopeRecorder = createTransactionReceiptRecorder(); - const { nodes, edges } = wrapTransactionCollections( - context.nodes, - context.edges, - scopeRecorder, - ); - const identity = - this.#graph.identity === undefined ? - undefined - : wrapTransactionIdentity( - ( - context as unknown as TransactionContext & { - identity: IdentityFacade; - } - ).identity, - scopeRecorder, - ); - const scoped = this.#attachMeasure( - overlayPropertyDescriptors(context, { - nodes, - edges, - ...(identity === undefined ? {} : { identity }), - ...this.#edgeCollectionAccess(edges), - getNodeCollection: (kind: K) => - this.#resolveDynamicNodeCollection(nodes, kind), - }), - ); + const chain = [...recorders, scopeRecorder]; + const surface = buildSurface(chain); + const { + nodes: scopedNodes, + edges: scopedEdges, + identity, + } = wrapSurfaceForRecorders(surface, chain); + // The internal delete port travels with the surface too. It runs + // `executeNodeDelete` against a node operation context directly and + // never touches `surface.nodes` — the collection wrappers just above + // are the only thing that increments a receipt's `writes` counters, so + // this delete counts toward NEITHER this scope's nor any enclosing + // scope's `writes`, rebound or not. What rebinding decides is + // `cascadedParts` alone: a scope that inherited the OUTER context's + // port would run the delete against the OUTER surface's node operation + // context, attributing its `cascadedParts` to the outer chain's + // recorders and leaving THIS scope's own (freshly created) receipt + // without them. Taken from the scope's own surface, so + // `cascadedParts` reaches this scope's receipt too — the same + // attribution-follows-the-context reasoning as the collections above, + // for a write those collections cannot see at all. The receipt this + // yields is therefore a receipt shape callers must expect: a delete + // reported here carries `cascadedParts` while its own `writes` stay at + // zero, since no collection ever counted it. + const scopedContext = overlayPropertyDescriptors(context, { + nodes: scopedNodes, + edges: scopedEdges, + ...(identity === undefined ? {} : { identity }), + ...this.#edgeCollectionAccess(scopedEdges), + ...this.#nodeCollectionAccess(scopedNodes), + [TRANSACTION_RUNTIME]: surface.runtime, + }); + // Non-enumerable, matching the outer context's own definition, so the + // symbol port never leaks into a caller's spread of `tx`. + Object.defineProperty(scopedContext, TRANSACTION_RUNTIME, { + configurable: false, + enumerable: false, + writable: false, + }); + const scoped = this.#attachMeasure(scopedContext, buildSurface, chain); const result = await fn(scoped); return { result, receipt: scopeRecorder.snapshot() }; }; @@ -4660,7 +5049,7 @@ class StoreImplementation { ); }, subgraph: (root, options) => - this.#createSubgraphRead(root, options, txBackend, attempt).execute(), + this.#executeSessionSubgraph(root, options, txBackend, attempt), }; } @@ -4713,25 +5102,16 @@ class StoreImplementation { { code: "HETEROGENEOUS_NODE_BATCH_UNSUPPORTED_TEMPORAL_OPTIONS" }, ); } - if (!hasOwnKey(this.#graph.nodes, entry.kind)) { - throw new KindNotFoundError(entry.kind, "node", { - graphId: this.graphId, - }); - } - const registration = this.#graph.nodes[entry.kind]; - if (registration === undefined) { - throw new KindNotFoundError(entry.kind, "node", { - graphId: this.graphId, - }); - } + const registration = requireNodeRegistration(this.#graph, entry.kind); if ( (registration.unique?.length ?? 0) > 0 || this.#registry.getDisjointKinds(entry.kind).length > 0 || + nodeKindOwesCompositionEdge(this.#registry, entry.kind) || resolveEmbeddingFields(registration.type.schema).length > 0 || getSearchableFields(registration.type.schema).length > 0 ) { throw new ConfigurationError( - "writeNodeUpsertBatch only supports plain node kinds without identity claims or projections.", + "writeNodeUpsertBatch only supports plain node kinds without identity claims, required composition, or projections.", { code: "HETEROGENEOUS_NODE_BATCH_UNSUPPORTED_KIND", kind: entry.kind, @@ -4826,6 +5206,28 @@ class StoreImplementation { ) as unknown as HeterogeneousNodeUpsertResult; } + /** + * The raw SQL handle a transaction callback receives as `tx.sql`, or + * `undefined` when it receives none: history capture and revision tracking + * withhold the handle, and a backend without a native transaction has none + * to give. THE one owner of that decision — the context builder exposes + * exactly this handle, and the write session reuses fence evidence across + * writes (the per-graph write fence and the schema-fence lease) only when + * it is absent. A history store always withholds it, which is what lets its + * capture overlay's graph-lock memo reuse the same evidence. It is the only + * raw SQL a transaction context carries: + * `tx.backend` is a projection with no statement execution + * (`createTransactionReadBackend`), so no other handle can issue the + * `ROLLBACK TO SAVEPOINT` that would release evidence a later write reuses. + */ + #exposedTransactionSql( + sql: TNativeTransaction | undefined, + ): TNativeTransaction | undefined { + return this.#captureEnabled || this.#revisionTrackingEnabled ? + undefined + : sql; + } + /** * Builds the transaction context bound to a transaction-scoped backend. * Collections, fluent queries, and set-oriented reads all use that same @@ -4846,80 +5248,94 @@ class StoreImplementation { // 1 outside a retried `store.transaction`; the retry owner's attempt // factory is the only caller that supplies anything else. attempt = 1, - recordedRevisionRequestAllowed = true, + // The transaction was opened with `accessMode: "read_only"`. + readOnly = false, measurable = receiptRecorder !== undefined, ): AdapterTransactionContext { // No statistics auto-refresh inside a caller-provided transaction: // ANALYZE from another connection cannot see the uncommitted rows, // so it would only reset the counter without fixing the estimates. - const txNodeOperations: NodeOperations = { - ...this.#buildNodeOperations( - this.#createNodeOperationContext(runHooks, runBulkHooks, attempt), - ), - createQuery: () => - this.#createQueryForBackend(txBackend, undefined, attempt), - maybeRefreshStatisticsAfterBulk: undefined, - }; - const txEdgeOperations: EdgeOperations = { - ...this.#buildEdgeOperations( - this.#createEdgeOperationContext(runHooks, attempt), - ), - createQuery: () => - this.#createQueryForBackend(txBackend, undefined, attempt), - maybeRefreshStatisticsAfterBulk: undefined, - }; - - const runNodeOperationHooks = ( - operation: "create" | "update" | "delete", - kind: string, - id: string, - fn: () => Promise, - ): Promise => - runHooks( - this.#createOperationContext(operation, "node", kind, id, attempt), - fn, + // THE one builder of this transaction's write surface, parameterized by + // the receipt chain the surface's writes belong to: the transaction itself + // builds one with its own recorder (or none, untracked), and every + // `tx.measure(...)` scope builds another with the scope's recorder added + // (`#attachMeasure`). One builder is why a scoped write reaches the same + // operations — and therefore the same composition cascade reporting — as + // an unscoped one. + const buildSurface = ( + receiptRecorders: readonly TransactionReceiptRecorder[], + ): TransactionWriteSurface => { + const nodeOperationContext = this.#createNodeOperationContext( + runHooks, + runBulkHooks, + attempt, + receiptRecorders, + readOnly, ); + const nodeOperations: NodeOperations = { + ...this.#buildNodeOperations(nodeOperationContext), + createQuery: () => + this.#createQueryForBackend(txBackend, undefined, attempt), + maybeRefreshStatisticsAfterBulk: undefined, + }; + const edgeOperations: EdgeOperations = { + ...this.#buildEdgeOperations( + this.#createEdgeOperationContext(runHooks, attempt), + ), + createQuery: () => + this.#createQueryForBackend(txBackend, undefined, attempt), + maybeRefreshStatisticsAfterBulk: undefined, + }; + return { + runtime: { + backend: txBackend, + deleteNodeWithPolicy: (work, policy) => + executeNodeDelete( + nodeOperationContext, + work.kind, + work.id, + txBackend, + { policy }, + ), + reviveNode: (work) => + executeNodeRevive( + nodeOperationContext, + work.kind, + work.id, + txBackend, + ), + }, + nodes: createNodeCollectionsProxy( + this.#graph, + this.graphId, + this.#registry, + txBackend, + this.#batchPointRead, + nodeOperations, + ), + edges: createEdgeCollectionsProxy( + this.#graph, + this.graphId, + this.#registry, + txBackend, + this.#batchPointRead, + this.#endpointSetRead, + edgeOperations, + ), + ...(this.#graph.identity === undefined ? + {} + : { identity: createIdentityFacade(this.#identityContext(txBackend)) }), + }; + }; - let nodes = createNodeCollectionsProxy( - this.#graph, - this.graphId, - this.#registry, - txBackend, - this.#batchPointRead, - txNodeOperations, - ); - - let edges = createEdgeCollectionsProxy( - this.#graph, - this.graphId, - this.#registry, - txBackend, - this.#batchPointRead, - this.#endpointSetRead, - txEdgeOperations, - ); - - if (receiptRecorder !== undefined) { - ({ nodes, edges } = wrapTransactionCollections( - nodes, - edges, - receiptRecorder, - )); - } - - const identity = - this.#graph.identity === undefined ? - undefined - : createIdentityFacade(this.#identityContext(txBackend)); - const receiptIdentity = - identity === undefined || receiptRecorder === undefined ? - identity - : wrapTransactionIdentity(identity, receiptRecorder); - - const getNodeCollection = ( - kind: K, - ): DynamicNodeCollection | undefined => - this.#resolveDynamicNodeCollection(nodes, kind); + const outerRecorders = + receiptRecorder === undefined ? [] : [receiptRecorder]; + const outerSurface = buildSurface(outerRecorders); + const { + nodes, + edges, + identity: receiptIdentity, + } = wrapSurfaceForRecorders(outerSurface, outerRecorders); // Honest capability discriminant for `tx.sql`. Capture/revision tracking // take precedence because they replace `tx.sql` with a fail-loud guard even @@ -4937,17 +5353,16 @@ class StoreImplementation { ...this.#createTransactionReadSurface(txBackend, attempt), ...(receiptIdentity === undefined ? {} : { identity: receiptIdentity }), backend: createTransactionReadBackend(txBackend), - [TRANSACTION_RUNTIME]: { backend: txBackend, runNodeOperationHooks }, - getNodeCollection, + [TRANSACTION_RUNTIME]: outerSurface.runtime, + ...this.#nodeCollectionAccess(nodes), ...this.#edgeCollectionAccess(edges), ...(this.#captureEnabled || this.#engineNativeHistory ? { requestRecordedRevision: (): void => { - if (!recordedRevisionRequestAllowed) { - throw new UnsupportedBackendCapabilityError( + if (readOnly) { + refuseReadOnlyTransactionWrite( "tx.requestRecordedRevision()", - "write transaction access", - { graphId: this.graphId, accessMode: "read_only" }, + this.graphId, 'Remove accessMode: "read_only" when the transaction must allocate a recorded revision.', ); } @@ -4985,10 +5400,12 @@ class StoreImplementation { defineUnavailableSqlGuard(withSql, () => throwRevisionTrackingUnsafeSqlAccess(), ); - } else if (sql === undefined) { - withSql = { ...base, sqlAvailability: "unavailable" }; } else { - withSql = { ...base, sql, sqlAvailability: "available" }; + const exposedSql = this.#exposedTransactionSql(sql); + withSql = + exposedSql === undefined ? + { ...base, sqlAvailability: "unavailable" } + : { ...base, sql: exposedSql, sqlAvailability: "available" }; } Object.defineProperty(withSql, TRANSACTION_RUNTIME, { configurable: false, @@ -5001,7 +5418,9 @@ class StoreImplementation { // Scoped write measurement (`tx.measure`) is only meaningful with a recorder // to scope; the plain `transaction()` path stays free of a `measure` the // caller has no receiver for. - return measurable ? this.#attachMeasure(withSql) : withSql; + return measurable && receiptRecorder !== undefined ? + this.#attachMeasure(withSql, buildSurface, [receiptRecorder]) + : withSql; } #identityContext( @@ -5024,6 +5443,7 @@ class StoreImplementation { backend, schema: this.#sqlSchema(), historyEnabled: this.#captureEnabled, + recordedTimeOwnership: this.#recordedTimeOwnership, revisionTrackingEnabled: this.#revisionTrackingEnabled, sameIdAcrossKinds: this.#graph.identity?.sameIdAcrossKinds ?? "ignore", loadNodes: async (references, coordinate) => { @@ -5103,6 +5523,14 @@ class StoreImplementation { * No hooks or per-row logic. Wrapped in a transaction when the backend * supports it. * + * Preserving the markers needs the backend's optional + * `clearGraphPreservingContributionMaterializations` member, which every + * bundled backend provides. On a custom backend without it, an omitted + * option clears through `clearGraph` and so follows whatever that backend's + * `clearGraph` does with the markers, while a stated + * `preserveContributionMaterializations: true` is refused with + * `UnsupportedBackendCapabilityError` before anything is deleted. + * * The store is usable after clearing — new data can be created immediately. */ async clear( @@ -5133,6 +5561,11 @@ class StoreImplementation { const doClear = async ( target: GraphBackend | TransactionBackend, ): Promise => { + // Resolved before the first lock so a refusal has touched nothing. + const clearGraphData = resolveClearGraphCommand( + target, + options.preserveContributionMaterializations, + ); if (this.#revisionTrackingEnabled) { await lockRecordedGraphWrite(target, this.graphId); } @@ -5143,16 +5576,7 @@ class StoreImplementation { this.#revisionTrackingEnabled && !this.#captureEnabled ? await readRecordedClock(target, this.#sqlSchema(), this.graphId) : undefined; - const clearGraphPreservingContributions = - target.clearGraphPreservingContributionMaterializations; - if ( - options.preserveContributionMaterializations === false || - clearGraphPreservingContributions === undefined - ) { - await target.clearGraph(this.graphId); - } else { - await clearGraphPreservingContributions(this.graphId); - } + await clearGraphData(this.graphId); if (mintsAnchorOrigin) { // Rotate the durable per-graph revision-origin nonce in the SAME // transaction as `clearGraph` for revision-tracked tokens and @@ -5384,7 +5808,7 @@ class StoreImplementation { }, target, ); - assertEvolutionPlanBaseline( + await assertEvolutionPlanBaseline( plan, await target.getActiveSchema(this.graphId), ); @@ -5451,17 +5875,41 @@ class StoreImplementation { await assertRecordedCaptureTransactionIsolation(ordinaryAdopt(externalTx)); const adopted = await adopt(externalTx, this.graphId, { waitBudgetMs }); const active = adopted.activeSchema; - assertEvolutionPlanBaseline(plan, active); + await assertEvolutionPlanBaseline(plan, active); await this.#assertNoPendingRemovalFor( payload.mergedGraph, payload.baselineGraph, adopted.backend, ); - await assertEvolvedSchemaRequiredKindsEmpty( - adopted.backend, - this.graphId, - requireDefined(payload.classification), - ); + // Ordering — and the "ontology before identity" reasoning — is spelled + // once, at `composeSchemaCommitPreflight` in `../schema/manager`. + await composeSchemaCommitPreflight({ + structural: (target) => + assertEvolvedSchemaRequiredKindsEmpty( + target, + this.graphId, + requireDefined(payload.classification), + ), + edgeMatchIdentity: undefined, + tightening: requirements.schemaTightening, + identity: + requirements.identityAffectedKinds.length === 0 ? + undefined + : async (target: SchemaCommitPreflightBackend) => { + const storage = await inspectAdoptedIdentityStorage( + adopted.backend, + this.#sqlSchema(), + { + graphId: this.graphId, + identityTableDdl: this.#baseBackend.identityTableDdl, + }, + ); + await candidate.identitySchemaPreflight( + target, + storage.provisionInCommit, + ); + }, + })(adopted.backend); if (requirements.vectorSlots.length > 0) { const provision = adopted.backend.ensureVectorSlotContributions; if (provision === undefined) { @@ -5475,20 +5923,6 @@ class StoreImplementation { onDrift: "throw", }); } - if (requirements.identityAffectedKinds.length > 0) { - const storage = await inspectAdoptedIdentityStorage( - adopted.backend, - this.#sqlSchema(), - { - graphId: this.graphId, - identityTableDdl: this.#baseBackend.identityTableDdl, - }, - ); - await candidate.identitySchemaPreflight( - adopted.backend, - storage.provisionInCommit, - ); - } const committed = await adopted.backend.commitSchemaVersion({ graphId: this.graphId, expected: { kind: "active", version: plan.baseline.version }, @@ -5508,13 +5942,18 @@ class StoreImplementation { ...outcome.receipt, schema: { version: committed.version, - hash: committed.schema_hash, + hash: await activeSchemaHash(committed), }, }, }; } - /** Prepares immutable schema instructions without acquiring a write fence. */ + /** + * Prepares immutable schema instructions without acquiring a write fence. + * + * @throws {MigrationError} (`reason: "custom-relation-removal"`) when the + * plan would drop a stored custom-meta-edge relation, as `evolve()` does. + */ async planEvolution( extension: GraphExtension, options?: PlanEvolutionOptions, @@ -5560,7 +5999,7 @@ class StoreImplementation { const plan = await prepareEvolutionPlan({ baselineGraph: snapshot.baseline, baselineVersion: snapshot.activeRow.version, - baselineHash: snapshot.activeRow.schema_hash, + baselineHash: await activeSchemaHash(snapshot.activeRow), storedSchema: snapshot.storedSchema, extension, }); @@ -5610,7 +6049,7 @@ class StoreImplementation { } if ( active.version === this.#schemaMetadata.schemaVersion && - active.schema_hash === this.#schemaMetadata.schemaHash + (await activeSchemaHash(active)) === this.#schemaMetadata.schemaHash ) { syncStoreReplacementRef(options?.ref, this); return this; @@ -5619,7 +6058,7 @@ class StoreImplementation { return this.#cloneWithGraph( this.#catchUpToStored(parseSerializedSchema(active.schema_doc)), options?.ref, - schemaMetadataFromRow(active), + await schemaMetadataFromRow(active), ); } @@ -5663,6 +6102,10 @@ class StoreImplementation { * @throws {IncompatibleChangeError} when a redeclared kind narrows * in a way the existing rows can't satisfy * (code `INCOMPATIBLE_CHANGE`). + * @throws {MigrationError} (`reason: "custom-relation-removal"`) when + * the stored schema holds a relation whose meta-edge is not built in: + * the evolved schema would drop its last copy, which only an explicit + * `migrateSchema()` may do. * @throws {GraphExtensionUnresolvedEndpointError} when an edge * endpoint references a kind that exists in neither the extension * nor the host graph @@ -5740,7 +6183,7 @@ class StoreImplementation { const caughtUp = this.#cloneWithGraph( baseline, options?.ref, - schemaMetadataFromRow(activeRow), + await schemaMetadataFromRow(activeRow), ); if (options?.eager !== undefined) { await this.#runEagerOrThrow(caughtUp, options.eager); @@ -5775,6 +6218,24 @@ class StoreImplementation { this.graphId, ); } + // Extension `evolve()` used to pass ontology through `classifyModifications` + // unclassified — that function's own docblock explains why (ontology is out + // of its scope by design). The ontology half is classified here instead, + // against the exact after-document the commit is about to publish. + const schemaTighteningPreflight = prepareStoreVerbSchemaTighteningPreflight( + { + verb: "store.evolve()", + graphId: this.graphId, + fromVersion: activeRow.version, + toVersion: activeRow.version + 1, + before: storedSchema, + after: serializeSchemaPreservingUnknownFields( + merged, + activeRow.version + 1, + storedSchema, + ), + }, + ); const identityCandidate = merged.identity === undefined ? undefined @@ -5793,7 +6254,7 @@ class StoreImplementation { // below, so an evolution that fails or is refused leaves nothing behind. const identityProvisioning = identityCandidate === undefined ? undefined : ( - await ensureIdentitySchemaStorage( + await ensureIdentityStorageOnAdoptedBaseSchema( this.#baseBackend, this.#sqlSchema(), { @@ -5806,8 +6267,28 @@ class StoreImplementation { }, ) ); + // Ordering — and the "ontology before identity" reasoning — is spelled + // once, at `composeSchemaCommitPreflight` in `../schema/manager`. + const preflight = planSchemaCommitPreflight({ + structural: (target) => + assertEvolvedSchemaRequiredKindsEmpty( + target, + this.graphId, + classification, + ), + edgeMatchIdentity: undefined, + tightening: schemaTighteningPreflight, + identity: + identityCandidate === undefined ? undefined : ( + (target: SchemaCommitPreflightBackend) => + identityCandidate.identitySchemaPreflight( + target, + identityProvisioning?.provisionInCommit ?? [], + ) + ), + }); const committed = - identityCandidate === undefined ? + preflight === undefined ? classification.requireEmpty.length > 0 ? await commitEvolvedSchemaWhenRequiredKindsAreEmpty( this.#backend, @@ -5826,17 +6307,7 @@ class StoreImplementation { this.#backend, merged, activeRow.version, - async (target) => { - await assertEvolvedSchemaRequiredKindsEmpty( - target, - this.graphId, - classification, - ); - await identityCandidate.identitySchemaPreflight( - target, - identityProvisioning?.provisionInCommit ?? [], - ); - }, + preflight, storedSchema, ); // Provision per-field vector tables + durable markers for any embedding @@ -5851,7 +6322,7 @@ class StoreImplementation { const evolved = this.#cloneWithGraph( merged, options?.ref, - schemaMetadataFromRow(committed), + await schemaMetadataFromRow(committed), ); if (options?.eager !== undefined) { await this.#runEagerOrThrow(evolved, options.eager); @@ -6298,6 +6769,7 @@ class StoreImplementation { registry, graphId: this.graphId, backend: this.#baseBackend, + batchPointRead: this.#batchPointRead, }); } @@ -6579,6 +7051,16 @@ class StoreImplementation { * compile-time kind. * @throws {KindHasReferentsError} when an extension kind being * removed is referenced by a compile-time declaration. + * @throws {MigrationError} (`reason: "ontology-tightening-violated"`) + * when a surviving kind was assignable to an edge endpoint only through + * a removed kind and a live edge still relies on it. + * @throws {MigrationError} (`reason: "custom-relation-removal"`) when + * the stored schema holds a relation whose meta-edge is not built in, + * as `evolve()` does. + * @throws {MigrationError} (`reason: "unique-constraint-requires-empty-kind"`) + * when a removed kind that sorts before a surviving, populated kind + * leaves its `kindWithSubClasses` subclass component, where its uniqueness + * claims may sit. * @throws {StaleVersionError} on a CAS race with another writer. * @throws {SchemaContentConflictError} on a same-version content * conflict. @@ -6608,7 +7090,7 @@ class StoreImplementation { return this.#cloneWithGraph( baseline, options?.ref, - schemaMetadataFromRow(activeRow), + await schemaMetadataFromRow(activeRow), ); } @@ -6634,8 +7116,35 @@ class StoreImplementation { finalGraph, plan.removedNodeKinds, ); + // A removal usually only drops declarations, but removing a kind from + // the middle of a subclass chain cuts its surviving descendants off from + // every edge kind that admitted them through it. The preflight decides, + // from the same before/after documents every commit path hands it. + const schemaTighteningPreflight = prepareStoreVerbSchemaTighteningPreflight( + { + verb: "store.removeKinds()", + graphId: this.graphId, + fromVersion: activeRow.version, + toVersion: activeRow.version + 1, + before: storedSchema, + after: serializeSchemaPreservingUnknownFields( + finalGraph, + activeRow.version + 1, + storedSchema, + ), + }, + ); + // No structural gate: dropping kinds that still hold rows is this verb's + // purpose (materializeRemovals reclaims them). No edge match identity + // step: a removal declares no new durable key. + const preflight = planSchemaCommitPreflight({ + structural: undefined, + edgeMatchIdentity: undefined, + tightening: schemaTighteningPreflight, + identity: identityCascade, + }); const committedRow = - identityCascade === undefined ? + preflight === undefined ? await commitNewSchemaVersion( this.#backend, finalGraph, @@ -6646,7 +7155,7 @@ class StoreImplementation { this.#backend, finalGraph, activeRow.version, - identityCascade, + preflight, storedSchema, ); @@ -6685,7 +7194,7 @@ class StoreImplementation { const evolved = this.#cloneWithGraph( finalGraph, options?.ref, - schemaMetadataFromRow(committedRow), + await schemaMetadataFromRow(committedRow), ); if (options?.eager !== undefined) { // Scope to just the kinds removed by THIS call (other pending @@ -6805,7 +7314,7 @@ class StoreImplementation { return this.#cloneWithGraph( baseline, options?.ref, - schemaMetadataFromRow(activeRow), + await schemaMetadataFromRow(activeRow), ); } @@ -6813,15 +7322,13 @@ class StoreImplementation { const result = await ensureSchemaImpl(this.#backend, merged, { preloaded: { activeRow, storedSchema }, autoMigrate: true, + historyEnabled: this.#captureEnabled, }); // Use the committed row from the migration result when available, // skipping the post-commit `getActiveSchema` round-trip. The // `unchanged` / `pending` / `breaking` branches don't write a new // version, so the existing `activeRow` metadata is still authoritative. - const metadata = - result.status === "migrated" || result.status === "initialized" ? - schemaMetadataFromRow(result.committedRow) - : schemaMetadataFromRow(activeRow); + const metadata = await schemaMetadataForResult(activeRow, result); return this.#cloneWithGraph(merged, options?.ref, metadata); } @@ -6977,7 +7484,8 @@ class StoreImplementation { ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, - ) => this.#withOperationHooks(ctx, fn, didWrite); + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, + ) => this.#withOperationHooks(ctx, fn, didWrite, operationFacts); } #immediateBulkHookRunner(): BulkOperationHookRunner { @@ -6987,15 +7495,53 @@ class StoreImplementation { ) => this.#withBulkOperationHooks(ctx, fn); } + /** The one binding of the node window-end identity hook to this graph. */ + readonly #requireValidityEndCompatible = ( + target: IdentityTarget, + ref: Readonly<{ kind: string; id: string }>, + validTo: string | undefined, + ): Promise => + requireNodeValidityEndCompatible( + { graphId: this.graphId, schema: this.#sqlSchema() }, + target, + ref, + validTo, + ); + + /** + * The slice of a node operation context `applyIdentityWindowEnd` reads, for + * the import update leg, which holds no operation context of its own. + */ + #identityWindowEndContext(): IdentityWindowEndContext { + return this.#graph.identity === undefined ? + {} + : { + identity: { + requireValidityEndCompatible: this.#requireValidityEndCompatible, + }, + }; + } + + /** + * `receiptRecorders` is the CHAIN of receipts a write through the + * collections built from this context belongs to: the enclosing + * transaction's recorder, plus one per `tx.measure(...)` scope the + * collections were built for (see {@link #attachMeasure}). Empty outside a + * receipt-tracked transaction, which is what leaves `recordCascadedParts` + * absent there. + */ #createNodeOperationContext( runHooks: OperationHookRunner = this.#immediateHookRunner(), runBulkHooks: BulkOperationHookRunner = this.#immediateBulkHookRunner(), attempt = 1, + receiptRecorders: readonly TransactionReceiptRecorder[] = [], + readOnlyTransaction = false, ): NodeOperationContext { const identityConfig = this.#graph.identity; return { graph: this.#graph, graphId: this.graphId, + readOnlyTransaction, schemaVersion: this.#schemaMetadata.schemaVersion, historyEnabled: this.#captureEnabled, revisionTrackingEnabled: this.#revisionTrackingEnabled, @@ -7012,11 +7558,11 @@ class StoreImplementation { identity: { lock: (target: IdentityTarget) => lockIdentityGraph(target, this.graphId), - foldCreated: ( + assertFoldable: ( target: IdentityTarget, references: readonly Readonly<{ kind: string; id: string }>[], ) => - foldIdentityForCreatedNodes( + assertIdentityFoldable( { graphId: this.graphId, registry: this.#registry, @@ -7026,40 +7572,58 @@ class StoreImplementation { target, references, ), - detachDeleted: ( + applyFold: ( target: IdentityTarget, - ref: Readonly<{ kind: string; id: string }>, - mode: "soft" | "hard", + plan: IdentityFoldPlan, + cause: "fold" | "restore", ) => - detachIdentityForNode( + applyIdentityFold( { graphId: this.graphId, + registry: this.#registry, sameIdAcrossKinds: identityConfig.sameIdAcrossKinds, schema: this.#sqlSchema(), }, target, - ref, - mode, + plan, + cause, ), - requireValidityEndCompatible: ( + detachDeleted: ( target: IdentityTarget, ref: Readonly<{ kind: string; id: string }>, - validTo: string, + mode: "soft" | "hard", ) => - requireNodeValidityEndCompatible( + detachIdentityForNode( { graphId: this.graphId, + sameIdAcrossKinds: identityConfig.sameIdAcrossKinds, schema: this.#sqlSchema(), }, target, ref, - validTo, + mode, ), + requireValidityEndCompatible: this.#requireValidityEndCompatible, }, }), createOperationContext: (operation, entity, kind, id) => this.#createOperationContext(operation, entity, kind, id, attempt), withOperationHooks: runHooks, + // Present only inside a receipt-tracked transaction; its absence is + // why a top-level delete records nothing (there is no receipt to + // record into). Fans out to the whole chain: a delete issued through a + // `tx.measure(...)` scope's collections populates that scope's receipt + // AND every receipt enclosing it, while one issued through the outer + // context reaches only the recorders that context was built with. + ...(receiptRecorders.length === 0 ? + {} + : { + recordCascadedParts: (parts: readonly CompositionCascadedPart[]) => { + for (const recorder of receiptRecorders) { + recorder.recordCascadedParts(parts); + } + }, + }), createBulkOperationContext: (operation, kind) => ({ ...this.#createHookContext(attempt), operation, @@ -7218,18 +7782,22 @@ class StoreImplementation { ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ): Promise { this.#hooks.onOperationStart?.(ctx); const startTime = Date.now(); try { const result = await fn(); - this.#hooks.onOperationEnd?.(ctx, { - durationMs: Date.now() - startTime, - outcome: - didWrite === undefined ? "unknown" - : didWrite(result) ? "written" - : "unchanged", - }); + this.#hooks.onOperationEnd?.( + operationEndContext(ctx, result, operationFacts), + { + durationMs: Date.now() - startTime, + outcome: + didWrite === undefined ? "unknown" + : didWrite(result) ? "written" + : "unchanged", + }, + ); return result; } catch (error) { this.#reportError(ctx, asError(error)); @@ -7288,6 +7856,7 @@ class StoreImplementation { ctx: OperationHookContext, fn: () => Promise, didWrite?: (result: T) => boolean, + operationFacts?: (result: T) => OperationOutcomeFacts | undefined, ): Promise => { this.#hooks.onOperationStart?.(ctx); const startTime = Date.now(); @@ -7295,7 +7864,7 @@ class StoreImplementation { const result = await fn(); pending.push({ type: "operation", - ctx, + ctx: operationEndContext(ctx, result, operationFacts), durationMs: Date.now() - startTime, outcome: didWrite === undefined ? "unknown" @@ -7393,6 +7962,7 @@ class StoreImplementation { backend: queryBackend, dialect: backend.dialect, defaultTraversalExpansion: this.#defaultTraversalExpansion, + defaultExpansion: this.#defaultExpansion, runtimeKindTokenResolver: (token, entity) => this.#resolveRuntimeKindToken(token, entity), ...(this.#schema !== undefined && { schema: this.#schema }), @@ -7634,6 +8204,50 @@ export type MeasurableAdapterHistoryTransactionContext< >; }>; +/** + * One transaction write surface, built for one receipt chain: the node/edge + * collection maps and the identity facade a caller writes through, UNWRAPPED + * (the receipt counters' wrapping is applied per recorder by the builder's + * caller), plus the node operation context those collections are bound to. + * + * The context travels with the surface because it is what carries facts no + * collection wrapper can observe — a node delete's composition + * `cascadedParts` — to the receipts the surface belongs to, and because + * `TRANSACTION_RUNTIME`'s delete port must run against the same one. + */ +type TransactionWriteSurface = Readonly<{ + /** The internal delete/revive port, bound to this surface's own node operation context. */ + runtime: TransactionRuntime; + nodes: GraphNodeCollections; + edges: GraphEdgeCollections; + identity?: IdentityFacade; +}>; + +/** + * Wraps a write surface's collections and identity facade once per recorder, + * innermost first, so every recorder in the chain counts each write. + */ +function wrapSurfaceForRecorders( + surface: TransactionWriteSurface, + recorders: readonly TransactionReceiptRecorder[], +): Readonly<{ + nodes: GraphNodeCollections; + edges: GraphEdgeCollections; + identity: IdentityFacade | undefined; +}> { + let nodes = surface.nodes; + let edges = surface.edges; + let identity = surface.identity; + for (const recorder of recorders) { + ({ nodes, edges } = wrapTransactionCollections(nodes, edges, recorder)); + identity = + identity === undefined ? undefined : ( + wrapTransactionIdentity(identity, recorder) + ); + } + return { nodes, edges, identity }; +} + export type AdapterRecordedReadStore< G extends GraphDef, TNativeTransaction, @@ -7861,13 +8475,13 @@ function setsEqual(a: ReadonlySet, b: ReadonlySet): boolean { return true; } -function schemaMetadataFromRow( +async function schemaMetadataFromRow( row: SchemaVersionRow | undefined, -): StoreSchemaMetadata { +): Promise { if (row === undefined) return UNKNOWN_SCHEMA_METADATA; return Object.freeze({ schemaVersion: row.version, - schemaHash: row.schema_hash, + schemaHash: await activeSchemaHash(row), }); } @@ -7897,7 +8511,7 @@ function stripReconciledOption( function schemaMetadataForResult( activeRow: SchemaVersionRow | undefined, result: SchemaValidationResult, -): StoreSchemaMetadata { +): Promise { if (result.status === "initialized" || result.status === "migrated") { return schemaMetadataFromRow(result.committedRow); } @@ -8278,52 +8892,56 @@ async function prepareStoreWithSchema( // provisioning for one shape and filling for another. The call is still // load-bearing for its EFFECT: it runs the idempotent identity DDL before // the commit takes the per-graph write lock. - await ensureIdentitySchemaStorage(backend, resolvedIdentitySchema, { - graphId: merged.id, - enablement: identityEnablement, - registry: identityRegistry, - // A derived relation this library version added, absent from (or left - // empty in) a database that predates it, is created and FILLED as one - // unit here — never created now and filled at the end of boot. Between - // those two points sit the schema commit, the history assertion, three - // materialization steps and an index build, and for that whole window an - // empty separation relation would answer "not separated" to every - // concurrent `assertSame`. See `ensureIdentitySchemaStorage`. - // - // Withheld when a schema commit is gated on an identity SEMANTICS - // change: that commit's own preflight creates AND rebuilds inside the - // commit transaction, under the semantics being committed. Filling here - // would instead derive classes from semantics this database has not - // accepted yet — and would keep them if the commit were then refused. - // Nothing is provisioned on that path either, so a refused commit leaves - // the relation absent rather than readable-empty, and the next open of a - // graph whose semantics ARE committed heals it here. - ...(identityGate === undefined ? - { - // The registry was resolved before this transaction. Pin its schema - // version before taking the identity lock: a concurrent migration - // may already have rebuilt under different semantics, and a stale - // startup must not overwrite that closure even if boot later fails. - recomputeDerivedRelations: (target) => - rebuildIdentityClosureWithSchemaFence( - { - backend: target, - graphId: merged.id, - registry: identityRegistry, - schema: resolvedIdentitySchema, - sameIdAcrossKinds: identityProfile.sameIdAcrossKinds, - }, - { - graphId: merged.id, - schemaVersion: activeRow?.version, - historyEnabled: false, - revisionTrackingEnabled: false, - revisionSchema: resolvedIdentitySchema, - }, - ), - } - : {}), - }); + await ensureIdentityStorageOnAdoptedBaseSchema( + backend, + resolvedIdentitySchema, + { + graphId: merged.id, + enablement: identityEnablement, + registry: identityRegistry, + // A derived relation this library version added, absent from (or left + // empty in) a database that predates it, is created and FILLED as one + // unit here — never created now and filled at the end of boot. Between + // those two points sit the schema commit, the history assertion, three + // materialization steps and an index build, and for that whole window an + // empty separation relation would answer "not separated" to every + // concurrent `assertSame`. See `ensureIdentitySchemaStorage`. + // + // Withheld when a schema commit is gated on an identity SEMANTICS + // change: that commit's own preflight creates AND rebuilds inside the + // commit transaction, under the semantics being committed. Filling here + // would instead derive classes from semantics this database has not + // accepted yet — and would keep them if the commit were then refused. + // Nothing is provisioned on that path either, so a refused commit leaves + // the relation absent rather than readable-empty, and the next open of a + // graph whose semantics ARE committed heals it here. + ...(identityGate === undefined ? + { + // The registry was resolved before this transaction. Pin its schema + // version before taking the identity lock: a concurrent migration + // may already have rebuilt under different semantics, and a stale + // startup must not overwrite that closure even if boot later fails. + recomputeDerivedRelations: (target) => + rebuildIdentityClosureWithSchemaFence( + { + backend: target, + graphId: merged.id, + registry: identityRegistry, + schema: resolvedIdentitySchema, + sameIdAcrossKinds: identityProfile.sameIdAcrossKinds, + }, + { + graphId: merged.id, + schemaVersion: activeRow?.version, + historyEnabled: false, + revisionTrackingEnabled: false, + revisionSchema: resolvedIdentitySchema, + }, + ), + } + : {}), + }, + ); } // No identity preflight is passed here — the schema manager derives the @@ -8336,6 +8954,17 @@ async function prepareStoreWithSchema( // eslint-disable-next-line unicorn/no-useless-fallback-in-spread ...(options ?? {}), preloaded: { activeRow, storedSchema }, + // This IS the first point a capture session could exist for this graph — + // `StoreImplementation`'s constructor, which normally wraps the backend + // with `createRecordedBackend`, has not run yet. Threaded so the identity + // preflight can bind its OWN transaction target to a capture session + // instead of silently dropping every ledger touch and transition note. + // Engine-native `history: true` captures nothing TypeGraph-owned, exactly + // as the Store it opens will not. + historyEnabled: capturesTypeGraphRecordedHistory( + options?.history === true, + backend, + ), }; // An identity semantic change reaches the store only after the preflight @@ -8406,7 +9035,7 @@ async function prepareStoreWithSchema( return { graph: merged, result, - schemaMetadata: schemaMetadataForResult(activeRow, result), + schemaMetadata: await schemaMetadataForResult(activeRow, result), }; } @@ -8659,7 +9288,7 @@ async function prepareVerifiedStore( return { graph: merged, result, - schemaMetadata: schemaMetadataFromRow(activeRow), + schemaMetadata: await schemaMetadataFromRow(activeRow), }; } diff --git a/packages/typegraph/src/store/subgraph.ts b/packages/typegraph/src/store/subgraph.ts index 89358121d..4a020ded5 100644 --- a/packages/typegraph/src/store/subgraph.ts +++ b/packages/typegraph/src/store/subgraph.ts @@ -31,6 +31,7 @@ import { import type { ExecutableOneStatementRead } from "../query/builder/types"; import { compileKindFilter } from "../query/compiler/predicate-utils"; import { MAX_EXPLICIT_RECURSIVE_DEPTH } from "../query/compiler/recursive"; +import { compileKindQualifiedToken } from "../query/compiler/recursive"; import { DEFAULT_SQL_SCHEMA, type RecordedReadBinding, @@ -38,8 +39,11 @@ import { type SqlSchema, } from "../query/compiler/schema"; import { + compileEdgeMembershipFilters, compileTemporalFilter, currentReadInstant, + type EdgeKindMembership, + recordedIntervalColumns, } from "../query/compiler/temporal"; import { compileTypedJsonExtract } from "../query/compiler/typed-json-extract"; import { quoteIdentifier } from "../query/compiler/utils"; @@ -53,17 +57,32 @@ import { } from "../query/schema-introspector"; import { sql, type SqlFragment } from "../query/sql-fragment"; import { asCompiledRowsSql, markForceCustomPlan } from "../query/sql-intent"; +import { + compositionEdgeKindMembers, + compositionViaKind, + compositionViaReach, + type CompositionViaRef, + partitionCompositionEdgeKindsByDirection, +} from "../registry/composition-relation"; +import type { KindRegistry } from "../registry/kind-registry"; import { compareCodePoints } from "../utils/compare"; import { fnv1aBase36 } from "../utils/hash"; import { truncateToBytes } from "../utils/identifier"; -import { hasOwnKey } from "../utils/object"; +import { hasOwnKey, isPlainObject } from "../utils/object"; +import { kindQualifiedToken } from "../utils/path"; import { requireDefined } from "../utils/presence"; +import { isStatementCutShortError } from "../utils/sql-errors"; import { type EdgeReadWindow, validateEdgeReadBounds } from "./neighbors"; import { + buildExhaustiveDirectedReachableCte, buildReachableCte, buildWindowedEdgesCte, type ReachableSeed, } from "./recursive-cte"; +import { + assertRegisteredEdgeKinds, + assertRegisteredNodeKinds, +} from "./registered-kinds"; import { validateProjectionField } from "./reserved-keys"; import { type EdgeRow, @@ -321,15 +340,38 @@ type SubgraphEdgeResultForKind< // Options & Result Types // ============================================================ +export type SubgraphCompositionSelection = + boolean | Readonly<{ via?: CompositionViaRef }>; + +function compositionRequested( + composition: SubgraphCompositionSelection | undefined, +): boolean { + return composition === true || typeof composition === "object"; +} + +function compositionViaOf( + composition: SubgraphCompositionSelection | undefined, +): string | undefined { + if (typeof composition !== "object") return undefined; + return composition.via === undefined ? + undefined + : compositionViaKind(composition.via); +} + export type SubgraphOptions< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined = undefined, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, > = Readonly<{ /** Edge kinds to follow during traversal. Edges not listed are not traversed. */ edges: readonly EK[]; - /** Maximum traversal depth from root (default: 10). */ + /** + * Maximum traversal depth from root for the `edges` list above (default: + * 10). It does NOT bound the `composition` closure: that returns the + * complete owned unit at any depth — see {@link SubgraphOptions.composition}. + */ maxDepth?: number; /** * Node kinds to include in the result. Nodes of other kinds are still @@ -348,8 +390,53 @@ export type SubgraphOptions< /** Cycle policy — reuse RecursiveCyclePolicy (default: "prevent"). */ cyclePolicy?: RecursiveCyclePolicy; /** - * Temporal mode applied to both nodes and edges along the traversal and in - * the hydrated result. Defaults to `graph.defaults.temporalMode`. + * Close the root over its declared composition parts — the whole-plus-parts + * export unit. When `true`, every composition edge kind transitively under + * the root's kind (`registry.compositionEdgeKindsUnder`) is added to the + * traversal and to the hydrated edge set, in addition to whatever `edges` + * already lists. `{ via }` closes over that one realizing edge instead; it + * must realize a pair at the root's kind itself, the rule `parts({ via })` + * applies, or the read refuses with `ConfigurationError` + * (`COMPOSITION_VIA_NOT_DECLARED`). + * + * The closure's EDGES follow ownership, not the read's validity window: the + * composition rows that bind a part to its whole, the same membership the + * delete cascade counts. An ended `population: "one"` row still binds (and + * is returned with its `validTo`); an ended `population: "oneActive"` row + * does not. `temporalMode` sets the instant ownership is evaluated at + * (`"asOf"` at `asOf`, every other mode now — `includeEnded` and + * `includeTombstones` do not widen it). NODES follow `temporalMode` at + * every hop, so a part the read cannot see (its own window ended or not yet + * started) is not returned, nor is anything reachable only through it, + * although a delete of the root removes them: the export is not a delete + * preview — `planDelete` is. + * + * This is set-level, not per-root-kind-required: a root whose kind declares + * no composition parts contributes nothing extra and the read still runs + * normally (applied, not ignored — it just has no effect for that root). A + * graph that declares no composition relation *at all* cannot honor the + * option meaningfully and refuses with `ConfigurationError` + * (`COMPOSITION_NO_PARTS_DECLARED`) rather than silently running as if the + * option were absent. + * + * Composition edge kinds added this way are not necessarily members of the + * compile-time `edges` list, so passing `true` widens the result's edge-kind + * type to the graph's whole edge-kind union (see + * {@link SubgraphResultEdgeKinds}): every edge kind the read can return is + * then reachable through the result type. + * + * The closure is COMPLETE: it is bounded by its own visited set, never by + * `maxDepth`. A part tree deeper than the default depth still comes back + * whole, because a whole plus a truncated prefix of its parts is not an + * owned unit. `maxDepth` (and `cyclePolicy`) bound the explicit `edges` + * traversal alone. + */ + composition?: C; + /** + * Temporal mode applied to nodes, and to the `edges` traversal and its + * hydrated edges. Composition edges follow ownership at the mode's instant + * instead — see {@link SubgraphOptions.composition}. Defaults to + * `graph.defaults.temporalMode`. */ temporalMode?: TemporalMode; /** ISO-8601 timestamp used when `temporalMode === "asOf"`. */ @@ -386,8 +473,9 @@ export type InternalSubgraphOptions< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined = undefined, -> = Omit, "recordedAsOf"> & + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, +> = Omit, "recordedAsOf"> & Readonly<{ recordedAsOf?: RecordedInstant; }>; @@ -414,6 +502,52 @@ export type SubgraphEdgeResult< [Kind in EK]: SubgraphEdgeResultForKind; }[EK]; +/** + * The edge-kind union a `subgraph(...)` result carries in `edges`: the + * declared `edges` list, widened when the call passes `composition`. + * + * `composition: true` adds `registry.compositionEdgeKindsUnder(root.kind)` to + * the traversal — a set the call site does not state — so the exact addition + * is not spelled in the call's types. Widening to every declared edge kind is + * the conservative reading: every kind the read can return is in the result + * type, and no kind outside the graph's own edges ever appears. A `via` names + * its one realizing edge kind. A `composition` that is absent or `false` + * leaves the `edges`-list typing exactly as it was. `true extends C` is the + * test, not `C extends true`, so an unresolved `boolean` — a flag that MIGHT + * be `true` at runtime — widens as well. + */ +export type SubgraphResultEdgeKinds< + G extends GraphDef, + EK extends EdgeKinds, + C extends SubgraphCompositionSelection | undefined, +> = + | EK + | (true extends C ? EdgeKinds + : C extends { via: infer V } ? + [V] extends [AnyEdgeType] ? + V["kind"] extends EdgeKinds ? + V["kind"] + : EdgeKinds + : EdgeKinds + : never); + +/** + * The projection a `subgraph(...)` call may state, keyed by the edge kinds its + * RESULT carries ({@link SubgraphResultEdgeKinds}) rather than by the declared + * `edges` list. A `composition: true` call receives composition edge rows, and + * the executor builds its edge projection plan from that same widened kind + * list, so constraining the input by the narrow list alone would leave a + * caller unable to shrink the payload of rows it is already being handed. + * Without `composition` the two lists are identical, so a projection naming a + * kind outside `edges` stays a compile-time error. + */ +export type SubgraphProjectFor< + G extends GraphDef, + NK extends NodeKinds, + EK extends EdgeKinds, + C extends SubgraphCompositionSelection | undefined, +> = SubgraphProject>; + /** * A node's identity inside a subgraph result. Node rows are keyed by * (kind, id), so two nodes of different kinds may share an id; every lookup @@ -435,6 +569,12 @@ export type SubgraphEdgeKey = Readonly<{ toId: string; }>; +/** + * The result of a `subgraph(...)` read. `EK` is the edge-kind union the result + * CARRIES ({@link SubgraphResultEdgeKinds} of the call's declared `edges`), so + * `P` is constrained by the projection over that same union — the one + * {@link SubgraphProjectFor} admits at the call site. + */ export type SubgraphResult< G extends GraphDef, NK extends NodeKinds = NodeKinds, @@ -523,6 +663,13 @@ type SubgraphContext = Readonly<{ recordedReadBinding: RecordedReadBinding | undefined; backend: GraphBackend; edgeWindows: Readonly> | undefined; + /** + * The composition edge kinds a composition read closes over and hydrates, + * each with its membership rule; empty for any other read. Their rows are + * read by membership at the read's instant, the `edgeKinds` walk's by the + * read's validity window. + */ + compositionMembership: readonly EdgeKindMembership[]; }>; type SubgraphNodeFetchRow = Readonly< @@ -553,7 +700,8 @@ type SubgraphExecutionParams< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, > = Readonly<{ graph: G; graphId: string; @@ -562,15 +710,19 @@ type SubgraphExecutionParams< dialect: DialectAdapter; schema: SqlSchema | undefined; recordedReadBinding: RecordedReadBinding | undefined; - options: InternalSubgraphOptions; + registry: KindRegistry; + options: InternalSubgraphOptions; }>; type SubgraphPlan = Readonly<{ baseSchema: SqlSchema; ctx: SubgraphContext; + /** `ctx` widened by the composition edge kinds a composition read also walks: the context node and edge hydration read through. */ + readContext: SubgraphContext; reachableCte: SqlFragment; includedIdsCte: SqlFragment; nodeProjectionPlan: ProjectionPlan; + /** Covers every edge kind {@link readContext} hydrates, composition kinds included. */ edgeProjectionPlan: ProjectionPlan; }>; @@ -580,13 +732,33 @@ function buildSubgraphPlan< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined, + P extends SubgraphProjectFor | undefined, + C extends SubgraphCompositionSelection | undefined = undefined, >( - params: SubgraphExecutionParams, + params: SubgraphExecutionParams, surface: SubgraphSurface, ): SubgraphPlan { const { options } = params; validateSubgraphTraversalOptions(options); + assertRegisteredEdgeKinds(params.graph, options.edges); + assertRegisteredNodeKinds(params.graph, options.includeKinds ?? []); + if ( + surface === "batchOnce.subgraph" && + compositionRequested(options.composition) + ) { + throw new ConfigurationError( + "A one-statement subgraph read cannot close over composition parts.", + { + code: "SUBGRAPH_COMPOSITION_ONE_STATEMENT_UNSUPPORTED", + surface, + via: compositionViaOf(options.composition), + }, + { + suggestion: + "Read the composition unit with store.subgraph() or tx.subgraph() outside batchOnce(); the composition closure is a separate walk, read in its own statement.", + }, + ); + } const { valid: coordinate } = resolveReadCoordinate( options.temporalMode ?? params.graph.defaults.temporalMode, options.asOf, @@ -624,6 +796,7 @@ function buildSubgraphPlan< options.edgeWindows === undefined ? undefined : structuredClone(options.edgeWindows), + compositionMembership: [], }; const introspector = getSubgraphSchemaIntrospector(params.graph); const nodeProjectionPlan = buildProjectionPlan( @@ -632,22 +805,79 @@ function buildSubgraphPlan< (kind, field) => introspector.getFieldTypeInfo(kind, field), "node", ); - const edgeProjectionPlan = buildProjectionPlan( - dedupeStrings(options.edges), + const edgeProjectionPlan = buildEdgeProjectionPlan( + params.graph, + options.edges, options.project?.edges, - (kind, field) => introspector.getEdgeFieldTypeInfo(kind, field), - "edge", ); const reachableCte = buildSubgraphReachableCte(ctx, baseSchema, surface); - - return { + const compositionEdgeKinds = resolveSubgraphCompositionEdgeKinds(params, ctx); + const plan = { baseSchema, ctx, + readContext: ctx, reachableCte, includedIdsCte: buildIncludedIdsCte(ctx), nodeProjectionPlan, edgeProjectionPlan, }; + if (compositionEdgeKinds.length === 0) return plan; + + const compositionOnlyEdgeKinds = compositionEdgeKinds.filter( + (edgeKind) => !ctx.edgeKinds.includes(edgeKind), + ); + return { + ...plan, + readContext: { + ...ctx, + compositionMembership: compositionEdgeKindMembers( + params.registry, + compositionEdgeKinds, + ), + }, + edgeProjectionPlan: extendProjectionPlan( + edgeProjectionPlan, + buildEdgeProjectionPlan( + params.graph, + compositionOnlyEdgeKinds, + options.project?.edges, + ), + ), + }; +} + +/** `base` followed by `extension`, as one plan built over both kind lists in that order would be. */ +function extendProjectionPlan( + base: ProjectionPlan, + extension: ProjectionPlan, +): ProjectionPlan { + return { + fullKinds: [...base.fullKinds, ...extension.fullKinds], + projectedKinds: new Map([ + ...base.projectedKinds, + ...extension.projectedKinds, + ]), + }; +} + +/** + * The edge projection plan over the edge kinds a read actually hydrates. A + * composition read hydrates its resolved composition edge kinds too, so its + * plan is built from that resolved list, never from `options.edges` alone. + */ +function buildEdgeProjectionPlan( + graph: G, + edgeKinds: readonly string[], + projection: + Readonly> | undefined, +): ProjectionPlan { + const introspector = getSubgraphSchemaIntrospector(graph); + return buildProjectionPlan( + dedupeStrings(edgeKinds), + projection, + (kind, field) => introspector.getEdgeFieldTypeInfo(kind, field), + "edge", + ); } /** @@ -724,16 +954,19 @@ export async function executeSubgraph< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined = undefined, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, >( - params: SubgraphExecutionParams, -): Promise> { + params: SubgraphExecutionParams, +): Promise, P>> { const { ctx, + readContext, reachableCte, includedIdsCte, nodeProjectionPlan, edgeProjectionPlan, + baseSchema, } = buildSubgraphPlan(params, "subgraph"); // The node and edge fetches both need the traversal closure. Embedding @@ -745,58 +978,191 @@ export async function executeSubgraph< // is cheap, an id list would bind one parameter per id (bind-budget // pressure), and per-count SQL texts would churn the prepared-statement // cache. + // + // Composition cannot share that single reachable CTE: the caller's edges + // use one direction, while a composition relation may mix part->whole and + // whole->part realizing edges. Those are walked as their own closure and + // the two (kind, id) sets are unioned. let membership: SubgraphMembership; - const membershipStrategy = - ctx.dialect.capabilities.subgraphMembershipStrategy; - switch (membershipStrategy) { - case "materialized-ids": { - const members = await fetchIncludedNodes( - ctx, - reachableCte, - includedIdsCte, - ); - const kindsArray = textArrayParam(members.map((member) => member.kind)); - const idsArray = textArrayParam(members.map((member) => member.id)); - membership = { - prefix: sql``, - memberFilter: (kindColumn, idColumn) => - sql`EXISTS (SELECT 1 FROM unnest(${kindsArray}, ${idsArray}) AS tg_included(kind, id) WHERE tg_included.kind = ${kindColumn} AND tg_included.id = ${idColumn})`, - parameterDependentPlan: true, - }; - break; - } - case "inline-cte": { - membership = { - prefix: sql`${reachableCte}${includedIdsCte} `, - // `row IN (subquery)` evaluates via a transient index — optimal - // as-is. (The materialized-ids strategy takes the parameterized - // array form above instead.) - memberFilter: (kindColumn, idColumn) => - sql`(${kindColumn}, ${idColumn}) IN (SELECT kind, id FROM included_ids)`, - parameterDependentPlan: false, - }; - break; - } - default: { - membershipStrategy satisfies never; - throw new Error( - `Unsupported subgraph membership strategy: ${String(membershipStrategy)}`, - ); + if (readContext.compositionMembership.length > 0) { + const baseMembers = await fetchIncludedNodes( + ctx, + reachableCte, + includedIdsCte, + ); + const compositionMembers = await fetchCompositionClosureNodes( + readContext, + buildSubgraphCompositionReachableCte(params, readContext, baseSchema), + includedIdsCte, + ); + membership = nodeListMembership( + dedupeSeeds([...baseMembers, ...compositionMembers]), + ctx.dialect, + ); + } else { + const membershipStrategy = + ctx.dialect.capabilities.subgraphMembershipStrategy; + switch (membershipStrategy) { + case "materialized-ids": { + const members = await fetchIncludedNodes( + ctx, + reachableCte, + includedIdsCte, + ); + const kindsArray = textArrayParam(members.map((member) => member.kind)); + const idsArray = textArrayParam(members.map((member) => member.id)); + membership = { + prefix: sql``, + memberFilter: (kindColumn, idColumn) => + sql`EXISTS (SELECT 1 FROM unnest(${kindsArray}, ${idsArray}) AS tg_included(kind, id) WHERE tg_included.kind = ${kindColumn} AND tg_included.id = ${idColumn})`, + parameterDependentPlan: true, + }; + break; + } + case "inline-cte": { + membership = { + prefix: sql`${reachableCte}${includedIdsCte} `, + // `row IN (subquery)` evaluates via a transient index — optimal + // as-is. (The materialized-ids strategy takes the parameterized + // array form above instead.) + memberFilter: (kindColumn, idColumn) => + sql`(${kindColumn}, ${idColumn}) IN (SELECT kind, id FROM included_ids)`, + parameterDependentPlan: false, + }; + break; + } + default: { + membershipStrategy satisfies never; + throw new Error( + `Unsupported subgraph membership strategy: ${String(membershipStrategy)}`, + ); + } } } const [nodeRows, edgeRows] = await Promise.all([ - fetchSubgraphNodes(ctx, membership, nodeProjectionPlan), - fetchSubgraphEdges(ctx, membership, edgeProjectionPlan), + fetchSubgraphNodes(readContext, membership, nodeProjectionPlan), + fetchSubgraphEdges(readContext, membership, edgeProjectionPlan), ]); - return assembleSubgraphResult( + return assembleSubgraphResult, P>( ctx.root, nodeRows.map((row) => mapSubgraphNodeRow(row, nodeProjectionPlan)), edgeRows.map((row) => mapSubgraphEdgeRow(row, edgeProjectionPlan)), ); } +/** + * The realizing edge kinds a `composition` subgraph walks from its root: every + * composition edge kind under the root's kind, or the one `via` names. Empty + * when the read asks for no composition, or the root's kind declares no parts. + */ +function resolveSubgraphCompositionEdgeKinds< + G extends GraphDef, + EK extends EdgeKinds, + NK extends NodeKinds, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, +>( + params: SubgraphExecutionParams, + ctx: SubgraphContext, +): readonly string[] { + if (!compositionRequested(params.options.composition)) return []; + if (params.registry.compositionEdgeKinds().length === 0) { + throw new ConfigurationError( + `subgraph({ composition: true }) requires the graph to declare at least one partOf/hasPart relation, but "${params.graphId}" declares none.`, + { code: "COMPOSITION_NO_PARTS_DECLARED" }, + { + suggestion: + "Declare a partOf/hasPart relation in the graph's ontology, or omit `composition` from the subgraph options.", + }, + ); + } + const requestedVia = compositionViaOf(params.options.composition); + if (requestedVia === undefined) { + return params.registry.compositionEdgeKindsUnder(ctx.root.kind); + } + const viaReach = compositionViaReach( + params.registry, + "parts", + [ctx.root.kind], + requestedVia, + ); + if (!viaReach.realized) { + throw new ConfigurationError( + `subgraph composition via "${requestedVia}" realizes no partOf/hasPart pair at root kind "${ctx.root.kind}".`, + { + code: "COMPOSITION_VIA_NOT_DECLARED", + via: requestedVia, + rootKind: ctx.root.kind, + declaredVia: viaReach.declaredVia, + }, + { + suggestion: + "Pass via naming a realizing edge of this root's composition, or pass composition: true to include every realizing edge.", + }, + ); + } + return [requestedVia]; +} + +function buildSubgraphCompositionReachableCte< + G extends GraphDef, + EK extends EdgeKinds, + NK extends NodeKinds, + P extends SubgraphProjectFor | undefined, + C extends SubgraphCompositionSelection | undefined = undefined, +>( + params: SubgraphExecutionParams, + ctx: SubgraphContext, + baseSchema: SqlSchema, +): SqlFragment { + const { outEdgeKinds, inEdgeKinds } = + partitionCompositionEdgeKindsByDirection( + params.registry, + ctx.compositionMembership.map((member) => member.edgeKind), + "parts", + ); + return buildExhaustiveDirectedReachableCte({ + graphId: ctx.graphId, + source: ctx.root, + outEdgeKinds, + inEdgeKinds, + edgeMembership: ctx.compositionMembership, + temporalMode: ctx.temporalMode, + currentTimestamp: ctx.currentTimestamp, + ...(ctx.asOf !== undefined && { asOf: ctx.asOf }), + ...(ctx.recordedAsOf !== undefined && { recordedAsOf: ctx.recordedAsOf }), + dialect: ctx.dialect, + schema: baseSchema, + ...(ctx.recordedReadBinding === undefined ? + {} + : { recordedReadBinding: ctx.recordedReadBinding }), + recursiveTraversal: resolveRecursiveTraversal(params.backend.capabilities), + operation: "subgraph", + }); +} + +/** + * Runs a subgraph on a pinned session (a transaction). The read is one + * statement unless it closes over composition parts: the closure is a second + * walk read in its own statement, so it runs the multi-statement executor on + * the same session instead. + */ +export async function executeSessionSubgraph< + G extends GraphDef, + EK extends EdgeKinds, + NK extends NodeKinds, + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, +>( + params: SubgraphExecutionParams, +): Promise, P>> { + return compositionRequested(params.options.composition) ? + executeSubgraph(params) + : createSubgraphRead(params).execute(); +} + /** A subgraph read that can execute alone or in `store.batchOnce()`. */ export type SubgraphRead< G extends GraphDef, @@ -810,8 +1176,11 @@ export function createSubgraphRead< G extends GraphDef, EK extends EdgeKinds, NK extends NodeKinds, - P extends SubgraphProject | undefined = undefined, ->(params: SubgraphExecutionParams): SubgraphRead { + P extends SubgraphProjectFor | undefined = undefined, + C extends SubgraphCompositionSelection | undefined = undefined, +>( + params: SubgraphExecutionParams, +): SubgraphRead, P> { const plan = buildSubgraphPlan(params, "batchOnce.subgraph"); const { ctx, @@ -829,13 +1198,13 @@ export function createSubgraphRead< ); function mapRows( rows: readonly Record[], - ): SubgraphResult { - return mapOneStatementSubgraphRows( - ctx.root, - rows as readonly OneStatementSubgraphRow[], - nodePlan, - edgePlan, - ); + ): SubgraphResult, P> { + return mapOneStatementSubgraphRows< + G, + NK, + SubgraphResultEdgeKinds, + P + >(ctx.root, rows as readonly OneStatementSubgraphRow[], nodePlan, edgePlan); } return { execute: async () => @@ -845,7 +1214,9 @@ export function createSubgraphRead< ), ), compileOneStatementBatchItem: () => { - const item: OneStatementBatchItem> = { + const item: OneStatementBatchItem< + SubgraphResult, P> + > = { query: asCompiledRowsSql(query), provenance: { graphId: params.graphId, @@ -1183,24 +1554,59 @@ function buildIncludedIdsCte(ctx: SubgraphContext): SqlFragment { return sql`, included_ids AS (SELECT DISTINCT kind, id FROM reachable${whereClause})`; } -function buildSubgraphEdgeSource( +/** + * Which edge rows a subgraph read hydrates: a row of a walked `edges` kind by + * the read's validity window (`edgeTemporalFilter`), a row of a composition + * kind by its membership at the read's instant — so the owned unit comes back + * with the rows that bind it, an ended `"allRows"` row (and its `validTo`) + * included. A kind in both lists is hydrated when either admits the row. + */ +function subgraphEdgeRowFilter( ctx: SubgraphContext, edgeTemporalFilter: SqlFragment, +): SqlFragment { + const windowArm = sql`${compileKindFilter(sql.raw("e.kind"), ctx.edgeKinds)} AND ${edgeTemporalFilter}`; + if (ctx.compositionMembership.length === 0) return windowArm; + const membershipArm = sql.join( + [ + compileKindFilter( + sql.raw("e.kind"), + ctx.compositionMembership.map((member) => member.edgeKind), + ), + ...compileEdgeMembershipFilters({ + members: ctx.compositionMembership, + tableAlias: "e", + mode: ctx.temporalMode, + asOf: ctx.asOf, + recordedAsOf: ctx.recordedAsOf, + recordedReadBinding: ctx.recordedReadBinding, + }), + ], + sql` AND `, + ); + return ctx.edgeKinds.length === 0 ? + membershipArm + : sql`((${windowArm}) OR (${membershipArm}))`; +} + +function buildSubgraphEdgeSource( + ctx: SubgraphContext, + edgeRowFilter: SqlFragment, ): SqlFragment { const windows = Object.entries(ctx.edgeWindows ?? {}).filter( (entry): entry is [string, EdgeReadWindow] => entry[1] !== undefined, ); if (windows.length === 0) return ctx.schema.edgesTable; - const edgeKindFilter = compileKindFilter(sql.raw("e.kind"), ctx.edgeKinds); + const hydratedEdgeKinds = dedupeStrings([ + ...ctx.edgeKinds, + ...ctx.compositionMembership.map((member) => member.edgeKind), + ]); const windowedEdges = buildWindowedEdgesCte( ctx.schema.edgesTable, ctx.direction, - ctx.edgeKinds.map((kind) => [kind, ctx.edgeWindows?.[kind]]), - sql.join( - [sql`e.graph_id = ${ctx.graphId}`, edgeKindFilter, edgeTemporalFilter], - sql` AND `, - ), + hydratedEdgeKinds.map((kind) => [kind, ctx.edgeWindows?.[kind]]), + sql.join([sql`e.graph_id = ${ctx.graphId}`, edgeRowFilter], sql` AND `), ); const physicalColumns = [ "graph_id", @@ -1216,6 +1622,8 @@ function buildSubgraphEdgeSource( "created_at", "updated_at", "deleted_at", + // The row filter applied over this source reads the recorded interval too. + ...recordedIntervalColumns(ctx.recordedAsOf, ctx.recordedReadBinding), ].map((column) => sql.raw(`windowed_edge.${column}`)); // Bidirectional windows can surface one physical edge through both @@ -1256,6 +1664,96 @@ function textArrayParam(values: readonly string[]): SqlFragment { return sql`${`{${elements.join(",")}}`}::text[]`; } +/** + * A portable (dialect-agnostic) `SubgraphMembership` over (kind, id) pairs + * already computed in JS — the shape the composition closure union needs, + * since neither dialect's normal membership strategy (Postgres `unnest`, + * SQLite's embedded CTE) has a "two closures, unioned" input. + * + * `memberFilter` is called twice per edge fetch (both endpoints), so binding + * parameters per pair would bind 4N with no bind-budget check — enough to + * exceed a Worker/D1-class backend's `maxBindParameters` on an ordinary + * whole-plus-parts export. The dialect's `inListParameter`/`packListValue` + * seam (the one `IN`-predicate compilation already uses for a parameterized + * list) packs each list into ONE bound value instead, so the filter binds a + * constant number of parameters however large the closure is. The id list + * keeps the filter on an indexable column; the kind-qualified token list is + * what makes it exact, so a same-id node of another kind is never admitted. + */ +function nodeListMembership( + members: readonly SubgraphNodeKey[], + dialect: DialectAdapter, +): SubgraphMembership { + if (members.length === 0) { + return { + prefix: sql``, + memberFilter: () => sql`1 = 0`, + parameterDependentPlan: true, + }; + } + const listOptions = { negated: false, elementType: undefined } as const; + const packedIds = dialect.packListValue( + dedupeStrings(members.map((member) => member.id)), + ); + const packedTokens = dialect.packListValue( + members.map((member) => kindQualifiedToken(member.kind, member.id)), + ); + return { + prefix: sql``, + memberFilter: (kindColumn, idColumn) => + sql`(${dialect.inListParameter(idColumn, sql`${packedIds}`, listOptions)} AND ${dialect.inListParameter(compileKindQualifiedToken(kindColumn, idColumn), sql`${packedTokens}`, listOptions)})`, + parameterDependentPlan: true, + }; +} + +/** + * The composition closure's own fetch, and the ONE refusal its exhaustiveness + * argument cannot cover. + * + * The walk reaches a fixpoint on any finite graph, so it cannot return a + * truncated unit of its own accord — but an engine that CUT THE STATEMENT + * SHORT (`statement_timeout`, `pg_cancel_backend`, a compiled-in limit, + * `sqlite3_interrupt`) finished no walk at all, and a raw driver error is not + * an answer a caller can tell apart from a transport failure. Classified here + * the way the acyclicity probe classifies the identical case + * (`runAcyclicityProbe` -> `EdgeAcyclicityIndeterminateError`, + * `src/store/acyclicity.ts`) — through the same `isStatementCutShortError` + * predicate, so neither owner spells its own structural code check — which is + * what makes "the complete owned unit, delivered or refused, never partial" + * hold against the ENGINE too and not only against the recursion. + * + * Only the COMPOSITION closure is classified: the caller's own `edges` + * traversal is explicitly depth- and cycle-bounded and makes no completeness + * promise for a cut-short statement to break. + * + * @throws ConfigurationError (`COMPOSITION_UNIT_INDETERMINATE`) + */ +async function fetchCompositionClosureNodes( + ctx: SubgraphContext, + reachableCte: SqlFragment, + includedIdsCte: SqlFragment, +): Promise { + try { + return await fetchIncludedNodes(ctx, reachableCte, includedIdsCte); + } catch (error) { + if (!isStatementCutShortError(error)) throw error; + throw new ConfigurationError( + `The composition closure of ${ctx.root.kind} "${ctx.root.id}" was cut short by the database before it finished, so the owned unit is unknown rather than incomplete.`, + { + code: "COMPOSITION_UNIT_INDETERMINATE", + rootKind: ctx.root.kind, + rootId: ctx.root.id, + graphId: ctx.graphId, + }, + { + cause: error, + suggestion: + "Retry with a higher statement timeout (or without one). `subgraph({ composition: true })` walks the whole part tree by design and never returns a prefix of it.", + }, + ); + } +} + /** Runs the traversal once and returns the (kind, id) pairs the read returns. */ async function fetchIncludedNodes( ctx: SubgraphContext, @@ -1374,12 +1872,13 @@ function buildOneStatementSubgraphQuery( ...nullProjectionColumns(nodePlan), ...buildProjectedPropertyColumns("e", edgePlan, ctx.dialect), ]; - const edgeSource = buildSubgraphEdgeSource(ctx, edgeTemporal); + const edgeRowFilter = subgraphEdgeRowFilter(ctx, edgeTemporal); + const edgeSource = buildSubgraphEdgeSource(ctx, edgeRowFilter); const edgeMembership = sharedOrigins ? sql`EXISTS (SELECT 1 FROM included_ids source_membership JOIN included_ids target_membership ON source_membership.origin_kind = target_membership.origin_kind AND source_membership.origin_id = target_membership.origin_id WHERE source_membership.kind = e.from_kind AND source_membership.id = e.from_id AND target_membership.kind = e.to_kind AND target_membership.id = e.to_id)` : sql`(e.from_kind, e.from_id) IN (SELECT kind, id FROM included_ids) AND (e.to_kind, e.to_id) IN (SELECT kind, id FROM included_ids)`; - return sql`${reachable}${includedIds} SELECT ${sql.join(nodeColumns, sql`, `)} FROM ${ctx.schema.nodesTable} n WHERE n.graph_id = ${ctx.graphId} AND ${nodeTemporal} AND (n.kind, n.id) IN (SELECT kind, id FROM included_ids) UNION ALL SELECT ${sql.join(edgeColumns, sql`, `)} FROM ${edgeSource} e WHERE e.graph_id = ${ctx.graphId} AND ${compileKindFilter(sql.raw("e.kind"), ctx.edgeKinds)} AND ${edgeTemporal} AND ${edgeMembership}`; + return sql`${reachable}${includedIds} SELECT ${sql.join(nodeColumns, sql`, `)} FROM ${ctx.schema.nodesTable} n WHERE n.graph_id = ${ctx.graphId} AND ${nodeTemporal} AND (n.kind, n.id) IN (SELECT kind, id FROM included_ids) UNION ALL SELECT ${sql.join(edgeColumns, sql`, `)} FROM ${edgeSource} e WHERE e.graph_id = ${ctx.graphId} AND ${edgeRowFilter} AND ${edgeMembership}`; } function mapOneStatementSubgraphRows< @@ -1587,15 +2086,17 @@ async function fetchSubgraphEdges( membership: SubgraphMembership, projectionPlan: ProjectionPlan, ): Promise { - const edgeKindFilter = compileKindFilter(sql.raw("e.kind"), ctx.edgeKinds); - const edgeTemporalFilter = compileTemporalFilter({ - mode: ctx.temporalMode, - asOf: ctx.asOf, - recordedAsOf: ctx.recordedAsOf, - tableAlias: "e", - currentTimestamp: ctx.currentTimestamp, - recordedReadBinding: ctx.recordedReadBinding, - }); + const edgeRowFilter = subgraphEdgeRowFilter( + ctx, + compileTemporalFilter({ + mode: ctx.temporalMode, + asOf: ctx.asOf, + recordedAsOf: ctx.recordedAsOf, + tableAlias: "e", + currentTimestamp: ctx.currentTimestamp, + recordedReadBinding: ctx.recordedReadBinding, + }), + ); const columns: SqlFragment[] = [ sql`e.id`, sql`e.kind`, @@ -1614,8 +2115,8 @@ async function fetchSubgraphEdges( ...buildProjectedPropertyColumns("e", projectionPlan, ctx.dialect), ]; - const edgesSource = buildSubgraphEdgeSource(ctx, edgeTemporalFilter); - const query = sql`${membership.prefix}SELECT ${sql.join(columns, sql`, `)} FROM ${edgesSource} e WHERE e.graph_id = ${ctx.graphId} AND ${edgeKindFilter} AND ${edgeTemporalFilter} AND ${membership.memberFilter(sql.raw("e.from_kind"), sql.raw("e.from_id"))} AND ${membership.memberFilter(sql.raw("e.to_kind"), sql.raw("e.to_id"))}`; + const edgesSource = buildSubgraphEdgeSource(ctx, edgeRowFilter); + const query = sql`${membership.prefix}SELECT ${sql.join(columns, sql`, `)} FROM ${edgesSource} e WHERE e.graph_id = ${ctx.graphId} AND ${edgeRowFilter} AND ${membership.memberFilter(sql.raw("e.from_kind"), sql.raw("e.from_id"))} AND ${membership.memberFilter(sql.raw("e.to_kind"), sql.raw("e.to_id"))}`; if (membership.parameterDependentPlan) markForceCustomPlan(query); return ctx.backend.execute( @@ -1647,11 +2148,30 @@ function validateEdgeWindows( } } +function isCompositionSelection( + composition: unknown, +): composition is SubgraphCompositionSelection | undefined { + if (composition === undefined || typeof composition === "boolean") { + return true; + } + if (!isPlainObject(composition)) return false; + const { via } = composition; + return ( + via === undefined || + typeof via === "string" || + (typeof via === "object" && + via !== null && + "kind" in via && + typeof via.kind === "string") + ); +} + function validateSubgraphTraversalOptions( options: Readonly<{ maxDepth?: number; direction?: unknown; cyclePolicy?: unknown; + composition?: unknown; }>, ): void { const maxDepth = options.maxDepth; @@ -1708,6 +2228,20 @@ function validateSubgraphTraversalOptions( }, ); } + if (!isCompositionSelection(options.composition)) { + throw new ValidationError( + "Subgraph composition must be a boolean or { via?: edge kind or edge type }", + { + issues: [ + { + path: "composition", + message: "Received an unsupported composition selection", + code: "invalid_value", + }, + ], + }, + ); + } } function buildMetadataColumns( diff --git a/packages/typegraph/src/store/transaction-receipt.ts b/packages/typegraph/src/store/transaction-receipt.ts index 85d5b09e9..008eb625c 100644 --- a/packages/typegraph/src/store/transaction-receipt.ts +++ b/packages/typegraph/src/store/transaction-receipt.ts @@ -8,11 +8,13 @@ import { } from "../identity/types"; import { type Assert, type Equal } from "../utils/type-assert"; import type { + IDENTITY_HISTORY_NAMES, IDENTITY_READ_NAMES, IDENTITY_WRITE_NAMES, } from "./collection-surface"; import { EDGE_WRITE_NAMES, NODE_WRITE_NAMES } from "./collection-surface"; import type { + CompositionCascadedPart, EdgeWrites, GraphEdgeCollections, GraphNodeCollections, @@ -40,7 +42,8 @@ type _receiptEdgeSurfaceIsComplete = Assert< type _receiptIdentityFacadeIsComplete = Assert< Equal< | (typeof IDENTITY_READ_NAMES)[number] - | (typeof IDENTITY_WRITE_NAMES)[number], + | (typeof IDENTITY_WRITE_NAMES)[number] + | (typeof IDENTITY_HISTORY_NAMES)[number], keyof IdentityFacade > >; @@ -54,13 +57,39 @@ type _identityReadSplitIsHonest = Assert< > >; -/** The receipt's identity counter buckets, minus the derived total. */ -type IdentityWriteCounterName = Exclude; +/** + * The receipt's identity counter buckets, minus the derived `total` and + * `transitions` — `transitions` is an annotation count recorded through its + * own {@link TransactionReceiptRecorder.recordIdentityTransitions}, never + * through {@link TransactionReceiptRecorder.recordIdentity}, because it must + * not bump `identity.total` or the receipt's overall `total` the way a real + * write kind does. + */ +type IdentityWriteCounterName = Exclude< + keyof IdentityWriteSummary, + "total" | "transitions" +>; export type TransactionReceiptRecorder = Readonly<{ recordNode: (kind: string, count: number) => void; recordEdge: (kind: string, count: number) => void; recordIdentity: (kind: IdentityWriteCounterName, count: number) => void; + /** + * Records `count` identity transition-log notes flushed for this + * transaction's graph — an annotation of the writes `recordIdentity` + * already counted, so it touches only `identity.transitions`, never + * `identity.total` or the receipt's overall `total`. + */ + recordIdentityTransitions: (count: number) => void; + /** + * Records the composition parts one node delete's cascade removed. Not a + * write intent at the collection surface — the caller issued ONE `delete`, + * which `recordNode` already counted — so this touches no counter; it only + * appends to the receipt's `cascadedParts`. Called by the delete's own row + * work (`node-operations.ts`), which is the only place the cascade plan + * exists. + */ + recordCascadedParts: (parts: readonly CompositionCascadedPart[]) => void; snapshot: (recorded?: TransactionReceipt["recorded"]) => TransactionReceipt; /** * Seals the recorder: every subsequent write through a collection wrapped with @@ -101,6 +130,8 @@ const NODE_WRITE_INTENT_COUNTERS = { create: countSingleWrite, createFromRecord: countSingleWrite, update: countSingleWrite, + reparent: countSingleWrite, + bulkReparent: countBulkInputAt(0), compareAndSet: countSingleWrite, updateWhere: countSingleWrite, delete: countSingleWrite, @@ -158,6 +189,8 @@ interface WriteCounters { readonly edges: Record; readonly identity: { -readonly [K in keyof IdentityWriteSummary]: number }; total: number; + /** Appended in delete order; each entry is one cascade's leaf-first closure. */ + readonly cascadedParts: CompositionCascadedPart[]; } export function createTransactionReceiptRecorder(): TransactionReceiptRecorder { @@ -168,9 +201,11 @@ export function createTransactionReceiptRecorder(): TransactionReceiptRecorder { sameAssertions: 0, differentAssertions: 0, retractions: 0, + transitions: 0, total: 0, }, total: 0, + cascadedParts: [], }; let sealed = false; @@ -191,6 +226,15 @@ export function createTransactionReceiptRecorder(): TransactionReceiptRecorder { counters.total += count; }, + recordIdentityTransitions(count): void { + if (count === 0) return; + counters.identity.transitions += count; + }, + + recordCascadedParts(parts): void { + counters.cascadedParts.push(...parts); + }, + snapshot(recorded): TransactionReceipt { // Spread, not `Object.assign` onto a fresh null-prototype bucket. Both // keep a prototype-colliding kind name readable, but the receipt is @@ -207,6 +251,7 @@ export function createTransactionReceiptRecorder(): TransactionReceiptRecorder { identity: Object.freeze({ ...counters.identity }), total: counters.total, }), + cascadedParts: Object.freeze([...counters.cascadedParts]), ...(recorded === undefined ? {} : { recorded }), }); }, @@ -243,6 +288,8 @@ export function wrapTransactionIdentity( areDifferent: (a, b) => identity.areDifferent(a, b), assertionsOf: (ref) => identity.assertionsOf(ref), explainSame: (a, b) => identity.explainSame(a, b), + transitionsOf: (ref, options) => identity.transitionsOf(ref, options), + replay: (ref, options) => identity.replay(ref, options), async assertSame(a, b) { recorder.assertWritable(); const result = await identity.assertSame(a, b); diff --git a/packages/typegraph/src/store/types.ts b/packages/typegraph/src/store/types.ts index 7189d7adc..daba167de 100644 --- a/packages/typegraph/src/store/types.ts +++ b/packages/typegraph/src/store/types.ts @@ -11,6 +11,7 @@ import { type TransactionReadBackend, } from "../backend/types"; import { + type AllNodeTypes, type EdgeKinds, type GraphDef, type GraphIdentityConfig, @@ -44,19 +45,24 @@ import type { DynamicNodeType, InitialQueryBuilder, } from "../query/builder"; +import type { DefaultAliasExpansionAxis } from "../query/builder/alias-expansion"; import type { BatchOnceOptions } from "../query/builder/one-statement-batch"; import type { + AssignableNodeKinds, BatchableQuery, + CompositionWholeKinds, NodeAccessor, NodeCandidateQuery, OneStatementBatchReads, OneStatementBatchResults, + SubsumedNodeType, } from "../query/builder/types"; import { type ExternalRecordedReadSource, type SqlSchema, } from "../query/compiler/schema"; import type { Predicate } from "../query/predicates"; +import type { CompositionViaRef } from "../registry/composition-relation"; import { typeGraphGlobalSymbol } from "../utils/global-symbol"; import type { CURRENT_ONLY_READ_NAMES, @@ -68,6 +74,7 @@ import type { RECORDED_POINT_READ_NAMES, } from "./collection-surface"; import type { NeighborReadOptions, NeighborResult } from "./neighbors"; +import type { NodeDeletePolicy } from "./operations/node-write-pipeline"; import type { BatchReadBuilder, EdgeCollectionLookup, @@ -79,9 +86,11 @@ import type { ValidateStoreOptions, } from "./store-analysis"; import type { + SubgraphCompositionSelection, SubgraphOptions, - SubgraphProject, + SubgraphProjectFor, SubgraphResult, + SubgraphResultEdgeKinds, } from "./subgraph"; /** @@ -174,6 +183,174 @@ export type Node = Readonly<{ }> & Readonly>; +/** + * One node named by its kind and id, on the composition surfaces: the whole a + * part is attached to, and the parts a whole's delete cascaded through. + */ +export type CompositionNodeRef = Readonly<{ kind: string; id: string }>; + +/** + * One part a composition whole's delete cascades through: the part itself, the + * whole it was directly a part of, and the composition edge kind that bound the + * two. Reversed, the list names every part after its whole, so once the + * deleted root is restored (the root is not in the list) it gives the order to + * re-attach parts in and the `partOf: { whole, via: viaEdgeKind }` each one + * takes. Neither the parts' props nor the composition edges' props are + * carried, and a part re-created under a new id replaces its old id wherever + * that id is a later entry's `whole`. + */ +export type CompositionCascadedPart = CompositionNodeRef & + Readonly<{ + /** + * The part's own immediate whole: the deleted node for a direct part, an + * intermediate part for one deeper in the closure. + */ + whole: CompositionNodeRef; + /** The composition edge kind that realized this part's membership. */ + viaEdgeKind: string; + }>; + +/** + * What `nodes..delete(id)` would do to a live node, computed by the same + * planner the delete runs and with every write skipped. See + * {@link NodeCollection.planDelete}. + */ +export type NodeDeletePlan = Readonly<{ + /** + * The parts the delete would cascade through, in the order the delete would + * report them as `cascadedParts`: leaf-first, then by kind, then id, within + * one level of the closure. Empty for a node with no live parts. + */ + cascadedParts: readonly CompositionCascadedPart[]; + /** + * Every edge the delete would remove, sorted by id: the composition edges + * the cascade consumes, and the edges each deleted node's `onDelete` + * (`cascade` / `disconnect`, or a `restrict` node's composition edges) + * removes alongside it. + */ + removedEdgeIds: readonly string[]; +}>; + +/** + * Options for `nodes..delete(id, options)`. See + * {@link NodeCollection.delete}. + */ +export type NodeDeleteOptions = Readonly<{ + /** + * The plan `planDelete(id)` returned, which this delete must still match. + * The delete computes its own plan, under its own lock and inside its own + * transaction, and refuses with `NodeDeletePlanMismatchError` before any + * write when the two differ or when the node is no longer live. + * + * Required once options are passed: `planDelete` answers `undefined` for a + * node there is nothing to delete, and that answer is not an expectation. + * Omit the options for an unguarded delete. + */ + expect: NodeDeletePlan; +}>; + +/** + * The edge that holds a part after an attachment write. + * + * Not a schema-typed {@link Edge}: the realizing kind is chosen at runtime + * (`via` may be a string), so the structural fields are what every caller + * can rely on. `validFrom` / `validTo` are omitted when the row has no + * bound on that side. + */ +export type CompositionHeldEdge = Readonly<{ + id: string; + kind: string; + fromKind: string; + fromId: string; + toKind: string; + toId: string; + validFrom?: string; + validTo?: string; +}>; + +export type CompositionAttachmentProps = + [Via] extends [AnyEdgeType] ? z.input + : Record; + +/** + * Where a part attaches, and how — the value every attachment surface takes + * (`create`, `bulkCreate`, `getOrCreateByConstraint`, + * `bulkGetOrCreateByConstraint`, and `reparent`). + * + * `whole` names the whole: a node you already hold, or a `{ kind, id }` + * reference. Only its `kind` and `id` are ever read, so a whole node whose + * own schema has a property called `via` or `props` never leaks into the + * attachment. On a typed collection `whole.kind` is limited at compile time + * to the whole kinds the graph's ontology declares for this part kind (see + * {@link CompositionWholeKinds}); the runtime refusal + * (`COMPOSITION_WHOLE_NOT_DECLARED`) covers everything the type cannot see. + * + * `via` names the realizing edge, and is required only when the part kind + * declares MORE THAN ONE composition pair toward that whole kind: omitting + * it there is refused (`COMPOSITION_VIA_AMBIGUOUS`) rather than resolved by + * sort order, and naming an edge kind that realizes no declared pair between + * the two is refused too (`COMPOSITION_VIA_NOT_DECLARED`). Pass the edge's + * TYPE when you have it — a typo is then a compile error, and `props` is + * checked against that edge's schema, the same check + * `store.edges..create` applies. A kind string keeps `props` as + * `Record` because the schema is not in scope. + * {@link compositionViaKind} is the one owner of the type-or-string + * distinction. + * + * `validFrom` / `validTo` are the realizing edge's validity window, not the + * part node's. A node's own `validFrom` / `validTo` on `create` are never + * copied onto the edge. Omit both to use the edge insert's own default. + * + * Any other top-level key is refused with `ValidationError`, so a + * misspelled option is never silently dropped. + */ +export type CompositionAttachment< + Via extends CompositionViaRef | undefined = CompositionViaRef | undefined, + WholeKind extends string = string, +> = Readonly<{ + whole: Readonly<{ kind: WholeKind; id: string }>; + via?: Via; + props?: CompositionAttachmentProps; + /** The realizing edge's lower bound. `null` requests no lower bound. */ + validFrom?: string | null; + /** The realizing edge's upper bound. Independent of the part node's window. */ + validTo?: string; +}>; + +/** + * Options for {@link NodeCollection.reparent}: the destination attachment, + * and the one instant the move happens at. + * + * A move has one instant — the incumbent's window ends where the new edge's + * begins — and `at` is its only spelling, so there is no `validFrom` here. + * There is no `validTo` either: a move opens the new attachment, and bounding + * a membership is a window edit on the realizing edge, not part of moving. + */ +export type NodeReparentOptions< + Via extends CompositionViaRef | undefined = CompositionViaRef | undefined, + WholeKind extends string = string, +> = Omit, "validFrom" | "validTo"> & + Readonly<{ + /** + * The instant a `oneActive` incumbent window ends and the new edge's + * `validFrom` begins, so the two halves abut. Omitted, the clock is read + * once. + */ + at?: string; + }>; + +/** + * The attachment that holds after {@link NodeCollection.reparent}. + * `moved` is false when the part already held this whole through the same + * realizing edge (no write, no history). `edge` is that holding edge either way. + */ +export type NodeReparentResult< + Via extends CompositionViaRef | undefined = CompositionViaRef | undefined, +> = Readonly<{ + edge: Via extends AnyEdgeType ? Edge : Edge; + moved: boolean; +}>; + /** * Input for creating a node. */ @@ -184,6 +361,34 @@ export type CreateNodeInput = Readonly<{ /** Omit to use the creation default; null explicitly requests no lower bound. */ validFrom?: string | null; validTo?: string; + /** The whole (and realizing edge) this create attaches its part to. */ + partOf?: CompositionAttachment; +}>; + +/** + * Options for {@link NodeCollection.create}. Extracted (rather than left as + * `create`'s inline options object) so `partOf`'s docblock is written once + * for every entry point that accepts it. + */ +export type NodeCreateOptions< + Via extends CompositionViaRef | undefined = CompositionViaRef | undefined, + WholeKind extends string = string, +> = Readonly<{ + id?: string; + /** Omit to use the creation default; null explicitly requests no lower bound. */ + validFrom?: string | null; + validTo?: string; + /** + * The whole a required part is created under, atomically with the node, in + * one write plan: a lost composition claim or a dead/missing whole aborts + * the node create too. Accepted on an optional-existence kind as a + * convenience (the same one write); never required for one. Runtime-checked + * against the graph's declared composition pairs — `ConfigurationError` + * (`COMPOSITION_WHOLE_NOT_DECLARED`) when no pair exists from this kind to + * `partOf.whole.kind`. See {@link CompositionAttachment} for `via` and + * `props`. + */ + partOf?: CompositionAttachment; }>; /** One caller-identified member of the closed heterogeneous node upsert batch. */ @@ -350,10 +555,40 @@ export type QueryHookContext = HookContext & params: readonly unknown[]; }>; +/** + * What an operation LEARNED while it ran, folded onto the context + * `onOperationEnd` receives. `onOperationStart` cannot carry any of it — the + * facts do not exist yet when the operation begins. + */ +export type OperationOutcomeFacts = Readonly<{ + /** + * The composition parts a whole's delete cascaded through, each with the + * whole it was directly a part of and the edge kind that bound it + * ({@link CompositionCascadedPart}), in the cascade's own delete order: + * LEAF-FIRST — every part before the whole it belongs to — and then a + * deterministic order by kind, then id, + * within one level of the closure. Two sibling parts of one whole have no order + * between them to respect, so they are sorted rather than left in the order + * the cascade's reads returned them, which makes this list comparable for + * equality across runs and backends. Taken from the plan the cascade already + * computed, so it names exactly the parts this delete removed. + * + * Present — and EMPTY when nothing cascaded, including for a kind that + * declares no composition pair at all — on a node hard delete, and on a + * node delete that took the portable write path; absent on a node delete + * the fused single-statement program ran (which is never a composition + * participant's: that program declines a composition whole or part + * outright) and on every non-delete operation. So "did a cascade happen" + * is the array's CONTENTS, never its presence. + */ + cascadedParts?: readonly CompositionCascadedPart[]; +}>; + /** * Operation hook context for CRUD operations. */ export type OperationHookContext = HookContext & + OperationOutcomeFacts & Readonly<{ /** Operation type */ operation: "create" | "update" | "delete"; @@ -426,6 +661,16 @@ export type StoreHooks = Readonly<{ * `withTransaction` / `withRecordedTransaction` — the commit belongs to * the caller and cannot be observed, so this fires when the operation * completes within the still-open transaction.) + * + * A composition whole's delete cascades to its parts (see + * [Composition Cascade](https://typegraph.dev/limitations#composition-cascade)) + * inside the SAME transaction, but each cascaded part delete is not itself + * a caller-issued operation: this hook fires exactly once, for the whole's + * own delete. The parts it removed are named on THIS context's + * `cascadedParts` (see {@link OperationOutcomeFacts}) — a consumer using + * hooks for cache invalidation or audit reads them from there rather than + * re-deriving the closure. `onOperationStart` never carries them: the + * cascade has not been planned when the operation begins. */ onOperationEnd?: ( ctx: OperationHookContext, @@ -564,6 +809,23 @@ export type BaseStoreOptions = Readonly<{ queryDefaults?: Readonly<{ /** Default traversal ontology expansion mode (default: "inverse"). */ traversalExpansion?: TraversalExpansion; + /** + * Default expansion axis for `from`/`to`/`fromDynamic`/`toDynamic` when + * an alias states no `expansion` (default: `"subclasses"` — a supertype + * query is polymorphic by default). Pass `"exact"` to restore the earlier + * exact-kind behavior for query aliases. `"narrower"` is not a store-wide + * default (see {@link DefaultAliasExpansionAxis}). + * This setting does NOT apply to `search()` or the collection APIs + * (`find`, `count`, `updateWhere`, `compareAndSet`). `search()` has its + * own default, {@link SEARCH_EXPANSION_DEFAULT} (`"exact"`), so a store + * opened with `queryDefaults.expansion: "subclasses"` is not polymorphic + * everywhere. The two defaults are different constants on purpose. + * + * Spelled `| undefined` so a stated `undefined` forwards from a caller's + * own options bag under `exactOptionalPropertyTypes`, the same way the + * per-alias `expansion` option does. + */ + expansion?: DefaultAliasExpansionAxis | undefined; }>; }>; @@ -615,8 +877,12 @@ export type StoreOptions = LiveStoreOptions | HistoryStoreOptions; * * `history` and `revisionTracking` are deliberately excluded: a working-copy * strategy decides those for itself (a fork mirrors the base's own - * `historyEnabled`/`revisionTrackingEnabled`; a clone documents why it keeps - * a narrower subset — see `cloneWorkingCopyStrategy`'s doc comment). + * `historyEnabled`/`revisionTrackingEnabled`). A working copy allocated from + * a live base store carries its `queryDefaults`, so a query answers alike on + * the store and on its working copy; a durable copy reopened later starts + * from library defaults unless the caller passes `reopenOptions`. A clone + * carries nothing else from this set — see `cloneWorkingCopyStrategy`'s doc + * comment. */ export type WorkingCopyOptions = Omit< LiveStoreOptions, @@ -715,6 +981,22 @@ export type TransactionReceipt = Readonly<{ /** Sum of all node, edge, and identity write intents. */ total: number; }>; + /** + * Every composition part a node delete inside this transaction cascaded + * through, in the order the deletes ran: each delete's own closure — + * leaf-first, then deterministically by kind, then id, within one level — + * concatenated in the order the deletes were issued. Empty when the + * transaction deleted no composition whole. Taken from the same plan the + * cascade executed, so it never names a part the cascade did not delete, and + * the order is fixed by the closure rather than by the order any read + * returned its rows. + * + * Not a write COUNT: a cascaded part is not a write intent at the + * collection surface (`writes.nodes` counts the whole's own `delete` call + * alone), so it is reported as refs beside the counters rather than folded + * into them. + */ + cascadedParts: readonly CompositionCascadedPart[]; /** * The recorded commit instant allocated for this store's graph by this * transaction. Under TypeGraph-owned capture, an explicit @@ -765,9 +1047,44 @@ export type NodeGetOrCreateByConstraintResult = Readonly<{ /** * Options for node getOrCreateByConstraint operations. */ -export type NodeGetOrCreateByConstraintOptions = Readonly<{ +export type NodeGetOrCreateByConstraintOptions< + Via extends CompositionViaRef | undefined = CompositionViaRef | undefined, + WholeKind extends string = string, +> = Readonly<{ /** Existing record behavior. Default: "return" */ ifExists?: IfExistsMode; + /** + * The attachment this call GUARANTEES the resolved node holds when it + * returns — a postcondition, not a create-only option. + * + * On `"created"` / `"resurrected"` it is applied exactly as a plain + * create's `partOf` is. On `"found"` / `"updated"` it is CHECKED: a node + * whose live whole, and whose realizing edge kind, already equal the + * resolved attachment satisfies it; a node with a DIFFERENT live whole, or + * the same whole through another realizing edge, refuses with + * `CompositionExistenceError` (`situation: "existing"`) naming both sides; + * a node with no live whole has the attachment written now, whatever the + * pair's declared `existence` (a REQUIRED part found unattached is + * repaired on the same terms — see `applyExistingPartOfPostcondition`). + * + * A satisfied match writes no edge, so stated `props` are never applied to + * it — but they are still validated, and still honored as a postcondition: + * omitted, or valid and canonically equal to the edge's live stored props, + * the call is idempotent; valid but DIFFERENT refuses with + * `CompositionExistenceError` (`situation: "props"`) naming both, rather + * than silently keeping the stored value. Update the realizing edge + * directly (`store.edges..update(...)`) to actually change it. + * + * An attachment this graph cannot resolve at all — an undeclared whole + * kind, an unknown `via`, an omitted `via` where the part declares more + * than one pair toward that whole kind — is a `ConfigurationError` on + * every action alike, decided before the match is even read. + * + * Shared by `bulkGetOrCreateByConstraint`, applying to every item in the + * batch. Use `reparent` to MOVE a part that already has a different whole + * — this option never silently re-homes one. + */ + partOf?: CompositionAttachment; }>; /** @@ -806,14 +1123,6 @@ export type EdgeBulkFindOptions = Readonly<{ */ export type EdgeBulkFindEndpointOptions = QueryOptions & EdgeBulkFindOptions; -/** A kind-grouped source list for a heterogeneous bulk edge read. */ -export type BulkEdgeSourceGroup = { - [K in NodeKinds]: Readonly<{ - kind: K; - ids: readonly NodeId[]; - }>; -}[NodeKinds]; - /** A node reference whose kind and branded id remain correlated. */ export type GraphNodeReference = { [K in NodeKinds]: Readonly<{ @@ -822,9 +1131,56 @@ export type GraphNodeReference = { }>; }[NodeKinds]; +type BrandedNodeReference = + N extends NodeType ? Readonly<{ kind: N["kind"]; id: NodeId }> : never; + +/** + * The widened `{ kind, id }` a node of type `N` carries when graph `G`'s + * subsumption reads it back polymorphically ({@link SubsumedNodeType}): a + * `string` kind and the widened `NodeId` brand, for each member of `N` whose + * kind has `subClassOf` descendants or `equivalentTo` kinds. A kind the + * ontology leaves exact contributes nothing, since its exact reference + * already covers it. + * + * The one owner of the widened reference every endpoint input accepts, so an + * endpoint read off a widened edge (`{ kind: edge.toKind, id: edge.toId }`), + * the same reference narrowed by `kind`, and a polymorphic query row all pass + * back into the next read without a cast. The id brand is what ties the + * reference to the declared kind; the runtime checks the `kind`. + */ +export type SubsumedNodeReference< + G extends GraphDef, + N extends NodeType, +> = BrandedNodeReference, N>>; + +/** + * A node reference the graph-wide endpoint reads (`bulkFindEdgesFrom`, + * `bulkFindEdgesTo`, `neighbors`, `countNeighbors`) accept: a + * {@link GraphNodeReference}, or the {@link SubsumedNodeReference} a + * widened edge endpoint or polymorphic query row carries. A `kind` the + * graph does not register is refused at runtime with `KindNotFoundError`. + */ +export type GraphNodeReferenceInput = + GraphNodeReference | SubsumedNodeReference>; + +type NodeReferenceGroup = + R extends Readonly<{ kind: infer K extends string; id: infer I }> ? + Readonly<{ kind: K; ids: readonly I[] }> + : never; + +/** + * A kind-grouped source list for a heterogeneous bulk edge read: one + * {@link GraphNodeReferenceInput} shape with its ids gathered under one + * `kind`. + */ +export type BulkEdgeSourceGroup = NodeReferenceGroup< + GraphNodeReferenceInput +>; + /** Runtime edge union narrowed to the selected graph edge kinds. */ export type GraphEdgeForKinds> = { - [P in K]: Edge< + [P in K]: EdgeInGraph< + G, G["edges"][P]["type"], G["edges"][P]["from"][number], G["edges"][P]["to"] extends readonly (infer N extends NodeType)[] ? N @@ -836,6 +1192,41 @@ export type GraphEdgeForKinds> = { >; }[K]; +/** Kind-discriminated node union over the selected graph node kinds. */ +export type GraphNodeForKinds> = { + [P in K]: Node; +}[K]; + +/** + * Resolves a `{ kind, id }` reference to the node it names, through the + * node collection `getNodeCollectionOrThrow(kind)` returns, so the read + * follows that collection's temporal model (or the view's pin). + * + * A reference whose `kind` is one of the graph's node kinds, such as a + * {@link GraphNodeReference}, resolves to that kind's node. A reference whose + * `kind` is the general `string`, such as an endpoint read off an edge whose + * declared kind has subclasses, resolves to the kind-discriminated union of + * the graph's node types plus the base `Node` of a kind added at runtime. + * Narrowing the reference's `kind` before the call + * (`if (edge.toKind === "Engineer")`) resolves straight to that kind's typed + * node; narrowing the result's `kind` rules out the other compile-time kinds + * but keeps the runtime-kind arm, whose properties are untyped. The kind is + * matched exactly: a reference naming a supertype with a subclass node's id + * resolves to `undefined`. + * + * Returns `undefined` when no node with that id exists under that kind, and + * rejects with `KindNotFoundError` when the kind is not registered in the + * graph. + */ +export interface NodeReferenceLookup { + >( + reference: Readonly<{ kind: K; id: string }>, + ): Promise | undefined>; + ( + reference: Readonly<{ kind: string; id: string }>, + ): Promise> | Node | undefined>; +} + /** Input for {@link Store.bulkFindEdgesFrom}. */ export type BulkFindEdgesFromParams< G extends GraphDef, @@ -845,12 +1236,15 @@ export type BulkFindEdgesFromParams< edgeKinds: readonly K[]; }>; -/** One source bucket returned by {@link Store.bulkFindEdgesFrom}. */ +/** + * One source bucket returned by {@link Store.bulkFindEdgesFrom}. `source` is + * the reference the caller passed in, so it is typed as the input is. + */ export type BulkFindEdgesFromResult< G extends GraphDef, K extends EdgeKinds, > = Readonly<{ - source: GraphNodeReference; + source: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -863,12 +1257,15 @@ export type BulkFindEdgesToParams< edgeKinds: readonly K[]; }>; -/** One target bucket returned by {@link Store.bulkFindEdgesTo}. */ +/** + * One target bucket returned by {@link Store.bulkFindEdgesTo}. `target` is + * the reference the caller passed in, so it is typed as the input is. + */ export type BulkFindEdgesToResult< G extends GraphDef, K extends EdgeKinds, > = Readonly<{ - target: GraphNodeReference; + target: GraphNodeReferenceInput; edges: readonly GraphEdgeForKinds[]; }>; @@ -954,6 +1351,7 @@ export type EdgeGetOrCreateByEndpointsOptions = export type NodeCollection< N extends NodeType, CN extends string = string, + WholeKind extends string = string, > = Readonly<{ /** * Create a new node. @@ -964,13 +1362,12 @@ export type NodeCollection< * bound ("ended at T, start unknown") and `meta.validFrom` reads back as * `undefined`. A future `validTo` is unaffected. */ - create: ( + create: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( props: z.input, - options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>, + options?: NodeCreateOptions, ) => Promise>; /** Get a node by ID */ @@ -1044,8 +1441,108 @@ export type NodeCollection< }>, ) => Promise>; - /** Delete a node (soft delete - sets deletedAt timestamp) */ - delete: (id: NodeId) => Promise; + /** + * Moves this composition part to a new whole, atomically: the old + * attachment is retired and the new one created in one transaction under + * one per-graph fence, so neither the one-whole-per-part claim nor + * `existence: "required"` ever observes an intermediate state. Neither + * half is expressible on its own — the create refuses while the old edge + * holds the claim, and the delete refuses while a required part is live. + * + * The part keeps its id, its properties, and every descendant beneath it. + * The FINAL state is what is validated: one whole, acyclicity over the + * oriented composition union, and a required part never left detached. + * + * How the old attachment is retired follows its declared population: a + * `population: "one"` edge is deleted, a `population: "oneActive"` edge + * has its window ended at the move instant, leaving the previous + * membership readable as valid-time history. + * + * Reparenting to the whole the part already holds (through the same + * realizing edge) is a no-op (no write, no history), not a refusal — but a + * stated `attachment.props` is still validated and still honored as a + * postcondition on that no-op: omitted, or valid and canonically equal to + * the edge's live stored props, the no-op stands; valid but DIFFERENT + * refuses with `CompositionExistenceError` (`situation: "props"`) rather + * than silently keeping the stored value. The result's `edge` is the + * holding edge either way; `moved` is false on that no-op. + * + * `at` is the move instant (see {@link NodeReparentOptions}). Refuses + * with `ConfigurationError` (`COMPOSITION_NOT_A_PART`) on a kind that + * declares no `partOf`/`hasPart` pair at all, + * (`COMPOSITION_WHOLE_NOT_DECLARED`) on an undeclared target pair, + * (`COMPOSITION_VIA_AMBIGUOUS` / `COMPOSITION_VIA_NOT_DECLARED`) on an + * unresolvable `via`, and with `NodeNotFoundError` when the part is + * missing or already deleted. + */ + reparent: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( + id: NodeId, + options: NodeReparentOptions, + ) => Promise>; + + /** + * Moves several parts in one transaction. Every item's verdict is reached + * before the first write, so a refusal leaves every part where it was, + * including when the caller catches it inside `store.transaction(...)`. + * The batch is judged on the state it produces as a whole: two moves that + * close a cycle between them are refused, and a part may move under one + * the same batch moves out from under it, in either item order. A part + * listed twice is refused with a `ValidationError`. Each item is + * {@link NodeReparentOptions} plus the part id. + */ + bulkReparent: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( + items: readonly Readonly<{ + id: NodeId; + options: NodeReparentOptions; + }>[], + ) => Promise[]>; + + /** + * Delete a node (soft delete - sets deletedAt timestamp). A no-op for a + * node that is absent or already deleted. + * + * `options.expect` guards the delete with a plan from + * {@link NodeCollection.planDelete}, for a preview the caller confirms + * before deleting, possibly in a later request. The delete computes its own + * plan with the same planner inside its transaction (for a composition + * whole, under the per-graph write lock the delete takes) and compares the + * two: the same parts (each with its `whole` and `viaEdgeKind`, so a + * reparented part counts as changed) and the same `removedEdgeIds`, in any + * order. A difference refuses with `NodeDeletePlanMismatchError` + * (`situation: "changed"`, naming the parts and edge ids added to and + * dropped from the plan) before any node, edge, sidecar, history or revision + * write and before `onOperationEnd`; a node that is absent or already + * deleted refuses the same way (`situation: "notLive"`) instead of being a + * no-op. A guarded delete always takes the portable delete path, never a + * fused single-statement command. + * + * The comparison covers the parts and edges the delete planned. For a + * composition whole no concurrent attach or reparent can change that plan + * before the delete runs it. Any other kind takes no per-graph lock, so + * every concurrent write can change the answer up to the comparison, and + * the comparison sees it at `READ COMMITTED`. A write that takes no + * per-graph lock (for a whole: a detach, a part's own delete, or an edge of + * a kind with no cardinality or acyclicity constraint; for any other kind: + * every write) can also commit after the comparison and before the delete's + * statements. The delete then treats it exactly as an unguarded delete + * does: at `READ COMMITTED` an edge created on a node it deletes is removed + * with that node under `cascade` / `disconnect`, or refuses the delete with + * `RestrictedDeleteError` under `restrict` (inside a caller's transaction, + * after the cascade's earlier writes); at PostgreSQL `REPEATABLE READ` or + * `SERIALIZABLE` the delete's snapshot does not see such an edge, and it + * stays live on the deleted node, as it does for every delete. SQLite + * admits no concurrent writer. + * + * An `options` object of the wrong shape, or one without a plan in + * `expect`, is refused with `ValidationError`. + */ + delete: (id: NodeId, options?: NodeDeleteOptions) => Promise; /** * Permanently delete a node from the database. @@ -1056,10 +1553,63 @@ export type NodeCollection< * **Warning:** This operation is irreversible and should be used carefully. * Consider using soft delete (`delete()`) for most use cases. * + * Takes no `expect` guard: {@link NodeCollection.planDelete} plans the soft + * delete, and a hard delete also purges a tombstoned node and the + * soft-deleted edge rows at each deleted node, which that plan does not + * name. An options argument (from an untyped caller) is refused with + * `ValidationError` rather than ignored. + * * @throws Error if edges are still connected to this node (delete edges first) */ hardDelete: (id: NodeId) => Promise; + /** + * Plans `delete(id)` without writing: the parts its composition cascade + * would delete and the edges it would remove, decided by the same planner + * the delete runs. `undefined` when the node is absent or already deleted, + * where `delete(id)` is a no-op. + * + * A delete the planner refuses is refused here with the same error the + * delete throws (`RestrictedDeleteError` for an `onDelete: "restrict"` node, + * or a cascaded part, that still has an ordinary edge; + * `CompositionCycleError` for a cycle in the stored parts), so a preview is + * read exactly like the delete it previews. + * + * To act on a preview someone confirms later, possibly in another request, + * pass it back as `delete(id, { expect: plan })`: the delete refuses with + * `NodeDeletePlanMismatchError` before any write when its own plan differs + * (see {@link NodeCollection.delete}). No lock spans two requests; the + * guard is what makes that flow safe. + * + * On a transaction's collections (`tx.nodes..planDelete`), the plan + * of a composition whole is read under the per-graph write lock its delete + * takes, held until the transaction ends, so no concurrent attach or + * reparent can add a part before a `delete(id)` later in the same + * transaction. Writes that take no per-graph lock still can change the + * answer: a concurrent detach or part delete drops that part from the later + * delete's cascade, and a concurrent ordinary edge changes `removedEdgeIds` + * or a restrict verdict. Any other kind's delete takes no per-graph lock, so + * every concurrent write can. Passing the plan as `expect` turns a change + * the delete can see (every committed change at `READ COMMITTED`) into a + * refusal. On the store's collections the plan is an unlocked, best-effort + * read. + * + * Neither form writes a graph, sidecar, history or revision row. The + * transactional form takes the lock the delete takes, which for a + * composition whole on PostgreSQL advances the graph's fence row as the + * delete does. It therefore fails the way that delete's fence fails: under + * PostgreSQL `REPEATABLE READ` or `SERIALIZABLE`, a snapshot taken before a + * concurrent attach fails with a serialization failure + * (`TransactionConflictError` inside your transaction), and a custom backend + * whose fence advances no fence row refuses at those levels with + * `ConfigurationError` (`COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT`). + * Inside `store.transaction(fn, { accessMode: "read_only" })` the + * transactional form refuses before any statement, for every kind, with + * `UnsupportedBackendCapabilityError`; use `store.nodes..planDelete` + * there for an unlocked preview. + */ + planDelete: (id: NodeId) => Promise; + /** * Find nodes matching criteria. * @@ -1093,11 +1643,7 @@ export type NodeCollection< */ createFromRecord: ( data: Record, - options?: Readonly<{ - id?: string; - validFrom?: string | null; - validTo?: string; - }>, + options?: NodeCreateOptions, ) => Promise>; /** @@ -1177,13 +1723,12 @@ export type NodeCollection< * bound ("ended at T, start unknown") and `meta.validFrom` reads back as * `undefined`. A future `validTo` is unaffected. */ - bulkCreate: ( - items: readonly Readonly<{ - props: z.input; - id?: string; - validFrom?: string | null; - validTo?: string; - }>[], + bulkCreate: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( + items: readonly (Readonly<{ props: z.input }> & + NodeCreateOptions)[], ) => Promise[]>; /** @@ -1279,7 +1824,8 @@ export type NodeCollection< * Delete multiple nodes by ID. * * Atomic when the backend supports transactions. Silently ignores IDs - * that don't exist. + * that don't exist. Takes no `expect` guard; an options argument (from an + * untyped caller) is refused with `ValidationError` rather than ignored. */ bulkDelete: (ids: readonly NodeId[]) => Promise; @@ -1348,10 +1894,13 @@ export type NodeCollection< * @param props - Full properties for create, or merge source for update * @param options - Existing record behavior (default: "return") */ - getOrCreateByConstraint: ( + getOrCreateByConstraint: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( constraintName: CN, props: z.input, - options?: NodeGetOrCreateByConstraintOptions, + options?: NodeGetOrCreateByConstraintOptions, ) => Promise>; /** @@ -1360,12 +1909,15 @@ export type NodeCollection< * Results are returned in the same order as the input items. * Atomic when the backend supports transactions. */ - bulkGetOrCreateByConstraint: ( + bulkGetOrCreateByConstraint: < + const Via extends CompositionViaRef | undefined = + CompositionViaRef | undefined, + >( constraintName: CN, items: readonly Readonly<{ props: z.input; }>[], - options?: NodeGetOrCreateByConstraintOptions, + options?: NodeGetOrCreateByConstraintOptions, ) => Promise[]>; }>; @@ -1382,6 +1934,53 @@ export type NodeCollection< export type NodeRef = Node | Readonly<{ kind: N["kind"]; id: string }>; +type KindNodeTypes = + K extends keyof G["nodes"] ? G["nodes"][K]["type"] : never; + +type AssignableNodeTypes = + N extends NodeType ? + KindNodeTypes, N["kind"]>> + : never; + +/** + * What a typed edge method accepts for an endpoint declared as `N` in graph + * `G`: a {@link NodeRef} of `N` or of a kind the runtime admits in its place + * ({@link AssignableNodeKinds}: a `subClassOf` descendant or an + * `equivalentTo` kind), or the {@link SubsumedNodeReference} a widened edge + * endpoint or a polymorphic query row over one of those kinds carries, whose + * `kind` is the general `string`. That arm is structural: a node or + * reference of an unrelated kind whose properties include the declared + * kind's also compiles. The runtime endpoint check stays authoritative and + * refuses a kind the edge does not admit with `EndpointError`. + */ +export type EdgeEndpointRef = + | NodeRef> + | SubsumedNodeReference>; + +/** + * An edge read in graph `G`: each declared endpoint typed by + * {@link SubsumedNodeType}, so an endpoint kind with `subClassOf` + * descendants or `equivalentTo` kinds reads back with a `string` kind and a + * widened `NodeId` brand. + */ +export type EdgeInGraph< + G extends GraphDef, + E extends AnyEdgeType, + From extends NodeType, + To extends NodeType, +> = Edge, SubsumedNodeType>; + +/** + * A graph whose ontology declares no relation: the context of an edge + * collection type named without a graph, where nothing widens or admits + * anything beyond the declared kinds. + */ +type GraphWithoutOntology = GraphDef< + Record, + Record, + readonly [] +>; + /** * Options for creating an edge. */ @@ -1433,11 +2032,12 @@ type EdgeEndpointPairTypes = Readonly<{ type EdgeBulkCreateItem< Pairs extends EdgeEndpointPairTypes, Schema extends z.ZodObject, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1448,13 +2048,14 @@ type EdgeBulkCreateItem< type EdgeBulkUpsertItem< E extends AnyEdgeType, Pairs extends EdgeEndpointPairTypes, - Id extends string = EdgeId, + Id extends string, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? Readonly<{ id: Id; - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; validFrom?: string | null; }> & @@ -1464,11 +2065,12 @@ type EdgeBulkUpsertItem< type EdgeBulkInsertItem< Pairs extends EdgeEndpointPairTypes, Schema extends z.ZodObject, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props?: z.input; id?: string; validFrom?: string | null; @@ -1479,11 +2081,12 @@ type EdgeBulkInsertItem< type EdgeBulkGetOrCreateItem< E extends AnyEdgeType, Pairs extends EdgeEndpointPairTypes, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? Readonly<{ - from: NodeRef; - to: NodeRef; + from: EdgeEndpointRef; + to: EdgeEndpointRef; props: z.input; validFrom?: string | null; onImmutableLowerBound?: "preserve" | "refuse"; @@ -1494,11 +2097,12 @@ type EdgeBulkGetOrCreateItem< type EdgeCreateArgumentsTuple< E extends AnyEdgeType, Pairs extends EdgeEndpointPairTypes, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, ...args: EdgeCreateArguments, ] : never; @@ -1506,11 +2110,12 @@ type EdgeCreateArgumentsTuple< type EdgeFindByEndpointsArguments< E extends AnyEdgeType, Pairs extends EdgeEndpointPairTypes, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, temporal?: QueryOptions, ] @@ -1519,11 +2124,12 @@ type EdgeFindByEndpointsArguments< type EdgeGetOrCreateByEndpointsArguments< E extends AnyEdgeType, Pairs extends EdgeEndpointPairTypes, + G extends GraphDef, > = Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, props: z.input, options?: EdgeGetOrCreateByEndpointsOptions, ] @@ -1540,6 +2146,7 @@ export type EdgeCollection< To extends NodeType = NodeType, Pairs extends EdgeEndpointPairTypes = { from: From; to: To }, InputId extends string = EdgeId, + G extends GraphDef = GraphWithoutOntology, > = Readonly<{ /** * Create a new edge. @@ -1555,20 +2162,20 @@ export type EdgeCollection< * @param args - Edge properties (optional if schema is empty) and creation options */ create: ( - ...args: EdgeCreateArgumentsTuple - ) => Promise>; + ...args: EdgeCreateArgumentsTuple + ) => Promise>; /** Get an edge by ID */ getById: ( id: InputId, options?: QueryOptions, - ) => Promise | undefined>; + ) => Promise | undefined>; /** Get multiple edges by ID, preserving input order (undefined for missing) */ getByIds: ( ids: readonly InputId[], options?: QueryOptions, - ) => Promise | undefined)[]>; + ) => Promise | undefined)[]>; /** * Update an edge's properties and optionally set or clear its validity end. @@ -1578,7 +2185,7 @@ export type EdgeCollection< id: InputId, props: Partial>, options?: ValidityEndMutation, - ) => Promise>; + ) => Promise>; /** * Find edges from a specific node. @@ -1590,9 +2197,9 @@ export type EdgeCollection< * endpoint's edges at another temporal coordinate. */ findFrom: ( - from: NodeRef, + from: EdgeEndpointRef, options?: QueryOptions, - ) => Promise[]>; + ) => Promise[]>; /** * Find edges to a specific node. @@ -1600,9 +2207,9 @@ export type EdgeCollection< * Temporal semantics mirror {@link EdgeCollection.findFrom}. */ findTo: ( - to: NodeRef, + to: EdgeEndpointRef, options?: QueryOptions, - ) => Promise[]>; + ) => Promise[]>; /** * Find the edges from a SET of source nodes in one read. @@ -1631,9 +2238,9 @@ export type EdgeCollection< * @throws {ConfigurationError} when the backend cannot read an endpoint set */ bulkFindFrom: ( - froms: readonly NodeRef[], + froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions, - ) => Promise[][]>; + ) => Promise[][]>; /** * Find the edges into a SET of target nodes in one read. @@ -1641,9 +2248,9 @@ export type EdgeCollection< * Mirrors {@link EdgeCollection.bulkFindFrom} on the `to` endpoint. */ bulkFindTo: ( - tos: readonly NodeRef[], + tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindEndpointOptions, - ) => Promise[][]>; + ) => Promise[][]>; /** * Deferred variant of `findFrom` for use with `store.batch()`. @@ -1658,9 +2265,9 @@ export type EdgeCollection< * many sources in one statement, traverse from them in a single query. */ batchFindFrom: ( - from: NodeRef, + from: EdgeEndpointRef, options?: QueryOptions, - ) => BatchableQuery>; + ) => BatchableQuery>; /** * Deferred variant of `findTo` for use with `store.batch()`. @@ -1670,9 +2277,9 @@ export type EdgeCollection< * statement per call, like {@link EdgeCollection.batchFindFrom}. */ batchFindTo: ( - to: NodeRef, + to: EdgeEndpointRef, options?: QueryOptions, - ) => BatchableQuery>; + ) => BatchableQuery>; /** * Deferred variant of `findByEndpoints` for use with `store.batch()`. @@ -1682,8 +2289,8 @@ export type EdgeCollection< * per call, like {@link EdgeCollection.batchFindFrom}. */ batchFindByEndpoints: ( - ...args: EdgeFindByEndpointsArguments - ) => BatchableQuery>; + ...args: EdgeFindByEndpointsArguments + ) => BatchableQuery>; /** Delete an edge (soft delete - sets deletedAt timestamp) */ delete: (id: InputId) => Promise; @@ -1702,19 +2309,19 @@ export type EdgeCollection< /** Find edges matching endpoint and pagination criteria */ find: ( filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; }>, temporal?: QueryOptions, - ) => Promise[]>; + ) => Promise[]>; /** Count edges matching criteria */ count: ( filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; }>, temporal?: QueryOptions, ) => Promise; @@ -1732,8 +2339,8 @@ export type EdgeCollection< * `undefined`. A future `validTo` is unaffected. */ bulkCreate: ( - items: readonly EdgeBulkCreateItem[], - ) => Promise[]>; + items: readonly EdgeBulkCreateItem[], + ) => Promise[]>; /** * Create or update multiple edges in a batch. @@ -1776,8 +2383,8 @@ export type EdgeCollection< * (free the slot, then claim it) or to apply those items individually. */ bulkUpsertById: ( - items: readonly EdgeBulkUpsertItem[], - ) => Promise[]>; + items: readonly EdgeBulkUpsertItem[], + ) => Promise[]>; /** * Insert multiple edges without returning results. @@ -1793,7 +2400,7 @@ export type EdgeCollection< * `undefined`. A future `validTo` is unaffected. */ bulkInsert: ( - items: readonly EdgeBulkInsertItem[], + items: readonly EdgeBulkInsertItem[], ) => Promise; /** @@ -1827,8 +2434,8 @@ export type EdgeCollection< * `temporalMode` / `asOf` to read the edge as of another coordinate. */ findByEndpoints: ( - ...args: EdgeFindByEndpointsArguments - ) => Promise | undefined>; + ...args: EdgeFindByEndpointsArguments + ) => Promise | undefined>; /** * Get an existing edge by endpoints and optional property fields, or create a new one. @@ -1858,8 +2465,14 @@ export type EdgeCollection< * @param options - Match criteria and conflict resolution */ getOrCreateByEndpoints: ( - ...args: EdgeGetOrCreateByEndpointsArguments - ) => Promise>; + ...args: EdgeGetOrCreateByEndpointsArguments + ) => Promise< + EdgeGetOrCreateByEndpointsResult< + E, + SubsumedNodeType, + SubsumedNodeType + > + >; /** * Batch version of getOrCreateByEndpoints. @@ -1868,12 +2481,18 @@ export type EdgeCollection< * Atomic when the backend supports transactions. */ bulkGetOrCreateByEndpoints: ( - items: readonly EdgeBulkGetOrCreateItem[], + items: readonly EdgeBulkGetOrCreateItem[], options?: Pick< EdgeGetOrCreateByEndpointsOptions, "matchOn" | "ifExists" >, - ) => Promise[]>; + ) => Promise< + EdgeGetOrCreateByEndpointsResult< + E, + SubsumedNodeType, + SubsumedNodeType + >[] + >; }>; // ============================================================ @@ -1939,15 +2558,31 @@ type EdgeAllowedPairs = ExtractAllowedPairs< R["to"] >; +type EdgeFromNodeTypes = + EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType; + +type EdgeToNodeTypes = + EdgeToTypes extends NodeType ? EdgeToTypes : NodeType; + /** * Create a type-safe EdgeCollection from an EdgeRegistration. * Extracts the edge type and from/to node types automatically. - */ -export type TypedEdgeCollection = EdgeCollection< + * + * In graph `G` (what `store.edges.` passes), the endpoints follow + * `G`'s subsumption: edges read back as {@link EdgeInGraph} and endpoint + * arguments take an {@link EdgeEndpointRef}. Without `G`, the declared kinds + * are taken exactly. + */ +export type TypedEdgeCollection< + R extends EdgeRegistration, + G extends GraphDef = GraphWithoutOntology, +> = EdgeCollection< R["type"], - EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType, - EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, - EdgeAllowedPairs + EdgeFromNodeTypes, + EdgeToNodeTypes, + EdgeAllowedPairs, + EdgeId, + G >; // ============================================================ @@ -1962,13 +2597,14 @@ export type TypedEdgeCollection = EdgeCollection< export type GraphNodeCollections = { [K in keyof G["nodes"] & string]-?: NodeCollection< G["nodes"][K]["type"], - ConstraintNames + ConstraintNames, + CompositionWholeKinds >; }; /** Mapped type of all edge collections for a graph. */ export type GraphEdgeCollections = { - [K in keyof G["edges"] & string]-?: TypedEdgeCollection; + [K in keyof G["edges"] & string]-?: TypedEdgeCollection; }; // ============================================================ @@ -1982,9 +2618,9 @@ export type NodeTemporalReads< > = Pick, (typeof NODE_TEMPORAL_READ_NAMES)[number]>; /** - * Current-state-only node reads (constraint / index lookups). No temporal - * axis, so a {@link StoreView} delegates them on a `current` pin and refuses - * them on a temporal pin. + * Current-state-only node reads (constraint / index lookups, delete plans). + * No temporal axis, so a {@link StoreView} delegates them on a `current` pin + * and refuses them on a temporal pin. */ export type NodeCurrentReads< N extends NodeType, @@ -2072,72 +2708,84 @@ export type StoreViewEdgeCollection< from: From; to: To; }, + G extends GraphDef = GraphWithoutOntology, > = Readonly<{ /** Get an edge by ID at the view's pinned coordinate. */ - getById: (id: EdgeId) => Promise | undefined>; + getById: (id: EdgeId) => Promise | undefined>; /** Get multiple edges by ID, preserving input order (undefined for missing). */ getByIds: ( ids: readonly EdgeId[], - ) => Promise | undefined)[]>; + ) => Promise | undefined)[]>; /** Find edges matching endpoint and pagination criteria. */ find: ( filter?: Readonly<{ - from?: NodeRef; - to?: NodeRef; + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; limit?: number; offset?: number; }>, - ) => Promise[]>; + ) => Promise[]>; /** Count edges matching criteria at the view's pinned coordinate. */ count: ( - filter?: Readonly<{ from?: NodeRef; to?: NodeRef }>, + filter?: Readonly<{ + from?: EdgeEndpointRef; + to?: EdgeEndpointRef; + }>, ) => Promise; /** Find edges from a specific node at the view's pinned coordinate. */ - findFrom: (from: NodeRef) => Promise[]>; + findFrom: ( + from: EdgeEndpointRef, + ) => Promise[]>; /** Find edges to a specific node at the view's pinned coordinate. */ - findTo: (to: NodeRef) => Promise[]>; + findTo: ( + to: EdgeEndpointRef, + ) => Promise[]>; /** Find the edges from a set of source nodes at the view's pinned coordinate. */ bulkFindFrom: ( - froms: readonly NodeRef[], + froms: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions, - ) => Promise[][]>; + ) => Promise[][]>; /** Find the edges into a set of target nodes at the view's pinned coordinate. */ bulkFindTo: ( - tos: readonly NodeRef[], + tos: readonly EdgeEndpointRef[], options?: EdgeBulkFindOptions, - ) => Promise[][]>; + ) => Promise[][]>; /** Find the edge between two endpoints at the view's pinned coordinate. */ findByEndpoints: ( ...args: Pairs extends EdgeEndpointPairTypes ? [ - from: NodeRef, - to: NodeRef, + from: EdgeEndpointRef, + to: EdgeEndpointRef, options?: EdgeFindByEndpointsOptions, ] : never - ) => Promise | undefined>; + ) => Promise | undefined>; }>; /** * Read-only view edge collection derived from an `EdgeRegistration`, * extracting the edge type and from/to node types — the read-only - * counterpart of {@link TypedEdgeCollection}. - */ -export type TypedStoreViewEdgeCollection = - StoreViewEdgeCollection< - R["type"], - EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType, - EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, - EdgeAllowedPairs - >; + * counterpart of {@link TypedEdgeCollection}, typing endpoints in graph `G` + * the same way. + */ +export type TypedStoreViewEdgeCollection< + R extends EdgeRegistration, + G extends GraphDef = GraphWithoutOntology, +> = StoreViewEdgeCollection< + R["type"], + EdgeFromNodeTypes, + EdgeToNodeTypes, + EdgeAllowedPairs, + G +>; /** Mapped type of all read-only view node collections for a graph. */ export type StoreViewNodeCollections = { @@ -2149,7 +2797,8 @@ export type StoreViewNodeCollections = { /** Mapped type of all read-only view edge collections for a graph. */ export type StoreViewEdgeCollections = { [K in keyof G["edges"] & string]-?: TypedStoreViewEdgeCollection< - G["edges"][K] + G["edges"][K], + G >; }; @@ -2190,25 +2839,29 @@ export type RecordedStoreViewEdgeCollection< from: From; to: To; }, + G extends GraphDef = GraphWithoutOntology, > = Pick< - StoreViewEdgeCollection, + StoreViewEdgeCollection, (typeof RECORDED_POINT_READ_NAMES)[number] > & Readonly<{ /** Scan one bounded page at the view's pinned coordinate. */ scan: ( options?: RecordedScanOptions, - ) => Promise>>; + ) => Promise>>; }>; /** Recorded-time edge collection derived from an `EdgeRegistration`. */ -export type TypedRecordedStoreViewEdgeCollection = - RecordedStoreViewEdgeCollection< - R["type"], - EdgeFromTypes extends NodeType ? EdgeFromTypes : NodeType, - EdgeToTypes extends NodeType ? EdgeToTypes : NodeType, - EdgeAllowedPairs - >; +export type TypedRecordedStoreViewEdgeCollection< + R extends EdgeRegistration, + G extends GraphDef = GraphWithoutOntology, +> = RecordedStoreViewEdgeCollection< + R["type"], + EdgeFromNodeTypes, + EdgeToNodeTypes, + EdgeAllowedPairs, + G +>; /** Mapped type of all recorded-time node reconstructing-read collections. */ export type RecordedStoreViewNodeCollections = { @@ -2220,7 +2873,8 @@ export type RecordedStoreViewNodeCollections = { /** Mapped type of all recorded-time edge reconstructing-read collections. */ export type RecordedStoreViewEdgeCollections = { [K in keyof G["edges"] & string]-?: TypedRecordedStoreViewEdgeCollection< - G["edges"][K] + G["edges"][K], + G >; }; @@ -2257,14 +2911,32 @@ export const TRANSACTION_RUNTIME: unique symbol = typeGraphGlobalSymbol( "transaction-runtime-v1", ); -type TransactionRuntime = Readonly<{ +export type TransactionRuntime = Readonly<{ backend: TransactionBackend; - runNodeOperationHooks: ( - operation: "create" | "update" | "delete", - kind: string, - id: string, - fn: () => Promise, - ) => Promise; + /** + * Soft-deletes one node under an explicit {@link NodeDeletePolicy} through + * this SAME transaction's node-operation context — the buffered hook + * runner and attempt number `#buildTransactionContext` already built for + * `nodes`/`edges` on this context, not a freshly-minted immediate-hook + * context. `StoreRuntime.deleteNodeWithPolicy` builds exactly such a fresh + * context and is correct only OUTSIDE a `store.transaction` callback; a + * caller invoking that Store-scoped variant from inside one (as + * `graph-merge`'s merge apply once did) would report `onOperationEnd` for a + * delete the instant it runs, even when the transaction it is part of + * later rolls back. A caller already inside a `store.transaction` callback + * — today, merge apply — reaches this transaction-scoped variant instead, + * via {@link file://./runtime-port.ts transactionDeleteNodeWithPolicy}. + */ + deleteNodeWithPolicy: ( + work: Readonly<{ kind: string; id: string }>, + policy?: NodeDeletePolicy, + ) => Promise; + /** + * Revives one soft-deleted node with its stored props through this SAME + * transaction's node-operation context — the inverse of a soft delete via + * {@link file://./runtime-port.ts transactionReviveNode}. + */ + reviveNode: (work: Readonly<{ kind: string; id: string }>) => Promise; }>; /** @@ -2303,6 +2975,8 @@ type TransactionCollections = Readonly<{ getNodeCollection: ( kind: K, ) => DynamicNodeCollection | undefined; + /** Reads the node a `{ kind, id }` reference names, mirroring `Store.getNodeByRef`. */ + getNodeByRef: NodeReferenceLookup; }> & (G["identity"] extends GraphIdentityConfig ? Readonly<{ identity: IdentityFacade }> @@ -2327,21 +3001,22 @@ export type TransactionContext = TransactionCollections & options?: BatchOnceOptions, ) => Promise>; neighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: NeighborReadOptions, ) => Promise[]>; countNeighbors: >( - source: GraphNodeReference, + source: GraphNodeReferenceInput, options: Omit, "limit" | "orderBy">, ) => Promise; subgraph: < const EK extends EdgeKinds, const NK extends NodeKinds = NodeKinds, - const P extends SubgraphProject | undefined = undefined, + const P extends SubgraphProjectFor | undefined = undefined, + const C extends SubgraphCompositionSelection | undefined = undefined, >( root: GraphNodeReference, - options: SubgraphOptions, - ) => Promise>; + options: SubgraphOptions, + ) => Promise, P>>; }>; /** @@ -2608,6 +3283,13 @@ export type BulkFindRuntimeEdgesFromResult< * This is intentional: unique constraints are graph-registration-level * details that differ between graphs sharing the same node types. * + * Composition whole kinds are NOT erased: they are the whole kinds `G`'s + * ontology declares for each projected kind (see + * {@link CompositionWholeKinds}), so `partOf` and `reparent` stay callable + * on a projection with the same compile-time check `Store` applies. A + * store of another graph is assignable only when its collection accepts at + * least those whole kinds. + * * @example * ```typescript * type CoreStore = StoreProjection< @@ -2635,6 +3317,12 @@ export type StoreProjection< N extends keyof G["nodes"] & string = never, E extends keyof G["edges"] & string = never, > = Readonly<{ - nodes: { [K in N]-?: NodeCollection }; + nodes: { + [K in N]-?: NodeCollection< + G["nodes"][K]["type"], + never, + CompositionWholeKinds + >; + }; edges: Pick, E>; }>; diff --git a/packages/typegraph/src/utils/compare.ts b/packages/typegraph/src/utils/compare.ts index caba77099..fe2a3f84b 100644 --- a/packages/typegraph/src/utils/compare.ts +++ b/packages/typegraph/src/utils/compare.ts @@ -52,3 +52,24 @@ export function compareCodePoints(left: string, right: string): number { if (leftExhausted && rightExhausted) return 0; return leftExhausted ? -1 : 1; } + +/** + * Lexicographic {@link compareStrings} over two string tuples: earlier + * elements decide first, and a strict prefix sorts before its extension. The + * order a NUL-separated composite key would give, without building the key — + * for a tuple whose elements may themselves contain any character. + */ +export function compareStringTuples( + left: readonly string[], + right: readonly string[], +): number { + const shared = Math.min(left.length, right.length); + for (let index = 0; index < shared; index += 1) { + const verdict = compareStrings( + requireDefined(left[index]), + requireDefined(right[index]), + ); + if (verdict !== 0) return verdict; + } + return left.length - right.length; +} diff --git a/packages/typegraph/src/utils/hash.ts b/packages/typegraph/src/utils/hash.ts index 29e680b8a..0a7955796 100644 --- a/packages/typegraph/src/utils/hash.ts +++ b/packages/typegraph/src/utils/hash.ts @@ -3,6 +3,7 @@ * generation, SHA-256 (truncated) for collision-resistant signatures. */ import { FNV1A_OFFSET_BASIS, FNV1A_PRIME } from "../constants"; +import { requireDefined } from "./presence"; /** * FNV-1a hash of `input`, returned as a base-36 encoded string. Used for @@ -44,3 +45,27 @@ export async function sha256Hex( } return hex; } + +/** + * Deterministic 8-char hash for collision-safe truncation of over-long + * identifiers. Self-contained so the strategy layer has no backend-runtime + * dependency. + */ +export function shortHash(input: string): string { + let h1 = 0xde_ad_be_ef; + let h2 = 0x41_c6_ce_57; + for (let index = 0; index < input.length; index++) { + const ch = requireDefined(input.codePointAt(index)); + h1 = Math.imul(h1 ^ ch, 0x9e_37_79_b1); + h2 = Math.imul(h2 ^ ch, 0x5f_35_64_95); + } + h1 = + Math.imul(h1 ^ (h1 >>> 16), 0x85_eb_ca_6b) ^ + Math.imul(h2 ^ (h2 >>> 13), 0xc2_b2_ae_35); + h2 = + Math.imul(h2 ^ (h2 >>> 16), 0x85_eb_ca_6b) ^ + Math.imul(h1 ^ (h1 >>> 13), 0xc2_b2_ae_35); + const hi = (h2 >>> 0).toString(16).padStart(8, "0"); + const lo = (h1 >>> 0).toString(16).padStart(8, "0"); + return `${hi}${lo}`.slice(0, 8); +} diff --git a/packages/typegraph/src/utils/object.ts b/packages/typegraph/src/utils/object.ts index 285c4fc17..f95218174 100644 --- a/packages/typegraph/src/utils/object.ts +++ b/packages/typegraph/src/utils/object.ts @@ -12,6 +12,18 @@ export function isPlainObject( return prototype === Object.prototype || prototype === null; } +/** + * Narrows `unknown` to a non-array object whose string-keyed members can be + * read defensively — never an assertion. Unlike {@link isPlainObject} it admits + * class instances (an `Error`, say), so it suits a structural read of a value + * of unknown origin rather than a check that it is a literal `{}`. + */ +export function isObjectRecord( + value: unknown, +): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + /** * Own-key membership for a bag whose KEYS ARE DATA — a props record parsed out * of a JSON column, a JSON-Schema `properties` map, a serialized-schema kind diff --git a/packages/typegraph/src/utils/path.ts b/packages/typegraph/src/utils/path.ts index c2db48a72..f023ab14f 100644 --- a/packages/typegraph/src/utils/path.ts +++ b/packages/typegraph/src/utils/path.ts @@ -109,6 +109,15 @@ export function assertPathTokenSafeKind(kind: string): void { ); } +/** + * The composite `kind + SEP + id` token for one node. Distinct (kind, id) + * pairs always spell distinct tokens, because node kinds cannot contain the + * separator ({@link assertPathTokenSafeKind}). + */ +export function kindQualifiedToken(kind: string, id: string): string { + return `${kind}${KIND_QUALIFIED_PATH_TOKEN_SEPARATOR}${id}`; +} + /** * Strips the composite wrapper from kind-qualified recursive path tokens, * restoring the bare node ids that the public path contract promises. diff --git a/packages/typegraph/src/utils/revision-column.ts b/packages/typegraph/src/utils/revision-column.ts new file mode 100644 index 000000000..0a38f989f --- /dev/null +++ b/packages/typegraph/src/utils/revision-column.ts @@ -0,0 +1,6 @@ +/** Decodes a driver's bigint or digit-string revision column to a number; any other value passes through for the caller to validate. */ +export function decodeRevisionColumn(value: unknown): unknown { + if (typeof value === "bigint") return Number(value); + if (typeof value === "string" && /^\d+$/.test(value)) return Number(value); + return value; +} diff --git a/packages/typegraph/src/utils/sql-errors.ts b/packages/typegraph/src/utils/sql-errors.ts index 51e9ae96a..2acdca9fd 100644 --- a/packages/typegraph/src/utils/sql-errors.ts +++ b/packages/typegraph/src/utils/sql-errors.ts @@ -223,6 +223,22 @@ function sqliteErrorCode(link: unknown): unknown { return Reflect.get(link, "code"); } +/** + * Whether the link carries a SQLite result code in either spelling a driver + * reports it: better-sqlite3's symbolic `code`, or libSQL's numeric + * `rawCode`/`extendedCode`. + */ +function hasSqliteResultCode( + link: object, + symbolic: string, + extended: number, +): boolean { + if (Reflect.get(link, "code") === symbolic) return true; + return SQLITE_EXTENDED_CODE_FIELDS.some( + (field) => Reflect.get(link, field) === extended, + ); +} + /** * SQLite driver messages normalized independently of transport decoration. * Native SQLite usually returns the engine text alone, while libSQL prefixes @@ -439,18 +455,15 @@ function isSqlitePrimaryKeyViolation( link: object, relation: PrimaryKeyRelation, ): boolean { - if (Reflect.get(link, "code") === SQLITE_PRIMARY_KEY_VIOLATION_CODE) { + if ( + hasSqliteResultCode( + link, + SQLITE_PRIMARY_KEY_VIOLATION_CODE, + SQLITE_PRIMARY_KEY_VIOLATION_EXTENDED_CODE, + ) + ) { return true; } - for (const field of SQLITE_EXTENDED_CODE_FIELDS) { - const value: unknown = Reflect.get(link, field); - if ( - typeof value === "number" && - value === SQLITE_PRIMARY_KEY_VIOLATION_EXTENDED_CODE - ) { - return true; - } - } if (Reflect.get(link, "code") !== "SQLITE_CONSTRAINT") return false; const message = sqliteErrorMessage(link); const expectedColumns = relation.sqliteColumns @@ -519,11 +532,11 @@ export function isNotNullColumnViolation( } const code: unknown = Reflect.get(link, "code"); const isSqliteNotNullViolation = - code === SQLITE_NOT_NULL_VIOLATION_CODE || code === "SQLITE_CONSTRAINT" || - SQLITE_EXTENDED_CODE_FIELDS.some( - (field) => - Reflect.get(link, field) === SQLITE_NOT_NULL_VIOLATION_EXTENDED_CODE, + hasSqliteResultCode( + link, + SQLITE_NOT_NULL_VIOLATION_CODE, + SQLITE_NOT_NULL_VIOLATION_EXTENDED_CODE, ); if ( sqliteErrorMessage(link) === expectedSqliteMessage && @@ -560,11 +573,11 @@ export function isDuplicateUniqueIndexError( return true; } const isSqliteUniqueViolation = - Reflect.get(link, "code") === SQLITE_UNIQUE_VIOLATION_CODE || Reflect.get(link, "code") === "SQLITE_CONSTRAINT" || - SQLITE_EXTENDED_CODE_FIELDS.some( - (field) => - Reflect.get(link, field) === SQLITE_UNIQUE_VIOLATION_EXTENDED_CODE, + hasSqliteResultCode( + link, + SQLITE_UNIQUE_VIOLATION_CODE, + SQLITE_UNIQUE_VIOLATION_EXTENDED_CODE, ); const message = sqliteErrorMessage(link); const expectedColumns = relation.sqliteColumns @@ -601,18 +614,28 @@ export function isSqliteMissingEdgeMatchIdentityColumnError( return false; } -/** Whether a concurrent SQLite adopter already added the column we planned. */ -export function isSqliteDuplicateEdgeMatchIdentityColumnError( +/** + * Whether a concurrent SQLite adopter already added one of the columns we + * planned to `ADD`. + * + * `columnNames` is the adoption's own column list, so one predicate serves + * every additive column rather than one hand-written predicate per column. + * Deliberately narrow — an exact `duplicate column name: ` message on + * a `SQLITE_ERROR` — because provisioning treats a match as "the post-state I + * wanted is already there" and must not swallow any other DDL failure. + */ +export function isSqliteDuplicateColumnError( error: unknown, + columnNames: readonly string[], ): boolean { + const duplicateMessages = new Set( + columnNames.map((column) => `duplicate column name: ${column}`), + ); for (const link of errorChain(error)) { if (!canReadProperty(link)) continue; if (Reflect.get(link, "code") !== "SQLITE_ERROR") continue; const message = sqliteErrorMessage(link); - if ( - message === "duplicate column name: match_identity_name" || - message === "duplicate column name: match_identity_key" - ) { + if (typeof message === "string" && duplicateMessages.has(message)) { return true; } } @@ -744,12 +767,82 @@ export function isSqliteNotAuthorizedError(error: unknown): boolean { */ export function isSqliteStaleSnapshotError(error: unknown): boolean { for (const link of errorChain(error)) { - if (sqliteErrorCode(link) === SQLITE_STALE_SNAPSHOT_CODE) return true; if (!canReadProperty(link)) continue; - for (const field of SQLITE_EXTENDED_CODE_FIELDS) { - if (Reflect.get(link, field) === SQLITE_STALE_SNAPSHOT_EXTENDED_CODE) { - return true; - } + if ( + hasSqliteResultCode( + link, + SQLITE_STALE_SNAPSHOT_CODE, + SQLITE_STALE_SNAPSHOT_EXTENDED_CODE, + ) + ) { + return true; + } + } + return false; +} + +/** + * PostgreSQL SQLSTATE for `query_canceled` — raised by `statement_timeout` + * expiring or by `pg_cancel_backend` — and `program_limit_exceeded`, raised + * when the planner or executor hits a compiled-in resource ceiling. + */ +const POSTGRES_QUERY_CANCELED_CODE = "57014"; +const POSTGRES_PROGRAM_LIMIT_EXCEEDED_CODE = "54000"; + +/** + * SQLite's base result codes for a statement the engine stopped before it + * finished: `SQLITE_INTERRUPT` (a call to `sqlite3_interrupt()`, which is how + * a statement timeout is implemented on this engine) and `SQLITE_TOOBIG` + * (a string/blob or a whole statement exceeded a compiled-in limit). Neither + * needs an extended-code variant — unlike the constraint-violation codes + * above, these carry no finer bucket to disambiguate. + */ +const SQLITE_INTERRUPT_CODE = "SQLITE_INTERRUPT"; +const SQLITE_INTERRUPT_EXTENDED_CODE = 9; +const SQLITE_TOOBIG_CODE = "SQLITE_TOOBIG"; +const SQLITE_TOOBIG_EXTENDED_CODE = 18; + +/** + * Whether `error` (or anything in its `.cause` chain) reports that the + * ENGINE cut a statement short rather than completing it — a cancelled or + * resource-exhausted search, never a business-rule refusal. Classified by + * code only, on both dialects, so it needs no dialect literal and adds no + * `DIALECT_LITERAL_EXEMPTIONS` entry. + * + * This is the one predicate the edge-acyclicity probe + * (`src/store/acyclicity.ts`) consults to decide "indeterminate" versus "no + * cycle": an unbounded reachability search that the engine could not finish + * must never be reported as a clean result, and only a structural code check + * — never a driver message, which is locale-dependent — can tell "the + * search ran out of budget" apart from "the search found nothing". + * + * PostgreSQL: `query_canceled` (57014) and `program_limit_exceeded` (54000), + * plus the shared "insufficient resources" class (SQLSTATE prefix `53`, + * {@link isInsufficientResourcesError}) rather than a second copy of it. + * SQLite: extended result codes `SQLITE_INTERRUPT` (9) and `SQLITE_TOOBIG` + * (18), in both spellings a driver reports them (better-sqlite3's symbolic + * `code`, libSQL's numeric `rawCode`/`extendedCode`). + * + * An error this function does not recognize propagates unchanged — a plain + * driver error is not silently reinterpreted as "indeterminate" either. + */ +export function isStatementCutShortError(error: unknown): boolean { + if (isInsufficientResourcesError(error)) return true; + for (const link of errorChain(error)) { + if (!canReadProperty(link)) continue; + if ( + isSqlStateIn(Reflect.get(link, "code"), [ + POSTGRES_QUERY_CANCELED_CODE, + POSTGRES_PROGRAM_LIMIT_EXCEEDED_CODE, + ]) || + hasSqliteResultCode( + link, + SQLITE_INTERRUPT_CODE, + SQLITE_INTERRUPT_EXTENDED_CODE, + ) || + hasSqliteResultCode(link, SQLITE_TOOBIG_CODE, SQLITE_TOOBIG_EXTENDED_CODE) + ) { + return true; } } return false; @@ -920,6 +1013,49 @@ function historyMissingRecordedRelationsError( ); } +const IDENTITY_TRANSITION_RELATIONS_MISSING_CODE = + "IDENTITY_TRANSITION_RELATIONS_MISSING"; + +/** + * Converts a missing-relation failure of a write to the identity transition + * log into an error that names the transition-log relations and the + * base-schema adoption that creates them. They are base-schema version 6 + * relations, so a database adopted only through version 5 lacks them while + * every recorded-time relation exists; the generic recorded-relations error + * would send its reader after tables that are already there. + */ +export async function withIdentityTransitionRelationsPrecondition( + promise: Promise, + relations: readonly string[], + details: Record, +): Promise { + try { + return await promise; + } catch (error) { + if (!isMissingTableError(error)) throw error; + throw new ConfigurationError( + `history: true on an identity graph requires the identity transition-log relations (${relations.join(", ")}), but one is missing.`, + { + ...details, + code: IDENTITY_TRANSITION_RELATIONS_MISSING_CODE, + relations, + }, + { + cause: error, + suggestion: + "They are base-schema version 6 relations: adopt it with backend.adoptBaseSchema(), or open the store once with createStoreWithSchema() / createAdapterStoreWithSchema() under a DDL-capable role, before any runtime worker writes. Externally managed DDL applies the version-6 statements and advances the base-schema marker to 6.", + }, + ); + } +} + +function isIdentityTransitionRelationsMissing(error: unknown): boolean { + return ( + error instanceof ConfigurationError && + error.details["code"] === IDENTITY_TRANSITION_RELATIONS_MISSING_CODE + ); +} + /** * Converts missing recorded-relation failures into the actionable precondition * error used by construction-time history checks. Capture paths use this after @@ -934,7 +1070,11 @@ export async function withRecordedRelationsPrecondition( try { return await promise; } catch (error) { - if (!isMissingTableError(error)) throw error; + if ( + isIdentityTransitionRelationsMissing(error) || + !isMissingTableError(error) + ) + throw error; throw historyMissingRecordedRelationsError( { ...details, code: "RECORDED_RELATIONS_MISSING" }, error, diff --git a/packages/typegraph/src/utils/tuple-key.ts b/packages/typegraph/src/utils/tuple-key.ts index 58cd10f01..b5c5250d5 100644 --- a/packages/typegraph/src/utils/tuple-key.ts +++ b/packages/typegraph/src/utils/tuple-key.ts @@ -8,3 +8,14 @@ export function encodeTupleKey(values: readonly string[]): string { return JSON.stringify(values); } + +/** + * The injective map key for a node reference: {@link encodeTupleKey} over + * `(kind, id)`. Node ids are arbitrary caller data, so a delimiter join could + * collapse two distinct references onto one key. + */ +export function nodeRefKey( + ref: Readonly<{ kind: string; id: string }>, +): string { + return encodeTupleKey([ref.kind, ref.id]); +} diff --git a/packages/typegraph/test-d/composition-via.test-d.ts b/packages/typegraph/test-d/composition-via.test-d.ts new file mode 100644 index 000000000..7b8fbb461 --- /dev/null +++ b/packages/typegraph/test-d/composition-via.test-d.ts @@ -0,0 +1,78 @@ +/** + * `via` as an edge type checks attachment props and types composition reads. + * A kind string stays unchecked at compile time. + */ +import { expectError } from "tsd"; +import { z } from "zod"; + +import { type Store, defineEdge, defineGraph, defineNode, partOf } from ".."; + +const Episode = defineNode("Episode", { + schema: z.object({ title: z.string() }), +}); +const Show = defineNode("Show", { schema: z.object({ name: z.string() }) }); +const episodeOf = defineEdge("episodeOf", { + schema: z.object({ season: z.number() }), + from: [Episode], + to: [Show], +}); + +const graph = defineGraph({ + id: "typed-via", + nodes: { Episode: { type: Episode }, Show: { type: Show } }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Show], + cardinality: "one", + }, + }, + ontology: [partOf(Episode, Show, { via: episodeOf })], +}); + +declare const store: Store; +declare const episodeId: string; + +expectError( + store.nodes.Episode.create( + { title: "one" }, + { + partOf: { + whole: { kind: "Show", id: "show" }, + via: episodeOf, + props: { season: "nope" }, + }, + }, + ), +); + +store.nodes.Episode.create( + { title: "one" }, + { + partOf: { + whole: { kind: "Show", id: "show" }, + via: episodeOf, + props: { season: 1 }, + }, + }, +); + +store.nodes.Episode.create( + { title: "one" }, + { + partOf: { + whole: { kind: "Show", id: "show" }, + via: "episodeOf", + props: { season: "unchecked" }, + }, + }, +); + +expectError( + store.nodes.Episode.reparent(episodeId as never, { + whole: { kind: "Show", id: "show" }, + via: episodeOf, + props: { season: "nope" }, + }), +); diff --git a/packages/typegraph/test-d/composition-whole.test-d.ts b/packages/typegraph/test-d/composition-whole.test-d.ts new file mode 100644 index 000000000..bf149e8f2 --- /dev/null +++ b/packages/typegraph/test-d/composition-whole.test-d.ts @@ -0,0 +1,312 @@ +/** + * An attachment names its whole under `whole`, and a typed collection limits + * `whole.kind` to the whole kinds the graph's ontology declares for that part + * kind. Where the restriction cannot be proven from the ontology tuple, any + * node kind of the graph is accepted and the runtime refusal decides. + */ +import { expectAssignable, expectError, expectType } from "tsd"; +import { z } from "zod"; + +import { + type CompositionWholeKinds, + type CreateNodeInput, + defineEdge, + defineGraph, + defineNode, + hasPart, + type Node, + type NodeCollection, + type OntologyRelation, + partOf, + type Store, + type StoreProjection, + subClassOf, +} from ".."; + +const Episode = defineNode("Episode", { + schema: z.object({ title: z.string() }), +}); +const Show = defineNode("Show", { schema: z.object({ name: z.string() }) }); +const Anthology = defineNode("Anthology", { schema: z.object({}) }); +const Album = defineNode("Album", { schema: z.object({}) }); +const Track = defineNode("Track", { schema: z.object({}) }); +const episodeOf = defineEdge("episodeOf", { schema: z.object({}) }); +const collectedIn = defineEdge("collectedIn", { schema: z.object({}) }); +const hasTrack = defineEdge("hasTrack", { schema: z.object({}) }); + +const nodes = { + Episode: { + type: Episode, + unique: [ + { + name: "episode_title", + fields: ["title"], + scope: "kind", + collation: "binary", + }, + ], + }, + Show: { type: Show }, + Anthology: { type: Anthology }, + Album: { type: Album }, + Track: { type: Track }, +} as const; +const edges = { + episodeOf: { type: episodeOf, from: [Episode], to: [Show] }, + collectedIn: { type: collectedIn, from: [Episode], to: [Anthology] }, + hasTrack: { type: hasTrack, from: [Album], to: [Track] }, +} as const; + +const graph = defineGraph({ + id: "typed-whole", + nodes, + edges, + ontology: [ + partOf(Episode, Show, { via: episodeOf }), + partOf(Episode, Anthology, { via: collectedIn }), + hasPart(Album, Track, { via: hasTrack }), + ], +}); + +declare const store: Store; +declare const show: Node; +declare const album: Node; +declare const episodeId: Node["id"]; +declare const trackId: Node["id"]; + +// ============================================================ +// Declared whole kinds, both declaration directions +// ============================================================ + +expectType<"Show" | "Anthology">( + {} as CompositionWholeKinds, +); +expectType<"Album">({} as CompositionWholeKinds); +expectType({} as CompositionWholeKinds); + +store.nodes.Episode.create({ title: "one" }, { partOf: { whole: show } }); +store.nodes.Episode.create( + { title: "one" }, + { partOf: { whole: { kind: "Anthology", id: "anthology" } } }, +); +store.nodes.Track.create({}, { partOf: { whole: album } }); + +expectError( + store.nodes.Episode.create({ title: "one" }, { partOf: { whole: album } }), +); +expectError( + store.nodes.Episode.create( + { title: "one" }, + { partOf: { whole: { kind: "Album", id: "album" } } }, + ), +); +expectError( + store.nodes.Track.create( + {}, + { partOf: { whole: { kind: "Show", id: "show" } } }, + ), +); + +// A kind that is no declared part accepts no attachment at all. +expectError( + store.nodes.Show.create( + { name: "one" }, + { partOf: { whole: { kind: "Anthology", id: "anthology" } } }, + ), +); + +// ============================================================ +// Every attachment surface carries the same restriction +// ============================================================ + +store.nodes.Episode.bulkCreate([ + { props: { title: "one" }, partOf: { whole: show } }, +]); +expectError( + store.nodes.Episode.bulkCreate([ + { props: { title: "one" }, partOf: { whole: album } }, + ]), +); + +store.nodes.Episode.getOrCreateByConstraint( + "episode_title", + { title: "one" }, + { partOf: { whole: show } }, +); +expectError( + store.nodes.Episode.getOrCreateByConstraint( + "episode_title", + { title: "one" }, + { partOf: { whole: album } }, + ), +); + +store.nodes.Episode.bulkGetOrCreateByConstraint( + "episode_title", + [{ props: { title: "one" } }], + { partOf: { whole: show } }, +); +expectError( + store.nodes.Episode.bulkGetOrCreateByConstraint( + "episode_title", + [{ props: { title: "one" } }], + { partOf: { whole: album } }, + ), +); + +store.nodes.Episode.reparent(episodeId, { whole: show }); +expectError(store.nodes.Episode.reparent(episodeId, { whole: album })); + +store.nodes.Episode.bulkReparent([{ id: episodeId, options: { whole: show } }]); +expectError( + store.nodes.Episode.bulkReparent([ + { id: episodeId, options: { whole: album } }, + ]), +); + +store.nodes.Track.reparent(trackId, { whole: album }); + +// ============================================================ +// The flat form is gone +// ============================================================ + +expectError( + store.nodes.Episode.create( + { title: "one" }, + { partOf: { kind: "Show", id: "show" } }, + ), +); +expectError( + store.nodes.Episode.reparent(episodeId, { kind: "Show", id: "show" }), +); +expectError>({ + kind: "Episode", + props: { title: "one" }, + partOf: { kind: "Show", id: "show" }, +}); +expectAssignable>({ + kind: "Episode", + props: { title: "one" }, + partOf: { whole: { kind: "Show", id: "show" } }, +}); + +// ============================================================ +// reparent has one instant +// ============================================================ + +store.nodes.Episode.reparent(episodeId, { + whole: show, + at: "2026-01-01T00:00:00.000Z", +}); +expectError( + store.nodes.Episode.reparent(episodeId, { + whole: show, + validFrom: "2026-01-01T00:00:00.000Z", + }), +); +expectError( + store.nodes.Episode.reparent(episodeId, { + whole: show, + validTo: "2027-01-01T00:00:00.000Z", + }), +); +// Create-time attachments keep the realizing edge's whole window. +store.nodes.Episode.create( + { title: "one" }, + { + partOf: { + whole: show, + validFrom: "2026-01-01T00:00:00.000Z", + validTo: "2027-01-01T00:00:00.000Z", + }, + }, +); + +// ============================================================ +// Conservative fallbacks: any node kind of the graph +// ============================================================ + +const Special = defineNode("Special", { + schema: z.object({ title: z.string() }), +}); +const subsumptionGraph = defineGraph({ + id: "typed-whole-subsumption", + nodes: { ...nodes, Special: { type: Special } }, + edges, + ontology: [ + partOf(Episode, Show, { via: episodeOf }), + subClassOf(Special, Episode), + ], +}); +declare const subsumptionStore: Store; + +type SubsumptionKinds = keyof (typeof subsumptionGraph)["nodes"] & string; +expectType( + {} as CompositionWholeKinds, +); +expectType( + {} as CompositionWholeKinds, +); +// The subclass inherits its parent's declared whole; the type cannot compute +// that closure, so it must not reject it. +subsumptionStore.nodes.Special.create( + { title: "one" }, + { partOf: { whole: show } }, +); +expectError( + subsumptionStore.nodes.Special.create( + { title: "one" }, + { partOf: { whole: { kind: "NotAKind", id: "x" } } }, + ), +); + +const erasedOntology: readonly OntologyRelation[] = [ + partOf(Episode, Show, { via: episodeOf }), +]; +const erasedGraph = defineGraph({ + id: "typed-whole-erased", + nodes, + edges, + ontology: erasedOntology, +}); +declare const erasedStore: Store; +expectType( + {} as CompositionWholeKinds, +); +erasedStore.nodes.Episode.create({ title: "one" }, { partOf: { whole: show } }); + +// ============================================================ +// Dynamic collections and projections +// ============================================================ + +const dynamicEpisodes = store.getNodeCollection("Episode"); +dynamicEpisodes?.create( + { title: "one" }, + { partOf: { whole: { kind: "AnyRuntimeKind", id: "x" } } }, +); + +type EpisodeProjection = StoreProjection; +expectAssignable(store); +declare const projection: EpisodeProjection; +projection.nodes.Episode.create({ title: "one" }, { partOf: { whole: show } }); +projection.nodes.Episode.reparent(episodeId, { whole: show }); +expectError( + projection.nodes.Episode.create( + { title: "one" }, + { partOf: { whole: album } }, + ), +); +expectError(projection.nodes.Episode.reparent(episodeId, { whole: album })); + +// A collection named with the default whole kinds is not the graph's own: the +// kinds sit in contravariant positions, so it must say which kinds it takes. +expectError>( + store.nodes.Episode, +); +expectAssignable< + NodeCollection< + typeof Episode, + "episode_title", + CompositionWholeKinds + > +>(store.nodes.Episode); diff --git a/packages/typegraph/test-d/graph-merge.test-d.ts b/packages/typegraph/test-d/graph-merge.test-d.ts index 571ab6ec2..a4832160a 100644 --- a/packages/typegraph/test-d/graph-merge.test-d.ts +++ b/packages/typegraph/test-d/graph-merge.test-d.ts @@ -285,7 +285,14 @@ expectType>>( ); expectError(applyMergePlanInTransaction(store, store, mergePlan)); expectError(applyMergePlanInTransaction(store, transaction, {} as unknown)); -expectError(applyMergePlanInTransaction(store, transaction, mergePlan, {})); +expectType>>( + applyMergePlanInTransaction(store, transaction, mergePlan, {}), +); +expectError( + applyMergePlanInTransaction(store, transaction, mergePlan, { + beforeApply: async () => {}, + }), +); const related = defineEdge("related", { schema: z.object({}) }); const identityGraph = defineGraph({ diff --git a/packages/typegraph/test-d/ontology.test-d.ts b/packages/typegraph/test-d/ontology.test-d.ts new file mode 100644 index 000000000..2dae75dbc --- /dev/null +++ b/packages/typegraph/test-d/ontology.test-d.ts @@ -0,0 +1,341 @@ +import { expectAssignable, expectError, expectType } from "tsd"; +import { z } from "zod"; + +import { + type AnyEdgeType, + broader, + defineEdge, + defineGraph, + defineNode, + disjointWith, + equivalentTo, + inverseOf, + type NodeType, + type OntologyRelation, + type Store, + subClassOf, +} from ".."; + +const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), +}); +const Individual = defineNode("Individual", { + schema: z.object({ name: z.string() }), +}); +const worksAt = defineEdge("worksAt", { + schema: z.object({ role: z.string() }), +}); +const employedBy = defineEdge("employedBy", { + schema: z.object({ role: z.string() }), +}); + +// Two node kinds — the unwidened usage still typechecks. +// The factory narrows the return type to a TypedOntologyRelation (still assignable +// to OntologyRelation, never identical to it), so this is an assignability +// check, not an exact-type one. +expectAssignable(equivalentTo(Person, Individual)); + +// A node kind mapped to an external IRI — the right parameter has always +// accepted a bare string. +expectAssignable( + equivalentTo(Person, "https://schema.org/Person"), +); + +// The widened left parameter: an edge kind mapped to an external IRI, so an +// edge can be declared equivalent to a cross-system vocabulary term. +expectAssignable( + equivalentTo(worksAt, "https://schema.org/worksFor"), +); + +// Two edge kinds have no defined substitution semantics (the runtime refusal +// in ontology/validation.ts exists precisely because this has no compile-time +// spelling: only a shared-IRI chain can put two edge kinds in one class). +expectError(equivalentTo(worksAt, employedBy)); + +declare const anyEdge: AnyEdgeType; +declare const anyNode: NodeType; +expectAssignable( + equivalentTo(anyEdge, "https://example.com/x"), +); +expectAssignable(equivalentTo(anyNode, anyNode)); + +// A node kind paired with an edge kind, in EITHER order, has no defined +// substitution semantics either — refused at registry build +// (ONTOLOGY_EQUIVALENCE_INVALID_CLASS) — and the overload set +// tightens this to a compile-time refusal too, in the node-first direction. +// (The edge-first direction, `equivalentTo(anyEdge, anyNode)`, DOES +// typecheck via the `(AnyEdgeType, NodeType)` overload; only the runtime +// registry build refuses it, matching the two-edge-kind case's runtime-only +// refusal above.) +expectError(equivalentTo(anyNode, anyEdge)); + +// ============================================================ +// Every meta-edge factory returns a typed relation +// ============================================================ + +// The IRI overload keeps `from` at the exact kind and types `to` as the +// bare `string` an external vocabulary term is, instead of collapsing the +// whole relation to `OntologyRelation`. +const personIriEquivalence = equivalentTo(Person, "https://schema.org/Person"); +expectType(personIriEquivalence.from); +expectType(personIriEquivalence.to); +expectType<"equivalentTo">(personIriEquivalence.metaEdge.name); +expectAssignable(personIriEquivalence); + +// The other helpers carry their meta-edge name literal too, so +// `SubsumptionAffected` can tell "this relation does not touch my kind" +// from "this relation was never typed". +const conceptBroader = broader(Individual, Person); +expectType<"broader">(conceptBroader.metaEdge.name); +expectType(conceptBroader.from); +expectAssignable(conceptBroader); + +const edgeInverse = inverseOf(worksAt, employedBy); +expectType<"inverseOf">(edgeInverse.metaEdge.name); +expectType(edgeInverse.from); +expectAssignable(edgeInverse); + +const mediaDisjoint = disjointWith(Individual, Person); +expectType<"disjointWith">(mediaDisjoint.metaEdge.name); +expectAssignable(mediaDisjoint); + +// ============================================================ +// SubsumptionAffected under the typed relations +// ============================================================ + +// An IRI-routed equivalence on `IriMedia` widens that kind's alias (the +// registry folds the IRI equivalence class into the subclass closure, so a +// co-registered kind's rows really can come back) while leaving a kind no +// relation names exact. +const IriMedia = defineNode("IriMedia", { + schema: z.object({ title: z.string() }), +}); +const IriPerson = defineNode("IriPerson", { + schema: z.object({ name: z.string() }), +}); +const iriGraph = defineGraph({ + id: "r2_iri_equivalence", + nodes: { IriMedia: { type: IriMedia }, IriPerson: { type: IriPerson } }, + edges: {}, + ontology: [equivalentTo(IriMedia, "https://schema.org/CreativeWork")], +}); + +declare const iriStore: Store; +const iriEquivalencedQuery = iriStore + .query() + .from("IriMedia", "m") + .select((ctx) => ctx.m); +declare const iriEquivalencedRow: Awaited< + ReturnType<(typeof iriEquivalencedQuery)["execute"]> +>[number]; +expectType(iriEquivalencedRow.kind); + +const iriUntouchedQuery = iriStore + .query() + .from("IriPerson", "p") + .select((ctx) => ctx.p); +declare const iriUntouchedRow: Awaited< + ReturnType<(typeof iriUntouchedQuery)["execute"]> +>[number]; +expectType<"IriPerson">(iriUntouchedRow.kind); + +// A tuple whose every element is a typed relation stays exact for a kind +// none of those relations subsumes: `broader`/`inverseOf` are not +// subsumption axes, and now that they are typed they no longer erase the +// tuple into conservative widening. +const TaxonomyMedia = defineNode("TaxonomyMedia", { + schema: z.object({ title: z.string() }), +}); +const TaxonomyPodcast = defineNode("TaxonomyPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); +const publishes = defineEdge("publishes", { + schema: z.object({ at: z.string() }), +}); +const typedTupleGraph = defineGraph({ + id: "r2_typed_tuple", + nodes: { + TaxonomyMedia: { type: TaxonomyMedia }, + TaxonomyPodcast: { type: TaxonomyPodcast }, + }, + edges: { + publishes: { + type: publishes, + from: [TaxonomyMedia], + to: [TaxonomyPodcast], + }, + }, + ontology: [ + broader(TaxonomyPodcast, TaxonomyMedia), + inverseOf(publishes, publishes), + ], +}); + +declare const typedTupleStore: Store; +const typedTupleQuery = typedTupleStore + .query() + .from("TaxonomyMedia", "m") + .select((ctx) => ctx.m); +declare const typedTupleRow: Awaited< + ReturnType<(typeof typedTupleQuery)["execute"]> +>[number]; +expectType<"TaxonomyMedia">(typedTupleRow.kind); + +// One element annotated as the bare `OntologyRelation` erases the literals +// `SubsumptionAffected` matches on, so the whole graph widens +// conservatively rather than reporting a kind as unaffected. +const AnnotatedMedia = defineNode("AnnotatedMedia", { + schema: z.object({ title: z.string() }), +}); +const AnnotatedPerson = defineNode("AnnotatedPerson", { + schema: z.object({ name: z.string() }), +}); +const annotatedRelation: OntologyRelation = broader( + AnnotatedMedia, + AnnotatedMedia, +); +const annotatedElementGraph = defineGraph({ + id: "r2_annotated_element", + nodes: { + AnnotatedMedia: { type: AnnotatedMedia }, + AnnotatedPerson: { type: AnnotatedPerson }, + }, + edges: {}, + ontology: [annotatedRelation], +}); + +declare const annotatedStore: Store; +const annotatedQuery = annotatedStore + .query() + .from("AnnotatedPerson", "p") + .select((ctx) => ctx.p); +declare const annotatedRow: Awaited< + ReturnType<(typeof annotatedQuery)["execute"]> +>[number]; +expectType(annotatedRow.kind); + +// An endpoint that is a UNION of node types — a caller helper declaring a +// subclass of either media root — widens every kind the union can hold. The +// relation keeps both its meta-edge name literal and its literal endpoint +// kinds, so neither conservative arm fires; only distributing over the +// endpoint union decides the two roots, and a kind no endpoint names stays +// exact. +const UnionAudio = defineNode("UnionAudio", { + schema: z.object({ title: z.string() }), +}); +const UnionVideo = defineNode("UnionVideo", { + schema: z.object({ title: z.string() }), +}); +const UnionEpisode = defineNode("UnionEpisode", { + schema: z.object({ title: z.string(), durationSeconds: z.number() }), +}); +const UnionUnrelated = defineNode("UnionUnrelated", { + schema: z.object({ name: z.string() }), +}); + +function declareEpisodeOf(root: typeof UnionAudio | typeof UnionVideo) { + return subClassOf(UnionEpisode, root); +} + +const unionEndpointGraph = defineGraph({ + id: "r2_union_endpoint", + nodes: { + UnionAudio: { type: UnionAudio }, + UnionVideo: { type: UnionVideo }, + UnionEpisode: { type: UnionEpisode }, + UnionUnrelated: { type: UnionUnrelated }, + }, + edges: {}, + ontology: [declareEpisodeOf(UnionAudio)], +}); + +declare const unionEndpointStore: Store; +const unionAudioQuery = unionEndpointStore + .query() + .from("UnionAudio", "a") + .select((ctx) => ctx.a); +declare const unionAudioRow: Awaited< + ReturnType<(typeof unionAudioQuery)["execute"]> +>[number]; +expectType(unionAudioRow.kind); + +const unionVideoQuery = unionEndpointStore + .query() + .from("UnionVideo", "v") + .select((ctx) => ctx.v); +declare const unionVideoRow: Awaited< + ReturnType<(typeof unionVideoQuery)["execute"]> +>[number]; +expectType(unionVideoRow.kind); + +const unionUnrelatedQuery = unionEndpointStore + .query() + .from("UnionUnrelated", "u") + .select((ctx) => ctx.u); +declare const unionUnrelatedRow: Awaited< + ReturnType<(typeof unionUnrelatedQuery)["execute"]> +>[number]; +expectType<"UnionUnrelated">(unionUnrelatedRow.kind); + +// The second shape the same union takes: a kind-literal union inside ONE +// endpoint object (`NodeType<"LiteralAudio" | "LiteralVideo">`), rather than a +// union of endpoint types. Both kinds widen; a kind the union cannot hold does +// not. +const LiteralAudio = defineNode("LiteralAudio", { + schema: z.object({ title: z.string() }), +}); +const LiteralVideo = defineNode("LiteralVideo", { + schema: z.object({ title: z.string() }), +}); +const LiteralEpisode = defineNode("LiteralEpisode", { + schema: z.object({ title: z.string(), durationSeconds: z.number() }), +}); +const LiteralUnrelated = defineNode("LiteralUnrelated", { + schema: z.object({ name: z.string() }), +}); + +function declareEpisodeUnderEither( + root: NodeType<"LiteralAudio" | "LiteralVideo">, +) { + return subClassOf(LiteralEpisode, root); +} + +const literalUnionGraph = defineGraph({ + id: "r2_union_endpoint_kind_literal", + nodes: { + LiteralAudio: { type: LiteralAudio }, + LiteralVideo: { type: LiteralVideo }, + LiteralEpisode: { type: LiteralEpisode }, + LiteralUnrelated: { type: LiteralUnrelated }, + }, + edges: {}, + ontology: [declareEpisodeUnderEither(LiteralAudio)], +}); + +declare const literalUnionStore: Store; +const literalAudioQuery = literalUnionStore + .query() + .from("LiteralAudio", "a") + .select((ctx) => ctx.a); +declare const literalAudioRow: Awaited< + ReturnType<(typeof literalAudioQuery)["execute"]> +>[number]; +expectType(literalAudioRow.kind); + +const literalVideoQuery = literalUnionStore + .query() + .from("LiteralVideo", "v") + .select((ctx) => ctx.v); +declare const literalVideoRow: Awaited< + ReturnType<(typeof literalVideoQuery)["execute"]> +>[number]; +expectType(literalVideoRow.kind); + +const literalUnrelatedQuery = literalUnionStore + .query() + .from("LiteralUnrelated", "u") + .select((ctx) => ctx.u); +declare const literalUnrelatedRow: Awaited< + ReturnType<(typeof literalUnrelatedQuery)["execute"]> +>[number]; +expectType<"LiteralUnrelated">(literalUnrelatedRow.kind); diff --git a/packages/typegraph/test-d/public-api.test-d.ts b/packages/typegraph/test-d/public-api.test-d.ts index 966c4601c..04f1afc68 100644 --- a/packages/typegraph/test-d/public-api.test-d.ts +++ b/packages/typegraph/test-d/public-api.test-d.ts @@ -1200,12 +1200,17 @@ expectType<"edge">(edgeIndex.entity); import { type Node } from ".."; -declare const personHits: Awaited< - ReturnType> ->; -declare const runtimeHits: Awaited< - ReturnType> ->; +// Typed through a call, not `ReturnType>`: +// that reads the last overload, which is the widened one an options value of +// the library's own option type takes. +async function searchPeople() { + return store.search.fulltext("Person", { query: "x", limit: 1 }); +} +async function searchPapers() { + return store.search.fulltext("Paper", { query: "x", limit: 1 }); +} +declare const personHits: Awaited>; +declare const runtimeHits: Awaited>; // Compile-time kind narrows to Node. expectAssignable>(personHits[0]!.node); @@ -1214,19 +1219,29 @@ expectAssignable>(personHits[0]!.node); expectAssignable(runtimeHits[0]!.node); // vector + hybrid follow the same pattern. -declare const vectorHits: Awaited< - ReturnType> ->; +async function vectorSearchPapers() { + return store.search.vector("Paper", { + fieldPath: "embedding", + queryEmbedding: [0, 1], + limit: 1, + }); +} +declare const vectorHits: Awaited>; expectAssignable(vectorHits[0]!.node); -declare const hybridHits: Awaited< - ReturnType> ->; +async function hybridSearch(kind: K) { + return store.search.hybrid(kind, { + limit: 1, + vector: { fieldPath: "embedding", queryEmbedding: [0, 1] }, + fulltext: { query: "x" }, + }); +} +declare const hybridHits: Awaited>>; expectAssignable(hybridHits[0]!.node); // Compile-time hybrid narrows. declare const compileTimeHybrid: Awaited< - ReturnType> + ReturnType> >; expectAssignable>(compileTimeHybrid[0]!.node); diff --git a/packages/typegraph/test-d/semantic-search.test-d.ts b/packages/typegraph/test-d/semantic-search.test-d.ts index a401262fc..31b9e2c27 100644 --- a/packages/typegraph/test-d/semantic-search.test-d.ts +++ b/packages/typegraph/test-d/semantic-search.test-d.ts @@ -81,9 +81,14 @@ expectError({ fieldPath: "embedding", limit: 10 }); expectError({ queryEmbedding, limit: 10 }); expectError({ fieldPath: "embedding", queryEmbedding }); -declare const vectorHits: Awaited< - ReturnType> ->; +async function vectorSearchDocs() { + return store.search.vector("Doc", { + fieldPath: "embedding", + queryEmbedding, + limit: 10, + }); +} +declare const vectorHits: Awaited>; const vectorHit = vectorHits[0]!; expectAssignable>(vectorHit.node); expectType(vectorHit.score); diff --git a/packages/typegraph/test-d/transition-page-cursor.test-d.ts b/packages/typegraph/test-d/transition-page-cursor.test-d.ts new file mode 100644 index 000000000..9efdb5f70 --- /dev/null +++ b/packages/typegraph/test-d/transition-page-cursor.test-d.ts @@ -0,0 +1,40 @@ +/** + * `nextCursor` is a transition-log cursor, not a recorded-time anchor. + * Passing it to `asOfRecorded` must fail to compile. + */ +import { expectError, expectNotAssignable } from "tsd"; +import { z } from "zod"; + +import { + type RecordedInstant, + type Store, + type TransitionPageCursor, + defineGraph, + defineNode, +} from ".."; + +const graph = defineGraph({ + id: "cursor", + nodes: { Person: { type: defineNode("Person", { schema: z.object({}) }) } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +declare const store: Store; +declare const cursor: TransitionPageCursor; +declare const recorded: RecordedInstant; + +expectNotAssignable(cursor); +expectError(store.asOfRecorded(cursor)); + +store.identity.transitionsOf({ kind: "Person", id: "ada" }, { cursor }); +expectError( + store.identity.transitionsOf( + { kind: "Person", id: "ada" }, + { cursor: recorded }, + ), +); +store.identity.transitionsOf( + { kind: "Person", id: "ada" }, + { fromRecorded: recorded }, +); diff --git a/packages/typegraph/tests/__snapshots__/claim-owner-sql-golden.test.ts.snap b/packages/typegraph/tests/__snapshots__/claim-owner-sql-golden.test.ts.snap index 2f223b0df..b04f4aff6 100644 --- a/packages/typegraph/tests/__snapshots__/claim-owner-sql-golden.test.ts.snap +++ b/packages/typegraph/tests/__snapshots__/claim-owner-sql-golden.test.ts.snap @@ -199,8 +199,10 @@ exports[`T2b — removal builders (§4.1b's zero-change proof) > buildHardDelete "one:knows", "unique:knows", "oneActive:knows", + "toone:knows", + "tooneActive:knows", ], - "sql": "DELETE FROM "typegraph_edge_claims" WHERE "graph_id" = $1 AND "axis" IN ($2, $3, $4)", + "sql": "DELETE FROM "typegraph_edge_claims" WHERE "graph_id" = $1 AND "axis" IN ($2, $3, $4, $5, $6)", } `; @@ -211,8 +213,10 @@ exports[`T2b — removal builders (§4.1b's zero-change proof) > buildHardDelete "one:knows", "unique:knows", "oneActive:knows", + "toone:knows", + "tooneActive:knows", ], - "sql": "DELETE FROM "typegraph_edge_claims" WHERE "graph_id" = ? AND "axis" IN (?, ?, ?)", + "sql": "DELETE FROM "typegraph_edge_claims" WHERE "graph_id" = ? AND "axis" IN (?, ?, ?, ?, ?)", } `; diff --git a/packages/typegraph/tests/__snapshots__/engine-profile-parity.test.ts.snap b/packages/typegraph/tests/__snapshots__/engine-profile-parity.test.ts.snap index 721bc821d..3efe4ff27 100644 --- a/packages/typegraph/tests/__snapshots__/engine-profile-parity.test.ts.snap +++ b/packages/typegraph/tests/__snapshots__/engine-profile-parity.test.ts.snap @@ -102,8 +102,8 @@ exports[`engine-profile parity: member keys, capabilities, marks > PGlite root > "commitSchemaVersionWithPreflight", "compareAndSetNode", "compileSql", + "countEdgesAtEndpoint", "countEdgesByKind", - "countEdgesFrom", "countNodesByKind", "createVectorIndex", "deleteEdge", @@ -212,6 +212,7 @@ exports[`engine-profile parity: member keys, capabilities, marks > PGlite root > "schemaProvisioning", "schemaWriteTransaction", "setActiveVersion", + "setActiveVersionWithPreflight", "setReconciliationMarker", "tableExists", "tableNames", @@ -317,8 +318,8 @@ exports[`engine-profile parity: member keys, capabilities, marks > PGlite root, "commitSchemaVersionWithPreflight", "compareAndSetNode", "compileSql", + "countEdgesAtEndpoint", "countEdgesByKind", - "countEdgesFrom", "countNodesByKind", "createVectorIndex", "deleteEdge", @@ -422,6 +423,7 @@ exports[`engine-profile parity: member keys, capabilities, marks > PGlite root, "schemaProvisioning", "schemaWriteTransaction", "setActiveVersion", + "setActiveVersionWithPreflight", "setReconciliationMarker", "tableExists", "tableNames", @@ -529,8 +531,8 @@ exports[`engine-profile parity: member keys, capabilities, marks > better-sqlite "commitSchemaVersionWithPreflight", "compareAndSetNode", "compileSql", + "countEdgesAtEndpoint", "countEdgesByKind", - "countEdgesFrom", "countNodesByKind", "createVectorIndex", "deleteEdge", @@ -626,6 +628,7 @@ exports[`engine-profile parity: member keys, capabilities, marks > better-sqlite "schemaProvisioning", "schemaWriteTransaction", "setActiveVersion", + "setActiveVersionWithPreflight", "setReconciliationMarker", "tableExists", "tableNames", @@ -724,8 +727,8 @@ exports[`engine-profile parity: member keys, capabilities, marks > better-sqlite "commitSchemaVersionWithPreflight", "compareAndSetNode", "compileSql", + "countEdgesAtEndpoint", "countEdgesByKind", - "countEdgesFrom", "countNodesByKind", "createVectorIndex", "deleteEdge", @@ -816,6 +819,7 @@ exports[`engine-profile parity: member keys, capabilities, marks > better-sqlite "schemaProvisioning", "schemaWriteTransaction", "setActiveVersion", + "setActiveVersionWithPreflight", "setReconciliationMarker", "tableExists", "tableNames", @@ -1199,6 +1203,47 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: efSearch "params": [], "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_separation_high_idx" ON "typegraph_identity_separation" ("graph_id", "class_key_high");", }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transitions" ( + "graph_id" TEXT NOT NULL, + "transition_id" TEXT NOT NULL, + "recorded_revision" BIGINT NOT NULL, + "recorded_at" TIMESTAMPTZ NOT NULL, + "valid_at" TIMESTAMPTZ NOT NULL, + "cause" TEXT NOT NULL, + "class_kind" TEXT NOT NULL, + "class_id" TEXT NOT NULL, + "prior_class_kind" TEXT, + "prior_class_id" TEXT, + "assertion_ids" JSONB NOT NULL, + "decision" JSONB, + "tx_id" TEXT, + "restored_at" TIMESTAMPTZ, + PRIMARY KEY ("graph_id", "transition_id") +);", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_class_idx" ON "typegraph_identity_transitions" ("graph_id", "class_kind", "class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_revision_idx" ON "typegraph_identity_transitions" ("graph_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_prior_class_idx" ON "typegraph_identity_transitions" ("graph_id", "prior_class_kind", "prior_class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transition_retention" ( + "graph_id" TEXT NOT NULL, + "pruned_before_revision" BIGINT NOT NULL, + "pruned_at" TIMESTAMPTZ NOT NULL, + PRIMARY KEY ("graph_id") +);", + }, { "params": [], "sql": "CREATE TABLE IF NOT EXISTS "typegraph_node_uniques" ( @@ -1442,6 +1487,23 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: efSearch ], "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = $1", }, + { + "params": [ + 1, + 6, + "<>", + 6, + "<>", + 6, + ], + "sql": "insert into "typegraph_base_schema_versions" ("installation", "version", "updated_at") values ($1, $2, $3) on conflict ("installation") do update set "version" = $4, "updated_at" = $5 where "typegraph_base_schema_versions"."version" <= $6", + }, + { + "params": [ + 1, + ], + "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = $1", + }, { "params": [], "sql": "-- BEGIN (native transaction) --", @@ -1650,11 +1712,15 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: efSearch "params": [], "sql": "-- BEGIN (native transaction) --", }, + { + "params": [], + "sql": "set transaction isolation level read committed", + }, { "params": [ "engine_profile_parity_script", ], - "sql": "SELECT pg_advisory_xact_lock(hashtext($1))", + "sql": "SELECT pg_advisory_xact_lock(hashtext($1)), current_setting('transaction_isolation') AS transaction_isolation", }, { "params": [ @@ -2596,6 +2662,47 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: graph-tem "params": [], "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_separation_high_idx" ON "typegraph_identity_separation" ("graph_id", "class_key_high");", }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transitions" ( + "graph_id" TEXT NOT NULL, + "transition_id" TEXT NOT NULL, + "recorded_revision" BIGINT NOT NULL, + "recorded_at" TIMESTAMPTZ NOT NULL, + "valid_at" TIMESTAMPTZ NOT NULL, + "cause" TEXT NOT NULL, + "class_kind" TEXT NOT NULL, + "class_id" TEXT NOT NULL, + "prior_class_kind" TEXT, + "prior_class_id" TEXT, + "assertion_ids" JSONB NOT NULL, + "decision" JSONB, + "tx_id" TEXT, + "restored_at" TIMESTAMPTZ, + PRIMARY KEY ("graph_id", "transition_id") +);", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_class_idx" ON "typegraph_identity_transitions" ("graph_id", "class_kind", "class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_revision_idx" ON "typegraph_identity_transitions" ("graph_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_prior_class_idx" ON "typegraph_identity_transitions" ("graph_id", "prior_class_kind", "prior_class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transition_retention" ( + "graph_id" TEXT NOT NULL, + "pruned_before_revision" BIGINT NOT NULL, + "pruned_at" TIMESTAMPTZ NOT NULL, + PRIMARY KEY ("graph_id") +);", + }, { "params": [], "sql": "CREATE TABLE IF NOT EXISTS "typegraph_node_uniques" ( @@ -2839,6 +2946,23 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: graph-tem ], "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = $1", }, + { + "params": [ + 1, + 6, + "<>", + 6, + "<>", + 6, + ], + "sql": "insert into "typegraph_base_schema_versions" ("installation", "version", "updated_at") values ($1, $2, $3) on conflict ("installation") do update set "version" = $4, "updated_at" = $5 where "typegraph_base_schema_versions"."version" <= $6", + }, + { + "params": [ + 1, + ], + "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = $1", + }, { "params": [ "engine_profile_parity_template_source", @@ -2859,11 +2983,15 @@ exports[`engine-profile parity: ordered compiled-SQL capture > PGlite: graph-tem "params": [], "sql": "-- BEGIN (native transaction) --", }, + { + "params": [], + "sql": "set transaction isolation level read committed", + }, { "params": [ "engine_profile_parity_template_source", ], - "sql": "SELECT pg_advisory_xact_lock(hashtext($1))", + "sql": "SELECT pg_advisory_xact_lock(hashtext($1)), current_setting('transaction_isolation') AS transaction_isolation", }, { "params": [ @@ -3547,6 +3675,47 @@ exports[`engine-profile parity: ordered compiled-SQL capture > better-sqlite3: e "params": [], "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_separation_high_idx" ON "typegraph_identity_separation" ("graph_id", "class_key_high");", }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transitions" ( + "graph_id" TEXT NOT NULL, + "transition_id" TEXT NOT NULL, + "recorded_revision" INTEGER NOT NULL, + "recorded_at" TEXT NOT NULL, + "valid_at" TEXT NOT NULL, + "cause" TEXT NOT NULL, + "class_kind" TEXT NOT NULL, + "class_id" TEXT NOT NULL, + "prior_class_kind" TEXT, + "prior_class_id" TEXT, + "assertion_ids" TEXT NOT NULL, + "decision" TEXT, + "tx_id" TEXT, + "restored_at" TEXT, + PRIMARY KEY ("graph_id", "transition_id") +);", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_class_idx" ON "typegraph_identity_transitions" ("graph_id", "class_kind", "class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_revision_idx" ON "typegraph_identity_transitions" ("graph_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_prior_class_idx" ON "typegraph_identity_transitions" ("graph_id", "prior_class_kind", "prior_class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transition_retention" ( + "graph_id" TEXT NOT NULL, + "pruned_before_revision" INTEGER NOT NULL, + "pruned_at" TEXT NOT NULL, + PRIMARY KEY ("graph_id") +);", + }, { "params": [], "sql": "CREATE TABLE IF NOT EXISTS "typegraph_node_uniques" ( @@ -3774,6 +3943,23 @@ exports[`engine-profile parity: ordered compiled-SQL capture > better-sqlite3: e ], "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = ?", }, + { + "params": [ + 1, + 6, + "<>", + 6, + "<>", + 6, + ], + "sql": "insert into "typegraph_base_schema_versions" ("installation", "version", "updated_at") values (?, ?, ?) on conflict ("typegraph_base_schema_versions"."installation") do update set "version" = ?, "updated_at" = ? where "typegraph_base_schema_versions"."version" <= ?", + }, + { + "params": [ + 1, + ], + "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = ?", + }, { "params": [], "sql": "BEGIN IMMEDIATE", @@ -4732,6 +4918,47 @@ exports[`engine-profile parity: ordered compiled-SQL capture > better-sqlite3: g "params": [], "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_separation_high_idx" ON "typegraph_identity_separation" ("graph_id", "class_key_high");", }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transitions" ( + "graph_id" TEXT NOT NULL, + "transition_id" TEXT NOT NULL, + "recorded_revision" INTEGER NOT NULL, + "recorded_at" TEXT NOT NULL, + "valid_at" TEXT NOT NULL, + "cause" TEXT NOT NULL, + "class_kind" TEXT NOT NULL, + "class_id" TEXT NOT NULL, + "prior_class_kind" TEXT, + "prior_class_id" TEXT, + "assertion_ids" TEXT NOT NULL, + "decision" TEXT, + "tx_id" TEXT, + "restored_at" TEXT, + PRIMARY KEY ("graph_id", "transition_id") +);", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_class_idx" ON "typegraph_identity_transitions" ("graph_id", "class_kind", "class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_revision_idx" ON "typegraph_identity_transitions" ("graph_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE INDEX IF NOT EXISTS "typegraph_identity_transitions_prior_class_idx" ON "typegraph_identity_transitions" ("graph_id", "prior_class_kind", "prior_class_id", "recorded_revision");", + }, + { + "params": [], + "sql": "CREATE TABLE IF NOT EXISTS "typegraph_identity_transition_retention" ( + "graph_id" TEXT NOT NULL, + "pruned_before_revision" INTEGER NOT NULL, + "pruned_at" TEXT NOT NULL, + PRIMARY KEY ("graph_id") +);", + }, { "params": [], "sql": "CREATE TABLE IF NOT EXISTS "typegraph_node_uniques" ( @@ -4959,6 +5186,23 @@ exports[`engine-profile parity: ordered compiled-SQL capture > better-sqlite3: g ], "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = ?", }, + { + "params": [ + 1, + 6, + "<>", + 6, + "<>", + 6, + ], + "sql": "insert into "typegraph_base_schema_versions" ("installation", "version", "updated_at") values (?, ?, ?) on conflict ("typegraph_base_schema_versions"."installation") do update set "version" = ?, "updated_at" = ? where "typegraph_base_schema_versions"."version" <= ?", + }, + { + "params": [ + 1, + ], + "sql": "select "version" from "typegraph_base_schema_versions" where "typegraph_base_schema_versions"."installation" = ?", + }, { "params": [ "engine_profile_parity_template_source", diff --git a/packages/typegraph/tests/advisory-only-backend.ts b/packages/typegraph/tests/advisory-only-backend.ts new file mode 100644 index 000000000..5b5cd6041 --- /dev/null +++ b/packages/typegraph/tests/advisory-only-backend.ts @@ -0,0 +1,40 @@ +/** + * A backend whose graph-write fence is an advisory lock and nothing else: its + * transactions name no fences relation, so the fence acquisition cannot + * advance a fence row, and they expose no fused schema + graph fence. This is + * the shape of a custom port that implements `pg_advisory_xact_lock` but + * stores no TypeGraph fence rows. + * + * Such a port cannot detect a stale snapshot at the fence, so every decision + * the fence alone protects must refuse a snapshot isolation on it. + */ +import { + deriveBackend, + projectBackendWithout, +} from "../src/backend/derive-backend"; +import type { GraphBackend, TransactionBackend } from "../src/backend/types"; + +function withoutFenceRow(transaction: TransactionBackend): TransactionBackend { + const lockOnly: TransactionBackend = projectBackendWithout(transaction, [ + "lockSchemaVersionAndGraphWrite", + ]); + const { tableNames } = transaction; + if (tableNames === undefined) return lockOnly; + return deriveBackend(lockOnly, { + tableNames: { ...tableNames, fences: undefined }, + }); +} + +/** Derives `backend` so every transaction it opens is advisory-lock-only. */ +export function advisoryOnlyBackend( + backend: TBackend, +): TBackend { + const overrides: Pick = { + transaction: (run, options) => + backend.transaction( + (transaction) => run(withoutFenceRow(transaction)), + options, + ), + }; + return deriveBackend(backend, overrides) as TBackend; +} diff --git a/packages/typegraph/tests/atomic-durable-convergence-program-ratchets.test.ts b/packages/typegraph/tests/atomic-durable-convergence-program-ratchets.test.ts index 41b6386ff..de375348b 100644 --- a/packages/typegraph/tests/atomic-durable-convergence-program-ratchets.test.ts +++ b/packages/typegraph/tests/atomic-durable-convergence-program-ratchets.test.ts @@ -21,6 +21,7 @@ import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; import { createLibsqlBackend } from "../src/backend/sqlite/libsql"; import { D1_MAX_BIND_PARAMETERS } from "../src/backend/types"; import { defineEdge, defineGraph, defineNode } from "../src/core"; +import { buildKindRegistry } from "../src/registry"; import { migrateSchema } from "../src/schema"; import { createStoreWithSchema } from "../src/store"; import { resolveAtomicEdgeConvergenceExecutor } from "../src/store/operations/atomic-mutation-program"; @@ -49,6 +50,8 @@ const durableGraph = defineGraph({ }, }); +const durableGraphRegistry = buildKindRegistry(durableGraph); + const evolvedGraph = defineGraph({ id: durableGraph.id, nodes: { @@ -204,6 +207,7 @@ describe("durable convergence program ratchets", () => { resolveAtomicEdgeConvergenceExecutor({ backend, graph: durableGraph, + registry: durableGraphRegistry, schemaVersion: 1, historyEnabled: false, revisionTrackingEnabled: false, @@ -228,6 +232,7 @@ describe("durable convergence program ratchets", () => { resolveAtomicEdgeConvergenceExecutor({ backend, graph: durableGraph, + registry: durableGraphRegistry, schemaVersion: 1, historyEnabled: false, revisionTrackingEnabled: false, @@ -242,6 +247,7 @@ describe("durable convergence program ratchets", () => { resolveAtomicEdgeConvergenceExecutor({ backend, graph: durableGraph, + registry: durableGraphRegistry, schemaVersion: 1, historyEnabled: false, revisionTrackingEnabled: false, diff --git a/packages/typegraph/tests/atomic-edge-claim-relation.test.ts b/packages/typegraph/tests/atomic-edge-claim-relation.test.ts index 123fdae7e..725fda4b9 100644 --- a/packages/typegraph/tests/atomic-edge-claim-relation.test.ts +++ b/packages/typegraph/tests/atomic-edge-claim-relation.test.ts @@ -21,11 +21,21 @@ import { buildAcquireAtomicEdgeClaims, buildAssertAtomicEdgeClaimsOwned, buildDeleteStaleAtomicEdgeClaims, + buildInsertEdgeIfEndpointsLiveWithCardinalityClaim, + buildLockEdgeClaimGuarded, + buildReadEdgeClaimIncumbents, + buildTakeOverEdgeClaim, + buildTakeOverEdgeClaimGuarded, } from "../src/backend/drizzle/operations/edge-claims"; import type { Tables } from "../src/backend/drizzle/operations/shared"; +import { NODE_ROW_LOCK_CLAUSES } from "../src/backend/drizzle/operations/strategy"; import { tables as postgresTables } from "../src/backend/drizzle/schema/postgres"; import { tables as sqliteTables } from "../src/backend/drizzle/schema/sqlite"; -import type { ClaimEdgeCardinalityParams } from "../src/backend/types"; +import type { + ClaimEdgeCardinalityParams, + CompositionClaimScope, + SqlDialect, +} from "../src/backend/types"; const SCHEMA_FENCE = { graphId: "graph-1", expectedVersion: 1 } as const; const LOCK_CLAUSE = drizzleSql`FOR SHARE`; @@ -37,12 +47,19 @@ const FIXED_PARAM_COUNT = 3; /** The DELETE carries no timestamp — it only releases. */ const DELETE_FIXED_PARAM_COUNT = 2; +type SourceCardinality = Extract< + ClaimEdgeCardinalityParams, + Readonly<{ direction: "source" }> +>["cardinality"]; + function claim( index: number, - cardinality: ClaimEdgeCardinalityParams["cardinality"], + cardinality: SourceCardinality, + scope?: CompositionClaimScope, ): ClaimEdgeCardinalityParams { return { graphId: "graph-1", + direction: "source", cardinality, edgeKind: "worksAt", edgeId: `edge-${index}`, @@ -50,12 +67,30 @@ function claim( fromId: `person-${index}`, toKind: "Company", toId: `company-${index}`, + ...(scope === undefined ? {} : { scope }), }; } +/** The same claim on the TARGET population: a different predicate shape. */ +function targetClaim(index: number): ClaimEdgeCardinalityParams { + return { ...claim(index, "one"), direction: "target", cardinality: "one" }; +} + +/** + * A composition claim's scope: the oriented realizing edge kinds whose live + * rows can hold the relation-wide axis. + */ +const COMPOSITION_SCOPE: CompositionClaimScope = { + kind: "composition", + holders: [ + { edgeKind: "worksAt", partSide: "from" }, + { edgeKind: "includedIn", partSide: "to" }, + ], +}; + function claims( count: number, - cardinality: ClaimEdgeCardinalityParams["cardinality"], + cardinality: SourceCardinality, ): readonly ClaimEdgeCardinalityParams[] { return Array.from({ length: count }, (_unused, index) => claim(index, cardinality), @@ -67,6 +102,10 @@ const DIALECTS = [ ["SQLite", sqliteTables, new SQLiteSyncDialect()], ] as const; +function dialectOf(tables: Tables): SqlDialect { + return tables === postgresTables ? "postgres" : "sqlite"; +} + function render( dialect: PgDialect | SQLiteSyncDialect, statement: SQL, @@ -123,6 +162,45 @@ const BUILDERS: readonly (readonly [string, ClaimStatementBuilder, number])[] = ], ]; +type SingleRowClaimStatementBuilder = ( + tables: Tables, + params: ClaimEdgeCardinalityParams, +) => SQL; + +const SINGLE_ROW_BUILDERS: readonly (readonly [ + string, + SingleRowClaimStatementBuilder, +])[] = [ + [ + "buildLockEdgeClaimGuarded", + (tables, params) => + buildLockEdgeClaimGuarded(tables, dialectOf(tables), params, TIMESTAMP), + ], + [ + "buildTakeOverEdgeClaim", + (tables, params) => buildTakeOverEdgeClaim(tables, params, TIMESTAMP), + ], + [ + "buildTakeOverEdgeClaimGuarded", + (tables, params) => + buildTakeOverEdgeClaimGuarded(tables, params, TIMESTAMP), + ], +]; + +/** + * How a rendered statement matches a claim's holders: the number of oriented + * `kind IN (...)` arms, and whether the scope's OTHER realizing kind + * (`includedIn` — never the claim's own `worksAt`) is bound at all. + */ +function compositionHolderShape( + rendered: Readonly<{ sql: string; params: readonly unknown[] }>, +): Readonly<{ armPairs: number; crossKindHolderBound: boolean }> { + return { + armPairs: rendered.sql.match(/"kind" IN \(/g)?.length ?? 0, + crossKindHolderBound: rendered.params.includes("includedIn"), + }; +} + describe.each(DIALECTS)( "atomic edge claim statements on %s", (_dialectName, tables, dialect) => { @@ -159,7 +237,7 @@ describe.each(DIALECTS)( } }); - it("renders one statement per cardinality group, keyed by its own spec", () => { + it("renders one statement per predicate shape, keyed by its own spec", () => { const statements = build(tables, [ claim(0, "one"), claim(1, "unique"), @@ -187,6 +265,41 @@ describe.each(DIALECTS)( expect(uniqueGroup.params).toContain("unique:worksAt"); expect(oneActiveGroup.params).toContain("oneActive:worksAt"); }); + + /** + * The spec is read off the axis NAME, so the same cardinality on the two + * populations asks for different endpoint terms. Grouping on the bare + * cardinality would put both in one statement and fence the target + * population on the source endpoint. + */ + it("splits the same cardinality on the two populations", () => { + const statements = build(tables, [claim(0, "one"), targetClaim(1)]); + expect(statements).toHaveLength(2); + }); + + /** + * A composition claim's holders are predicate shape, not values, so a + * composition-scoped row cannot share a statement with an ordinary one — + * and two rows carrying the same scope must still share ONE. + */ + it("splits a composition scope from the ordinary shape and folds its peers", () => { + const statements = build(tables, [ + claim(0, "one"), + claim(1, "one", COMPOSITION_SCOPE), + claim(2, "one", COMPOSITION_SCOPE), + ]); + expect(statements).toHaveLength(2); + + const [ordinary, composition] = statements.map((statement) => + render(dialect, statement), + ); + if (ordinary === undefined || composition === undefined) { + throw new Error("missing group"); + } + expect(ordinary.params).toContain("edge-0"); + expect(composition.params).toContain("edge-1"); + expect(composition.params).toContain("edge-2"); + }); }); /** @@ -219,6 +332,11 @@ describe.each(DIALECTS)( LOCK_CLAUSE, ), ], + [ + "buildReadEdgeClaimIncumbents", + (entries: readonly ClaimEdgeCardinalityParams[]) => + buildReadEdgeClaimIncumbents(tables, dialectOf(tables), entries), + ], ] as const)("%s spells each group's own spec", (_name, build) => { it.each([ ["one", false, false], @@ -238,6 +356,134 @@ describe.each(DIALECTS)( }).toEqual({ keyedOnTarget, activeOnly }); }, ); + + /** + * A composition group's holder predicate is the oriented two-arm OR, the + * one `claimHolderTerms` renders for every layer, and it is rendered ONCE + * for the group rather than per row — the whole point of carrying the + * scope in the group key instead of in a guard column. + */ + it("renders the composition scope's oriented arms once per group", () => { + const [statement] = build([ + claim(0, "one", COMPOSITION_SCOPE), + claim(1, "one", COMPOSITION_SCOPE), + ]); + if (statement === undefined) throw new Error("no statement"); + const { sql: statementSql, params } = render(dialect, statement); + expect({ + fromArm: statementSql.includes( + '"from_kind" = "proposed"."from_kind"', + ), + toArm: statementSql.includes('"to_kind" = "proposed"."from_kind"'), + armPairs: statementSql.match(/"kind" IN \(/g)?.length ?? 0, + holderKinds: params.filter((parameter) => parameter === "includedIn") + .length, + }).toEqual({ + fromArm: true, + toArm: true, + armPairs: 2, + holderKinds: 1, + }); + }); + }); + /** + * The single-row statements bind their values rather than reading them + * off `proposed`, but a composition claim's holder predicate must still be + * the oriented two-arm OR: without the scope it degrades to + * `kind = `, and a second whole attached through a + * DIFFERENT realizing edge kind is no longer a competing holder. + */ + describe.each(SINGLE_ROW_BUILDERS)( + "%s (single row)", + (_builderName, build) => { + it("renders the composition scope's oriented arms, so a cross-kind whole competes", () => { + expect( + compositionHolderShape( + render( + dialect, + build(tables, claim(0, "one", COMPOSITION_SCOPE)), + ), + ), + ).toEqual({ armPairs: 2, crossKindHolderBound: true }); + }); + + it("renders an ordinary claim's holder as its own edge kind", () => { + expect( + compositionHolderShape( + render(dialect, build(tables, claim(0, "one"))), + ), + ).toEqual({ armPairs: 0, crossKindHolderBound: false }); + }); + }, + ); + }, +); + +/** + * The PostgreSQL fused create (`claimable_axis`) decides the same axis in one + * statement, so it carries the same holder predicate. + */ +describe("buildInsertEdgeIfEndpointsLiveWithCardinalityClaim on PostgreSQL", () => { + it("renders the composition scope's oriented arms, so a cross-kind whole competes", () => { + const composition = claim(0, "one", COMPOSITION_SCOPE); + const statement = buildInsertEdgeIfEndpointsLiveWithCardinalityClaim( + postgresTables, + { + graphId: composition.graphId, + id: composition.edgeId, + kind: composition.edgeKind, + fromKind: composition.fromKind, + fromId: composition.fromId, + toKind: composition.toKind, + toId: composition.toId, + props: {}, + }, + composition, + TIMESTAMP, + NODE_ROW_LOCK_CLAUSES.postgres.edgeEndpoint, + ); + expect(compositionHolderShape(render(new PgDialect(), statement))).toEqual({ + armPairs: 2, + crossKindHolderBound: true, + }); + }); +}); + +function requireClaim( + entries: readonly ClaimEdgeCardinalityParams[], +): ClaimEdgeCardinalityParams { + const [first] = entries; + if (first === undefined) throw new Error("no claim"); + return first; +} + +describe.each(DIALECTS)( + "incumbent order on %s", + (_dialectName, tables, dialect) => { + it.each([ + [ + "buildReadEdgeClaimIncumbents", + (entries: readonly ClaimEdgeCardinalityParams[]) => + buildReadEdgeClaimIncumbents(tables, dialectOf(tables), entries)[0], + ], + [ + "buildLockEdgeClaimGuarded", + (entries: readonly ClaimEdgeCardinalityParams[]) => + buildLockEdgeClaimGuarded( + tables, + dialectOf(tables), + requireClaim(entries), + TIMESTAMP, + ), + ], + ] as const)("%s orders by code point", (_name, build) => { + const statement = build([claim(0, "one")]); + if (statement === undefined) throw new Error("no statement"); + const rendered = render(dialect, statement).sql; + + expect(rendered.includes('COLLATE "C"')).toBe( + dialectOf(tables) === "postgres", + ); }); }, ); diff --git a/packages/typegraph/tests/atomic-edge-convergence.test.ts b/packages/typegraph/tests/atomic-edge-convergence.test.ts index 29b61a8db..1865e6709 100644 --- a/packages/typegraph/tests/atomic-edge-convergence.test.ts +++ b/packages/typegraph/tests/atomic-edge-convergence.test.ts @@ -17,6 +17,7 @@ import { import { deriveBackend } from "../src/backend/derive-backend"; import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; import { createLibsqlBackend } from "../src/backend/sqlite/libsql"; +import { buildKindRegistry } from "../src/registry"; import { createStoreWithSchema, type Store } from "../src/store"; import { resolveAtomicEdgeConvergenceExecutor } from "../src/store/operations/atomic-mutation-program"; import { requireDefined } from "../src/utils/presence"; @@ -308,6 +309,7 @@ describe("atomic durable bulk edge convergence", () => { await withLibsqlStore(async (_store, _client, backend) => { const common = { backend, + registry: buildKindRegistry(durableGraph), schemaVersion: 1, historyEnabled: false, revisionTrackingEnabled: false, @@ -390,6 +392,7 @@ describe("atomic durable bulk edge convergence", () => { resolveAtomicEdgeConvergenceExecutor({ backend, graph: durableGraph, + registry: buildKindRegistry(durableGraph), schemaVersion: 1, historyEnabled, revisionTrackingEnabled, diff --git a/packages/typegraph/tests/atomic-edge-target-cardinality.test.ts b/packages/typegraph/tests/atomic-edge-target-cardinality.test.ts new file mode 100644 index 000000000..d20c000a3 --- /dev/null +++ b/packages/typegraph/tests/atomic-edge-target-cardinality.test.ts @@ -0,0 +1,118 @@ +/** + * The silent-bypass guard for a target-only-constrained edge kind (issue + * #610). Highest-value test in the target-cardinality change: every fast-path + * eligibility gate that reads `cardinality === "many"` (single-write, bulk + * batch, atomic-resolved-update, convergence) must be blind to NOTHING — a + * kind declared `{cardinality: "many", targetCardinality: "one"}` still owes + * a claim, so none of those gates may accept it into a program that writes no + * claim. + * + * PGlite (a real PostgreSQL engine) rather than SQLite: the fused/atomic + * command ports these gates protect are a PostgreSQL-only optimization + * (`edgeCardinalityInsertFusion`), so only this engine can prove a claim + * statement was actually issued. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { CardinalityError, defineEdge, defineGraph, defineNode } from "../src"; +import { createRecordedPostgresStore } from "./statement-recorder"; + +const Owner = defineNode("AebOwner", { schema: z.object({}) }); +const Asset = defineNode("AebAsset", { schema: z.object({}) }); + +// Deliberately `cardinality: "many"` (the DEFAULT, unconstrained source) with +// `targetCardinality: "one"` — the exact shape a bare `!== "many"` check +// reads as unconstrained. +const owns = defineEdge("aebOwns", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "atomic_edge_target_cardinality", + nodes: { AebOwner: { type: Owner }, AebAsset: { type: Asset } }, + edges: { + aebOwns: { + type: owns, + from: [Owner], + to: [Asset], + targetCardinality: "one", + }, + }, +}); + +function claimStatements(queries: readonly string[]): readonly string[] { + return queries.filter((query) => + /insert into "typegraph_edge_claims"/iu.test(query), + ); +} + +describe("target-only-constrained edge kind never takes a claim-free fast path", () => { + it("issues a claim statement on the single-write path, and refuses a second incoming edge", async () => { + const fixture = await createRecordedPostgresStore(graph); + const owner1 = await fixture.store.nodes.AebOwner.create({}); + const owner2 = await fixture.store.nodes.AebOwner.create({}); + const asset = await fixture.store.nodes.AebAsset.create({}); + + fixture.reset(); + await fixture.store.edges.aebOwns.create(owner1, asset, {}); + const statements = fixture.statements.map((statement) => statement.query); + expect(claimStatements(statements).length).toBeGreaterThan(0); + + await expect( + fixture.store.edges.aebOwns.create(owner2, asset, {}), + ).rejects.toBeInstanceOf(CardinalityError); + }); + // MUTATION CHECK (verified): in `validateAndPrepareEdgeCreate` + // (`src/store/operations/edge-operations.ts`), replace + // `const declarations: EdgeCardinalityDeclarations = registration;` with + // `{ cardinality: registration.cardinality ?? "many" }` (i.e. blind to + // `targetCardinality`, the source-only shape). `claimStatements(statements) + // .length` then drops to 0 and the first assertion above fails — this one + // mutation also fails the next two tests, since all three share this + // construction site. + + it("issues a claim statement on the bulk-create path, refusing the second of two targeting one asset", async () => { + const fixture = await createRecordedPostgresStore(graph); + const owner1 = await fixture.store.nodes.AebOwner.create({}); + const owner2 = await fixture.store.nodes.AebOwner.create({}); + const asset1 = await fixture.store.nodes.AebAsset.create({}); + const asset2 = await fixture.store.nodes.AebAsset.create({}); + + fixture.reset(); + const results = await fixture.store.edges.aebOwns.bulkCreate([ + { from: owner1, to: asset1, props: {} }, + { from: owner2, to: asset2, props: {} }, + ]); + expect(results).toHaveLength(2); + const statements = fixture.statements.map((statement) => statement.query); + expect(claimStatements(statements).length).toBeGreaterThan(0); + + await expect( + fixture.store.edges.aebOwns.create(owner2, asset1, {}), + ).rejects.toBeInstanceOf(CardinalityError); + }); + // Covered by the same mutation above (verified): both assertions fail. + + it("re-probes on resurrection: a target-one asset freed by a hard delete is takeable, and blocked once retaken", async () => { + const fixture = await createRecordedPostgresStore(graph); + const owner1 = await fixture.store.nodes.AebOwner.create({}); + const owner2 = await fixture.store.nodes.AebOwner.create({}); + const owner3 = await fixture.store.nodes.AebOwner.create({}); + const asset = await fixture.store.nodes.AebAsset.create({}); + + const first = await fixture.store.edges.aebOwns.create(owner1, asset, {}); + await expect( + fixture.store.edges.aebOwns.create(owner2, asset, {}), + ).rejects.toBeInstanceOf(CardinalityError); + + await fixture.store.edges.aebOwns.delete(first.id); + // The slot is genuinely freed by the delete, not merely "eligible for a + // program that skips the probe": a claim-free fast path would let BOTH + // of the next two writes land. + await fixture.store.edges.aebOwns.create(owner2, asset, {}); + await expect( + fixture.store.edges.aebOwns.create(owner3, asset, {}), + ).rejects.toBeInstanceOf(CardinalityError); + }); + // Covered by the same mutation above (verified): `owner2`'s create after + // the resurrection also wrongly succeeds instead of refusing. +}); diff --git a/packages/typegraph/tests/atomic-generated-edge-batch-backend.test.ts b/packages/typegraph/tests/atomic-generated-edge-batch-backend.test.ts index b82176420..da9d62c2f 100644 --- a/packages/typegraph/tests/atomic-generated-edge-batch-backend.test.ts +++ b/packages/typegraph/tests/atomic-generated-edge-batch-backend.test.ts @@ -40,6 +40,7 @@ function edgeClaim( ): ClaimEdgeCardinalityParams { return { graphId: params.graphId, + direction: "source", cardinality, edgeKind: params.kind, edgeId: params.id, diff --git a/packages/typegraph/tests/atomic-generated-edge-batch-pglite.test.ts b/packages/typegraph/tests/atomic-generated-edge-batch-pglite.test.ts index 99276d72e..832e9cf6b 100644 --- a/packages/typegraph/tests/atomic-generated-edge-batch-pglite.test.ts +++ b/packages/typegraph/tests/atomic-generated-edge-batch-pglite.test.ts @@ -18,7 +18,10 @@ import { tables } from "../src/backend/drizzle/schema/postgres"; import { createLocalPgliteBackend } from "../src/backend/postgres/pglite"; import { defineEdge, defineGraph, defineNode } from "../src/core"; import { createStoreWithSchema } from "../src/store"; -import { edgeCardinalityClaim } from "../src/store/claims/edge-claims"; +import { + edgeCardinalityAxisReferences, + edgeCardinalityClaims, +} from "../src/store/claims/edge-claims"; import { requireDefined } from "../src/utils/presence"; const Person = defineNode("Person", { @@ -79,7 +82,12 @@ describe("schema-fenced edge batches on a real PostgreSQL engine", () => { toId, props: { role: id }, } as const; - const claim = requireDefined(edgeCardinalityClaim("one", params)); + const claim = requireDefined( + edgeCardinalityClaims( + edgeCardinalityAxisReferences({ cardinality: "one" }), + params, + )[0], + ); const statements = [ ...buildDeleteStaleAtomicEdgeClaims( tables, diff --git a/packages/typegraph/tests/atomic-generated-edge-batch.test.ts b/packages/typegraph/tests/atomic-generated-edge-batch.test.ts index 90b0b1834..1575a0acb 100644 --- a/packages/typegraph/tests/atomic-generated-edge-batch.test.ts +++ b/packages/typegraph/tests/atomic-generated-edge-batch.test.ts @@ -10,6 +10,7 @@ import { CardinalityError, DatabaseOperationError, EdgeMatchIdentityConflictError, + partOf, StaleVersionError, ValidationError, } from "../src"; @@ -29,6 +30,7 @@ import { createLibsqlBackend } from "../src/backend/sqlite/libsql"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; import type { GraphBackend } from "../src/backend/types"; import { defineEdge, defineGraph, defineNode } from "../src/core"; +import { buildKindRegistry } from "../src/registry"; import { migrateSchema } from "../src/schema"; import type { Store } from "../src/store"; import { createStoreWithSchema, createVerifiedStore } from "../src/store"; @@ -199,6 +201,7 @@ describe("generated edge batch store consumer", () => { }); const common = { graph, + registry: buildKindRegistry(graph), expectedKind: "worksAt", ids: ["edge-1"], schemaVersion: 1, @@ -245,6 +248,116 @@ describe("generated edge batch store consumer", () => { ).toBeUndefined(); }); + it("declines a composition edge kind realizing a required-existence part", () => { + // `assertCompositionExistencePreserved` reads the part row under the + // held write lock — a decision this read-free fused command cannot + // express, so a required-existence composition edge kind must take the + // portable delete path. Built directly against a marked-eligible root: + // the store's ordinary edge-delete paths never reach this resolver for + // ANY composition edge kind in the first place (`compositionAcyclicRelation` + // already excludes it), so only a direct call proves this guard adds + // anything beyond that exclusion. + const AedPart = defineNode("AedPart", { schema: z.object({}) }); + const AedWhole = defineNode("AedWhole", { schema: z.object({}) }); + const aedPartOf = defineEdge("aedPartOf", { schema: z.object({}) }); + const requiredGraph = defineGraph({ + id: "atomic-generated-edge-batch-composition-required-delete", + nodes: { AedPart: { type: AedPart }, AedWhole: { type: AedWhole } }, + edges: { + aedPartOf: { + type: aedPartOf, + from: [AedPart], + to: [AedWhole], + cardinality: "one", + }, + }, + ontology: [ + partOf(AedPart, AedWhole, { via: aedPartOf, existence: "required" }), + ], + }); + + const backend = { + capabilities: { + execution: { interactiveTransactions: false, atomicBatch: "root" }, + }, + } as GraphBackend; + markBundledRootAutocommitEligible(backend); + markBundledRootAtomicMutationPrograms(backend, { + deleteEdges: (deleteInput) => + Promise.resolve({ + affectedCount: deleteInput.ids.length, + schemaFenceMatched: true, + }), + }); + + expect( + resolveAtomicEdgeDeleteBatchExecutor({ + backend, + graph: requiredGraph, + registry: buildKindRegistry(requiredGraph), + expectedKind: "aedPartOf", + ids: ["edge-1"], + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + // MUTATION CHECK: delete the + // `compositionEdgeHasRequiredExistencePart(...)` guard in + // `resolveAtomicEdgeDeleteBatchExecutor` + // (src/store/operations/atomic-mutation-program.ts). This assertion then + // fails. + + it("accepts a composition edge kind whose part is optional-existence", () => { + const AedOptPart = defineNode("AedOptPart", { schema: z.object({}) }); + const AedOptWhole = defineNode("AedOptWhole", { schema: z.object({}) }); + const aedOptPartOf = defineEdge("aedOptPartOf", { schema: z.object({}) }); + const optionalGraph = defineGraph({ + id: "atomic-generated-edge-batch-composition-optional-delete", + nodes: { + AedOptPart: { type: AedOptPart }, + AedOptWhole: { type: AedOptWhole }, + }, + edges: { + aedOptPartOf: { + type: aedOptPartOf, + from: [AedOptPart], + to: [AedOptWhole], + cardinality: "one", + }, + }, + ontology: [partOf(AedOptPart, AedOptWhole, { via: aedOptPartOf })], + }); + + const backend = { + capabilities: { + execution: { interactiveTransactions: false, atomicBatch: "root" }, + }, + } as GraphBackend; + markBundledRootAutocommitEligible(backend); + markBundledRootAtomicMutationPrograms(backend, { + deleteEdges: (deleteInput) => + Promise.resolve({ + affectedCount: deleteInput.ids.length, + schemaFenceMatched: true, + }), + }); + + expect( + resolveAtomicEdgeDeleteBatchExecutor({ + backend, + graph: optionalGraph, + registry: buildKindRegistry(optionalGraph), + expectedKind: "aedOptPartOf", + ids: ["edge-1"], + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeDefined(); + }); + it("selects the atomic executor only for the exact marked root", async () => { const backend = { capabilities: { @@ -271,6 +384,7 @@ describe("generated edge batch store consumer", () => { resolveAtomicEdgeBatchExecutor({ backend, graph, + registry: buildKindRegistry(graph), inputs: [input], schemaVersion: 1, historyEnabled: false, @@ -281,6 +395,7 @@ describe("generated edge batch store consumer", () => { resolveAtomicEdgeBatchExecutor({ backend: deriveBackend(backend, {}), graph, + registry: buildKindRegistry(graph), inputs: [input], schemaVersion: 1, historyEnabled: false, @@ -298,6 +413,7 @@ describe("generated edge batch store consumer", () => { resolveAtomicEdgeBatchExecutor({ backend: transactionBackend, graph, + registry: buildKindRegistry(graph), inputs: [input], schemaVersion: 1, historyEnabled: false, @@ -313,6 +429,72 @@ describe("generated edge batch store consumer", () => { } }); + it("excludes a composition edge kind even when the mark and the registration would otherwise be eligible", () => { + // A composition edge kind declares exactly ONE ordinary cardinality + // axis, same as `cardinalityGraph` below — the `length > 1` two-axis + // exclusion alone cannot see the SECOND claim `compositionClaim` adds. + // No separate exclusion is needed: `compositionAcyclicRelation` + // (src/store/acyclicity.ts) folds EVERY composition-realizing edge kind + // into the composition relation's union the moment any `partOf`/`hasPart` pair exists, so + // `edgeKindIsInAcyclicRelation` already answers `true` here — the exact + // mechanism `tests/backends/integration/composition-fence.ts`'s + // acyclicity cases already mutation-check. + const AmpPart = defineNode("AmpPart", { schema: z.object({}) }); + const AmpWhole = defineNode("AmpWhole", { schema: z.object({}) }); + const ampPartOf = defineEdge("ampPartOf", { schema: z.object({}) }); + const compositionGraph = defineGraph({ + id: "atomic-generated-edge-batch-composition", + nodes: { AmpPart: { type: AmpPart }, AmpWhole: { type: AmpWhole } }, + edges: { + ampPartOf: { + type: ampPartOf, + from: [AmpPart], + to: [AmpWhole], + cardinality: "one", + }, + }, + ontology: [partOf(AmpPart, AmpWhole, { via: ampPartOf })], + }); + + const backend = { + capabilities: { + execution: { interactiveTransactions: false, atomicBatch: "root" }, + }, + } as GraphBackend; + const executor = vi.fn(() => + Promise.resolve(1), + ) as unknown as AtomicEdgeBatchExecutor; + markBundledRootAutocommitEligible(backend); + declareAtomicBatchForTest(backend); + markBundledRootAtomicEdgeBatch(backend, executor); + + expect( + resolveAtomicEdgeBatchExecutor({ + backend, + graph: compositionGraph, + registry: buildKindRegistry(compositionGraph), + inputs: [ + { + kind: "ampPartOf", + fromKind: "AmpPart", + fromId: "part-1", + toKind: "AmpWhole", + toId: "whole-1", + props: {}, + }, + ], + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + // MUTATION CHECK (verified, reverted): narrow `compositionAcyclicRelation` + // (src/store/acyclicity.ts) to return `undefined` unconditionally. This + // test then resolves the marked executor instead of `undefined` — the + // same mutation `tests/backends/integration/composition-fence.ts`'s + // "refuses a cross-kind cycle" case already reverts. + it.each([ ["one cardinality", cardinalityGraph], ["unique cardinality", uniqueCardinalityGraph], @@ -339,6 +521,7 @@ describe("generated edge batch store consumer", () => { resolveAtomicEdgeBatchExecutor({ backend, graph: constrainedGraph, + registry: buildKindRegistry(constrainedGraph), inputs: [ { kind: "worksAt", diff --git a/packages/typegraph/tests/atomic-node-batch-eligibility.test.ts b/packages/typegraph/tests/atomic-node-batch-eligibility.test.ts index 7a0e670b1..6cd2d685c 100644 --- a/packages/typegraph/tests/atomic-node-batch-eligibility.test.ts +++ b/packages/typegraph/tests/atomic-node-batch-eligibility.test.ts @@ -18,8 +18,14 @@ import { deriveBackend } from "../src/backend/derive-backend"; import { createLibsqlBackend } from "../src/backend/sqlite/libsql"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; import type { GraphBackend } from "../src/backend/types"; -import { defineGraph, defineNode, embedding, searchable } from "../src/core"; -import { disjointWith, subClassOf } from "../src/ontology"; +import { + defineEdge, + defineGraph, + defineNode, + embedding, + searchable, +} from "../src/core"; +import { disjointWith, partOf, subClassOf } from "../src/ontology"; import { buildKindRegistry } from "../src/registry"; import { migrateSchema } from "../src/schema"; import { createStoreWithSchema } from "../src/store"; @@ -27,6 +33,7 @@ import { assertAtomicDeleteSchemaFenceMatched, resolveAtomicNodeBatchExecutor, resolveAtomicNodeDeleteBatchExecutor, + resolveAtomicNodeReplacementBatchProgram, } from "../src/store/operations/atomic-mutation-program"; import type { CreateNodeInput } from "../src/store/types"; @@ -904,3 +911,166 @@ describe("atomic node batch store consumer", () => { } }); }); + +// ============================================================ +// The composition gates on the fused node-create programs. +// +// `resolveAtomicNodeBatchExecutor` and `resolveAtomicNodeReplacementBatchProgram` +// each decline (return `undefined`) for a composition dimension a fused +// node-create program has no shape for: a stated `partOf`, or a kind whose +// declared existence is `"required"`. Exercised directly against a backend +// marked eligible for the fused path — the ONLY way to prove the guard +// itself fires, since the store's ordinary create paths never reach the +// fused executor for a composition create in the first place (there is no +// shape for the second row either way), which is exactly what let a +// deleted guard clause pass the full composition-existence integration +// suite unnoticed. +// ============================================================ + +const CompPart = defineNode("CompPart", { schema: z.object({}) }); +const CompWhole = defineNode("CompWhole", { schema: z.object({}) }); +const compPartOf = defineEdge("compPartOf", { schema: z.object({}) }); + +const compositionRequiredGraph = defineGraph({ + id: "atomic-node-batch-composition-required", + nodes: { CompPart: { type: CompPart }, CompWhole: { type: CompWhole } }, + edges: { + compPartOf: { + type: compPartOf, + from: [CompPart], + to: [CompWhole], + cardinality: "one", + }, + }, + ontology: [ + partOf(CompPart, CompWhole, { via: compPartOf, existence: "required" }), + ], +}); + +const compositionOptionalGraph = defineGraph({ + id: "atomic-node-batch-composition-optional", + nodes: { CompPart: { type: CompPart }, CompWhole: { type: CompWhole } }, + edges: compositionRequiredGraph.edges, + ontology: [partOf(CompPart, CompWhole, { via: compPartOf })], +}); + +describe("atomic node batch eligibility: composition gate", () => { + it("resolveAtomicNodeBatchExecutor declines a required-existence kind with no partOf", () => { + const backend = rootBackend(false); + markAtomicRoot(backend); + + expect( + resolveAtomicNodeBatchExecutor({ + backend, + graph: compositionRequiredGraph, + registry: buildKindRegistry(compositionRequiredGraph), + inputs: [{ kind: "CompPart", props: {} }], + schemaVersion: 1, + identityEnabled: false, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + // MUTATION CHECK: delete the `item.partOf !== undefined || ... === "required"` + // guard clause in `resolveAtomicNodeBatchExecutor` + // (src/store/operations/atomic-mutation-program.ts). This assertion then + // fails (`toBeDefined()` instead). + + it("resolveAtomicNodeBatchExecutor declines an optional-existence kind that states partOf", () => { + const backend = rootBackend(false); + markAtomicRoot(backend); + + expect( + resolveAtomicNodeBatchExecutor({ + backend, + graph: compositionOptionalGraph, + registry: buildKindRegistry(compositionOptionalGraph), + inputs: [ + { + kind: "CompPart", + props: {}, + partOf: { whole: { kind: "CompWhole", id: "whole-1" } }, + }, + ], + schemaVersion: 1, + identityEnabled: false, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + + it("resolveAtomicNodeBatchExecutor accepts an optional-existence composition kind with no partOf", () => { + const backend = rootBackend(false); + markAtomicRoot(backend); + + expect( + resolveAtomicNodeBatchExecutor({ + backend, + graph: compositionOptionalGraph, + registry: buildKindRegistry(compositionOptionalGraph), + inputs: [{ kind: "CompPart", props: {} }], + schemaVersion: 1, + identityEnabled: false, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeDefined(); + }); + + it("resolveAtomicNodeReplacementBatchProgram declines a required-existence kind", () => { + const backend = rootBackend(false); + declareAtomicBatchForTest(backend); + markBundledRootAutocommitEligible(backend); + markBundledRootAtomicMutationPrograms(backend, { + replaceNodes: Object.assign(() => Promise.resolve([]), { + maxEntries: { plain: 512, claimed: 128 }, + }), + }); + + expect( + resolveAtomicNodeReplacementBatchProgram({ + backend, + graph: compositionRequiredGraph, + registry: buildKindRegistry(compositionRequiredGraph), + kind: "CompPart", + entryCount: 1, + identityEnabled: false, + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + // MUTATION CHECK: delete the + // `if (input.registry.compositionExistence(input.kind) === "required") return;` + // guard in `resolveAtomicNodeReplacementBatchProgram` + // (src/store/operations/atomic-mutation-program.ts). This assertion then + // fails. + + it("resolveAtomicNodeReplacementBatchProgram accepts an optional-existence composition kind", () => { + const backend = rootBackend(false); + declareAtomicBatchForTest(backend); + markBundledRootAutocommitEligible(backend); + markBundledRootAtomicMutationPrograms(backend, { + replaceNodes: Object.assign(() => Promise.resolve([]), { + maxEntries: { plain: 512, claimed: 128 }, + }), + }); + + expect( + resolveAtomicNodeReplacementBatchProgram({ + backend, + graph: compositionOptionalGraph, + registry: buildKindRegistry(compositionOptionalGraph), + kind: "CompPart", + entryCount: 1, + identityEnabled: false, + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeDefined(); + }); +}); diff --git a/packages/typegraph/tests/atomic-resolved-update-batch.test.ts b/packages/typegraph/tests/atomic-resolved-update-batch.test.ts index 7295d20fc..0f7f19868 100644 --- a/packages/typegraph/tests/atomic-resolved-update-batch.test.ts +++ b/packages/typegraph/tests/atomic-resolved-update-batch.test.ts @@ -24,10 +24,11 @@ import { NODE_ROW_LOCK_CLAUSES } from "../src/backend/drizzle/operations/strateg import { tables as sqliteTables } from "../src/backend/drizzle/schema/sqlite"; import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; import { createLibsqlBackend } from "../src/backend/sqlite/libsql"; +import type { GraphBackend } from "../src/backend/types"; import { rowPropsToObject } from "../src/backend/types"; import { defineEdge, defineGraph, defineNode } from "../src/core"; import { CompilerInvariantError, DatabaseOperationError } from "../src/errors"; -import { disjointWith } from "../src/ontology"; +import { disjointWith, partOf } from "../src/ontology"; import { buildKindRegistry } from "../src/registry"; import { createStoreWithSchema, createVerifiedStore } from "../src/store"; import { @@ -527,6 +528,7 @@ describe("atomic resolved update batches", () => { const common = { backend: budgetedBackend, graph, + registry: buildKindRegistry(graph), schemaVersion: 1, historyEnabled: false, revisionTrackingEnabled: false, @@ -1007,3 +1009,110 @@ describe("atomic resolved update batches", () => { ).resolves.toEqual(nodes); }); }); + +function markedAtomicMutationSetBackend(): GraphBackend { + const backend = { + capabilities: { execution: { atomicBatch: "root" } }, + } as GraphBackend; + markBundledRootAtomicMutationPrograms(backend, { + mutateNodes: Object.assign( + () => Promise.resolve({ created: [], updated: [] }), + { maxEntries: 512 }, + ), + }); + return backend; +} + +describe("resolveAtomicNodeResolvedMutationSetExecutor: composition gate", () => { + const ArmPart = defineNode("ArmPart", { schema: z.object({}) }); + const ArmWhole = defineNode("ArmWhole", { schema: z.object({}) }); + const armPartOf = defineEdge("armPartOf", { schema: z.object({}) }); + + const requiredGraph = defineGraph({ + id: "atomic-resolved-mutation-set-composition-required", + nodes: { ArmPart: { type: ArmPart }, ArmWhole: { type: ArmWhole } }, + edges: { + armPartOf: { + type: armPartOf, + from: [ArmPart], + to: [ArmWhole], + cardinality: "one", + }, + }, + ontology: [ + partOf(ArmPart, ArmWhole, { via: armPartOf, existence: "required" }), + ], + }); + const optionalGraph = defineGraph({ + id: "atomic-resolved-mutation-set-composition-optional", + nodes: requiredGraph.nodes, + edges: requiredGraph.edges, + ontology: [partOf(ArmPart, ArmWhole, { via: armPartOf })], + }); + + it("declines a required-existence create even with no `partOf`", () => { + const backend = markedAtomicMutationSetBackend(); + expect( + resolveAtomicNodeResolvedMutationSetExecutor({ + backend, + graph: requiredGraph, + registry: buildKindRegistry(requiredGraph), + kind: "ArmPart", + creates: [{ kind: "ArmPart", id: "part-1", props: {} }], + updateCount: 0, + identityEnabled: false, + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + + it("declines an optional-existence create that states `partOf`", () => { + const backend = markedAtomicMutationSetBackend(); + expect( + resolveAtomicNodeResolvedMutationSetExecutor({ + backend, + graph: optionalGraph, + registry: buildKindRegistry(optionalGraph), + kind: "ArmPart", + creates: [ + { + kind: "ArmPart", + id: "part-1", + props: {}, + partOf: { whole: { kind: "ArmWhole", id: "whole-1" } }, + }, + ], + updateCount: 0, + identityEnabled: false, + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeUndefined(); + }); + // MUTATION CHECK (both cases above): delete the + // `item.partOf !== undefined || ... === "required"` guard in + // `resolveAtomicNodeResolvedMutationSetExecutor` + // (src/store/operations/atomic-mutation-program.ts). Both assertions then + // fail (`toBeDefined()` instead). + + it("accepts an optional-existence create with no `partOf`", () => { + const backend = markedAtomicMutationSetBackend(); + expect( + resolveAtomicNodeResolvedMutationSetExecutor({ + backend, + graph: optionalGraph, + registry: buildKindRegistry(optionalGraph), + kind: "ArmPart", + creates: [{ kind: "ArmPart", id: "part-1", props: {} }], + updateCount: 0, + identityEnabled: false, + schemaVersion: 1, + historyEnabled: false, + revisionTrackingEnabled: false, + }), + ).toBeDefined(); + }); +}); diff --git a/packages/typegraph/tests/backend-derivation-inventory.test.ts b/packages/typegraph/tests/backend-derivation-inventory.test.ts index f16115f96..eba8a3144 100644 --- a/packages/typegraph/tests/backend-derivation-inventory.test.ts +++ b/packages/typegraph/tests/backend-derivation-inventory.test.ts @@ -184,6 +184,18 @@ const INVENTORY: readonly InventoryEntry[] = [ reason: "In-batch cardinality accounting reads through the caller's write target, so the reads it issues must see that target's uncommitted rows.", }, + { + file: "store/operations/edge-batch-validation.ts", + line: "return deriveBackend(target, {", + reason: + "A reparent prepares its replacement edge before retiring the incumbent, so its cardinality reads go through the frame's own target with the retiring row already subtracted from the counts it holds.", + }, + { + file: "store/operations/edge-batch-validation.ts", + line: "const backend = deriveBackend(target, {", + reason: + "A batch decides every update, resurrection and window reopen before its first write, so each later item's cardinality and existence reads go through the frame's own target with the earlier items' transitions already applied to the counts.", + }, { file: "store/operations/node-operations.ts", line: "reader: deriveBackend(backend, reads),", diff --git a/packages/typegraph/tests/backends/differential-parity.test.ts b/packages/typegraph/tests/backends/differential-parity.test.ts index 19334de3d..5bc0f89af 100644 --- a/packages/typegraph/tests/backends/differential-parity.test.ts +++ b/packages/typegraph/tests/backends/differential-parity.test.ts @@ -28,12 +28,21 @@ * via PGlite (Postgres-in-WASM) — no Docker. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; import { + asNodeId, avg, count, createAdapterStoreWithSchema, + defineEdge, + defineGraph, + defineNode, field, + hasPart, + partOf, + type Store, + subClassOf, sum, } from "../../src"; import { createSqliteBackend } from "../../src/backend/sqlite"; @@ -117,13 +126,156 @@ async function seedParityCorpus(store: IntegrationStore): Promise { }); } +// ============================================================ +// Ontology graph — subclass, composition, qualified recursion, asOf +// ============================================================ + +const OpMedia = defineNode("OpMedia", { + schema: z.object({ title: z.string() }), +}); +const OpPodcast = defineNode("OpPodcast", { + schema: z.object({ title: z.string() }), +}); +const OpVideo = defineNode("OpVideo", { + schema: z.object({ title: z.string() }), +}); +const OpBook = defineNode("OpBook", { + schema: z.object({ title: z.string() }), +}); +const OpChapter = defineNode("OpChapter", { + schema: z.object({ title: z.string() }), +}); +const OpParagraph = defineNode("OpParagraph", { + schema: z.object({ title: z.string() }), +}); + +const opRelated = defineEdge("opRelated", { schema: z.object({}) }); +const opBookHasChapter = defineEdge("opBookHasChapter", { + schema: z.object({}), +}); +const opParagraphOf = defineEdge("opParagraphOf", { schema: z.object({}) }); + +const ontologyParityGraph = defineGraph({ + id: "differential_parity_ontology", + nodes: { + OpMedia: { type: OpMedia }, + OpPodcast: { type: OpPodcast }, + OpVideo: { type: OpVideo }, + OpBook: { type: OpBook }, + OpChapter: { type: OpChapter }, + OpParagraph: { type: OpParagraph }, + }, + edges: { + opRelated: { type: opRelated, from: [OpMedia], to: [OpMedia] }, + opBookHasChapter: { + type: opBookHasChapter, + from: [OpBook], + to: [OpChapter], + targetCardinality: "one", + }, + opParagraphOf: { + type: opParagraphOf, + from: [OpParagraph], + to: [OpChapter], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(OpPodcast, OpMedia), + subClassOf(OpVideo, OpMedia), + // Mixed orientation: the book names its chapters (whole -> part), each + // paragraph names its chapter (part -> whole). + hasPart(OpBook, OpChapter, { via: opBookHasChapter }), + partOf(OpParagraph, OpChapter, { via: opParagraphOf }), + ], +}); + +type OntologyStore = Store; + +const OP_EDGE_PAST_VALID_FROM = "2019-01-01T00:00:00.000Z"; +const OP_EDGE_PAST_VALID_TO = "2020-01-01T00:00:00.000Z"; +const OP_NODE_VALID_FROM = "2018-01-01T00:00:00.000Z"; +const OP_AS_OF_DURING_PAST = "2019-06-01T00:00:00.000Z"; +const OP_BOOK_ID = "op-book"; + +// Fixed ids keep qualified recursive paths and subgraph ids comparable across +// the two independently seeded stores. +async function seedOntologyCorpus(store: OntologyStore): Promise { + const generic = await store.nodes.OpMedia.create( + { title: "Generic" }, + { id: "op-media", validFrom: OP_NODE_VALID_FROM }, + ); + const podcast = await store.nodes.OpPodcast.create( + { title: "Pod" }, + { id: "op-podcast", validFrom: OP_NODE_VALID_FROM }, + ); + const video = await store.nodes.OpVideo.create( + { title: "Vid" }, + { id: "op-video", validFrom: OP_NODE_VALID_FROM }, + ); + const secondPodcast = await store.nodes.OpPodcast.create( + { title: "Pod Two" }, + { id: "op-podcast-2", validFrom: OP_NODE_VALID_FROM }, + ); + await store.edges.opRelated.create(generic, podcast, {}, { id: "rel-1" }); + await store.edges.opRelated.create(podcast, video, {}, { id: "rel-2" }); + await store.edges.opRelated.create(video, secondPodcast, {}, { id: "rel-3" }); + await store.edges.opRelated.create( + generic, + video, + {}, + { + id: "rel-past", + validFrom: OP_EDGE_PAST_VALID_FROM, + validTo: OP_EDGE_PAST_VALID_TO, + }, + ); + + await store.edges.opRelated.create( + podcast, + secondPodcast, + {}, + { + id: "rel-past-2", + validFrom: OP_EDGE_PAST_VALID_FROM, + validTo: OP_EDGE_PAST_VALID_TO, + }, + ); + + const book = await store.nodes.OpBook.create( + { title: "Book" }, + { id: OP_BOOK_ID }, + ); + const chapterOne = await store.nodes.OpChapter.create( + { title: "Ch1" }, + { id: "op-ch-1" }, + ); + const chapterTwo = await store.nodes.OpChapter.create( + { title: "Ch2" }, + { id: "op-ch-2" }, + ); + await store.edges.opBookHasChapter.create(book, chapterOne, {}); + await store.edges.opBookHasChapter.create(book, chapterTwo, {}); + for (const [index, chapter] of [ + chapterOne, + chapterOne, + chapterTwo, + ].entries()) { + const paragraph = await store.nodes.OpParagraph.create( + { title: `Para${index}` }, + { id: `op-para-${index}` }, + ); + await store.edges.opParagraphOf.create(paragraph, chapter, {}); + } +} + // ============================================================ // Corpus — read-only queries over the portable surface // ============================================================ -type ParityCase = Readonly<{ +type ParityCase = Readonly<{ name: string; - run: (store: IntegrationStore) => Promise; + run: (store: TStore) => Promise; /** Compare results in order (the query has a total order). Default: multiset. */ ordered?: boolean; }>; @@ -377,6 +529,106 @@ const CASES: readonly ParityCase[] = [ }, ]; +const ONTOLOGY_CASES: readonly ParityCase[] = [ + { + name: "ontology: polymorphic expansion of a superclass", + run: (store) => + store + .query() + .from("OpMedia", "m") + .select((ctx) => ({ kind: ctx.m.kind, title: ctx.m.title })) + .execute(), + }, + { + name: "ontology: parts() across mixed orientation (hasPart + partOf)", + run: (store) => + store + .query() + .from("OpBook", "b") + .whereNode("b", (book) => book.id.eq(OP_BOOK_ID)) + .parts("x", { depth: "level" }) + .select((ctx) => ({ + kind: ctx.x.kind, + id: ctx.x.id, + level: ctx.level, + })) + .execute(), + }, + { + name: "ontology: wholes() across mixed orientation (partOf + hasPart)", + run: (store) => + store + .query() + .from("OpParagraph", "p") + .wholes("w", { depth: "level" }) + .select((ctx) => ({ + part: ctx.p.title, + whole: ctx.w.id, + kind: ctx.w.kind, + level: ctx.level, + })) + .execute(), + }, + { + name: "ontology: composition subgraph of a whole", + run: async (store) => { + const book = await store.nodes.OpBook.getById( + asNodeId(OP_BOOK_ID), + ); + if (book === undefined) throw new Error("seed book missing"); + const result = await store.subgraph(book, { + edges: [], + composition: true, + }); + return result.nodes.map((node) => ({ kind: node.kind, id: node.id })); + }, + }, + { + name: "recursive: kind-qualified route over subclass nodes", + run: (store) => + store + .query() + .from("OpMedia", "root") + .whereNode("root", (media) => media.id.eq("op-media")) + .traverse("opRelated", "edge", { expand: "none" }) + .recursive({ + maxHops: 3, + path: { format: "qualified", alias: "route" }, + depth: true, + }) + .to("OpMedia", "target") + .select((ctx) => ({ + title: ctx.target.title, + depth: ctx.target_depth, + route: ctx.route, + })) + .execute(), + }, + { + name: "asOf: a past instant sees only the edges valid then", + run: (store) => + store + .asOf(OP_AS_OF_DURING_PAST) + .query() + .from("OpMedia", "m") + .traverse("opRelated", "e", { expand: "none" }) + .to("OpMedia", "t") + .select((ctx) => ({ from: ctx.m.title, to: ctx.t.title })) + .execute(), + }, + { + name: "asOf: a current read excludes the ended edges", + run: (store) => + store + .query() + .from("OpMedia", "m") + .traverse("opRelated", "e", { expand: "none" }) + .to("OpMedia", "t") + .select((ctx) => ({ from: ctx.m.title, to: ctx.t.title })) + .execute(), + }, +]; + // ============================================================ // Comparison // ============================================================ @@ -425,28 +677,70 @@ function canonical(rows: readonly unknown[]): string[] { // Suite // ============================================================ +async function expectBackendsAgree( + parityCase: ParityCase, + sqliteStore: TStore, + pgStore: TStore, +): Promise { + const [sqliteRows, pgRows] = await Promise.all([ + parityCase.run(sqliteStore), + parityCase.run(pgStore), + ]); + + // The corpus is intentionally all-non-empty: a result that is empty on + // either backend signals a seed/query mistake (and an empty-on-both case + // would otherwise pass vacuously). Check both backends, not just one. + expect(sqliteRows.length).toBeGreaterThan(0); + expect(pgRows.length).toBeGreaterThan(0); + + // Compare in order when the query has a total order; otherwise as a + // multiset (unordered queries may return rows in any order). + const sqliteCanonical = canonical(sqliteRows); + const pgCanonical = canonical(pgRows); + const [expected, actual] = + parityCase.ordered ? + [sqliteCanonical, pgCanonical] + : [sqliteCanonical.toSorted(), pgCanonical.toSorted()]; + + expect(actual).toEqual(expected); +} + describe("Differential backend parity (SQLite vs PGlite)", () => { let sqliteStore: IntegrationStore; let pgStore: IntegrationStore; + let sqliteOntologyStore: OntologyStore; + let pgOntologyStore: OntologyStore; let engine: SharedPgliteEngine; beforeAll(async () => { const { db } = createLocalSqliteBackend(); + const sqliteBackend = createSqliteBackend(db); [sqliteStore] = await createAdapterStoreWithSchema( integrationTestGraph, - createSqliteBackend(db), + sqliteBackend, + ); + [sqliteOntologyStore] = await createAdapterStoreWithSchema( + ontologyParityGraph, + sqliteBackend, ); engine = await setupSharedPgliteEngine(); + const pgBackend = engine.makeBackend(); [pgStore] = await createAdapterStoreWithSchema( integrationTestGraph, - engine.makeBackend(), + pgBackend, + ); + [pgOntologyStore] = await createAdapterStoreWithSchema( + ontologyParityGraph, + pgBackend, ); // Independent stores — seed concurrently. await Promise.all([ seedParityCorpus(sqliteStore), seedParityCorpus(pgStore), + seedOntologyCorpus(sqliteOntologyStore), + seedOntologyCorpus(pgOntologyStore), ]); }); @@ -456,27 +750,17 @@ describe("Differential backend parity (SQLite vs PGlite)", () => { for (const parityCase of CASES) { it(`agrees across backends — ${parityCase.name}`, async () => { - const [sqliteRows, pgRows] = await Promise.all([ - parityCase.run(sqliteStore), - parityCase.run(pgStore), - ]); - - // The corpus is intentionally all-non-empty: a result that is empty on - // either backend signals a seed/query mistake (and an empty-on-both case - // would otherwise pass vacuously). Check both backends, not just one. - expect(sqliteRows.length).toBeGreaterThan(0); - expect(pgRows.length).toBeGreaterThan(0); - - // Compare in order when the query has a total order; otherwise as a - // multiset (unordered queries may return rows in any order). - const sqliteCanonical = canonical(sqliteRows); - const pgCanonical = canonical(pgRows); - const [expected, actual] = - parityCase.ordered ? - [sqliteCanonical, pgCanonical] - : [sqliteCanonical.toSorted(), pgCanonical.toSorted()]; - - expect(actual).toEqual(expected); + await expectBackendsAgree(parityCase, sqliteStore, pgStore); + }); + } + + for (const parityCase of ONTOLOGY_CASES) { + it(`agrees across backends — ${parityCase.name}`, async () => { + await expectBackendsAgree( + parityCase, + sqliteOntologyStore, + pgOntologyStore, + ); }); } }); diff --git a/packages/typegraph/tests/backends/integration-test-suite.ts b/packages/typegraph/tests/backends/integration-test-suite.ts index dc0a0c455..4adbdc46e 100644 --- a/packages/typegraph/tests/backends/integration-test-suite.ts +++ b/packages/typegraph/tests/backends/integration-test-suite.ts @@ -34,6 +34,7 @@ import { registerAggregateIntegrationTests, registerAlgorithmIntegrationTests, registerBackendProvenanceIntegrationTests, + registerBatchWriteVerdictIntegrationTests, registerBulkFindByIndexIntegrationTests, registerBulkFindEndpointIntegrationTests, registerBulkFindHeterogeneousIntegrationTests, @@ -48,6 +49,14 @@ import { registerClearIntegrationTests, registerCoalesceUpsertIntegrationTests, registerCollectionAggregateIntegrationTests, + registerCompositionAttachmentIntegrationTests, + registerCompositionDeletePlanIntegrationTests, + registerCompositionExistenceIntegrationTests, + registerCompositionFenceIntegrationTests, + registerCompositionMembershipReadIntegrationTests, + registerCompositionNavigationIntegrationTests, + registerCompositionReadsModelIntegrationTests, + registerCompositionTighteningIntegrationTests, registerConstraintFenceErrorIntegrationTests, registerConstraintFenceTransactionHealthTests, registerConstraintFenceVerificationIntegrationTests, @@ -56,23 +65,34 @@ import { registerCurrentIdentityTraversalTests, registerDatabaseExpressionQueryIntegrationTests, registerDurableEdgeMatchIdentityIntegrationTests, + registerEdgeAcyclicityIntegrationTests, + registerEdgeCardinalityTighteningIntegrationTests, registerEdgeCaseIntegrationTests, registerEdgeClaimSelfHealIntegrationTests, registerEdgeOperationIntegrationTests, registerEdgePropertyIntegrationTests, + registerEdgeTargetCardinalityIntegrationTests, + registerEdgeTargetCardinalityTemporalIntegrationTests, registerExpressionSubqueryQueryIntegrationTests, registerFulltextIntegrationTests, registerGraphAnnotationsIntegrationTests, registerGraphMergeCallbackIntegrationTests, + registerGraphMergeCompositionIntegrationTests, registerGraphMergePlanIntegrationTests, registerGraphMergeReviewIntegrationTests, registerGraphMergeReviewV2IntegrationTests, + registerGraphMergeTargetCardinalityIntegrationTests, registerGraphStorageIntegrationTests, registerHistoricalIdentityTraversalTests, registerIdentityImportIntegrationTests, registerIdentityIntegrationTests, + registerIdentityReplayIntegrationTests, registerIdentitySeparationIntegrationTests, + registerImportDuplicateEdgeOrderIntegrationTests, + registerImportRequiredPartPurgeIntegrationTests, + registerImportTargetCardinalityIntegrationTests, registerImportUniquenessIntegrationTests, + registerKeysetPaginationPropertyTests, registerLateMaterializationIntegrationTests, registerLegacyClaimAxisIntegrationTests, registerLineageConformanceIntegrationTests, @@ -82,6 +102,9 @@ import { registerMultiKindQueryIntegrationTests, registerMultiRecursiveTraversalIntegrationTests, registerMultiRootSubgraphBatchIntegrationTests, + registerOntologyEquivalenceIntegrationTests, + registerOntologyTighteningIntegrationTests, + registerOntologyTypedSubsumptionIntegrationTests, registerOrderingIntegrationTests, registerPaginationIntegrationTests, registerPredicateIntegrationTests, @@ -114,6 +137,7 @@ import { registerTransactionReceiptIntegrationTests, registerTraversalIntegrationTests, registerTrustedImportIntegrationTests, + registerUniquenessClaimGateIntegrationTests, registerValidityEndClearingIntegrationTests, registerValidityLowerBoundIntegrationTests, registerWeightedShortestPathExtractionIntegrationTests, @@ -320,6 +344,13 @@ export function createIntegrationTestSuite< registerCapabilityPortBindingIntegrationTests(context); registerCapabilityRefusalIntegrationTests(context); registerCoalesceUpsertIntegrationTests(context); + registerCompositionAttachmentIntegrationTests(context); + registerCompositionDeletePlanIntegrationTests(context); + registerCompositionExistenceIntegrationTests(context); + registerCompositionFenceIntegrationTests(context); + registerCompositionMembershipReadIntegrationTests(context); + registerCompositionReadsModelIntegrationTests(context); + registerCompositionNavigationIntegrationTests(context); registerGraphAnnotationsIntegrationTests(context); registerValidityLowerBoundIntegrationTests(context); registerValidityEndClearingIntegrationTests(context); @@ -337,6 +368,8 @@ export function createIntegrationTestSuite< registerAdoptedEvolutionConcurrencyTests(context); registerMigrateSchemaKindIntegrationTests(context); registerReconciledSchemaIntegrationTests(context); + registerOntologyEquivalenceIntegrationTests(context); + registerOntologyTypedSubsumptionIntegrationTests(context); registerRecordedTimeIntegrationTests(context); registerLineageConformanceIntegrationTests(context); registerRemovalMaterializationIntegrationTests(context); @@ -350,6 +383,10 @@ export function createIntegrationTestSuite< registerConstraintFenceTransactionHealthTests(context); registerSavepointRecoveryIntegrationTests(context); registerConstraintFenceVerificationIntegrationTests(context); + registerOntologyTighteningIntegrationTests(context); + registerUniquenessClaimGateIntegrationTests(context); + registerEdgeCardinalityTighteningIntegrationTests(context); + registerCompositionTighteningIntegrationTests(context); registerRecordedReadBindingIntegrationTests(context); registerSetOperationIntegrationTests(context); registerSetNodeMutationIntegrationTests(context); @@ -361,6 +398,7 @@ export function createIntegrationTestSuite< registerRecursiveDelimiterIdIntegrationTests(context); registerRecursiveStopExpansionIntegrationTests(context); registerPaginationIntegrationTests(context); + registerKeysetPaginationPropertyTests(context); registerTraversalIntegrationTests(context); registerEdgePropertyIntegrationTests(context); registerAdvancedEdgePropertyIntegrationTests(context); @@ -374,17 +412,27 @@ export function createIntegrationTestSuite< registerWeightedShortestPathExtractionIntegrationTests(context); registerFulltextIntegrationTests(context); registerGraphMergeCallbackIntegrationTests(context); + registerGraphMergeCompositionIntegrationTests(context); registerGraphMergePlanIntegrationTests(context); + registerGraphMergeTargetCardinalityIntegrationTests(context); registerGraphMergeReviewIntegrationTests(context); registerGraphMergeReviewV2IntegrationTests(context); + registerImportDuplicateEdgeOrderIntegrationTests(context); + registerImportRequiredPartPurgeIntegrationTests(context); + registerImportTargetCardinalityIntegrationTests(context); registerImportUniquenessIntegrationTests(context); registerIdentityIntegrationTests(context); registerIdentityImportIntegrationTests(context); + registerIdentityReplayIntegrationTests(context); registerHistoricalIdentityTraversalTests(context); registerCurrentIdentityTraversalTests(context); registerIdentitySeparationIntegrationTests(context); registerEdgeCaseIntegrationTests(context); + registerEdgeAcyclicityIntegrationTests(context); registerEdgeClaimSelfHealIntegrationTests(context); + registerEdgeTargetCardinalityIntegrationTests(context); + registerBatchWriteVerdictIntegrationTests(context); + registerEdgeTargetCardinalityTemporalIntegrationTests(context); registerCrossBackendConsistencyTests(context); registerDurableEdgeMatchIdentityIntegrationTests(context); registerTrustedImportIntegrationTests(context); diff --git a/packages/typegraph/tests/backends/integration/adopted-evolution.ts b/packages/typegraph/tests/backends/integration/adopted-evolution.ts index c8121903b..cdc3d8816 100644 --- a/packages/typegraph/tests/backends/integration/adopted-evolution.ts +++ b/packages/typegraph/tests/backends/integration/adopted-evolution.ts @@ -2,11 +2,15 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { + type AdapterStore, ConfigurationError, createAdapterStoreWithSchema, defineGraph, defineNode, embedding, + type GraphDef, + MigrationError, + type MigrationErrorDetails, resolveGraphVectorSlots, SchemaContentConflictError, TransactionClosedError, @@ -15,9 +19,13 @@ import { import { deriveBackend } from "../../../src/backend/derive-backend"; import type { AdapterBackend, + GraphBackend, IdentityTableNames, } from "../../../src/backend/types"; -import { defineGraphExtension } from "../../../src/graph-extension"; +import { + defineGraphExtension, + type GraphExtension, +} from "../../../src/graph-extension"; import { mergeGraphExtension } from "../../../src/graph-extension/merge"; import { sql } from "../../../src/query/sql-fragment"; import { asCompiledStatementSql } from "../../../src/query/sql-intent"; @@ -122,6 +130,48 @@ const unrelatedFeatureGraph = defineGraph({ edges: {}, identity: { sameIdAcrossKinds: "fold" }, }); +const DisjointLeft = defineNode("AdoptedDisjointLeft", { + schema: z.object({}), +}); +const DisjointRight = defineNode("AdoptedDisjointRight", { + schema: z.object({}), +}); +const disjointTighteningGraph = defineGraph({ + id: "shared_adopted_disjoint_tightening", + nodes: { + AdoptedDisjointLeft: { type: DisjointLeft }, + AdoptedDisjointRight: { type: DisjointRight }, + }, + edges: {}, +}); +const disjointTighteningExtension = defineGraphExtension({ + ontology: [ + { + metaEdge: "disjointWith", + from: "AdoptedDisjointLeft", + to: "AdoptedDisjointRight", + }, + ], +}); +const AcyclicTask = defineNode("AdoptedAcyclicTask", { + schema: z.object({}), +}); +const acyclicTighteningGraph = defineGraph({ + id: "shared_adopted_acyclic_tightening", + nodes: { AdoptedAcyclicTask: { type: AcyclicTask } }, + edges: {}, +}); +function acyclicTighteningExtension(acyclic: boolean) { + return defineGraphExtension({ + edges: { + adoptedDependsOn: { + from: ["AdoptedAcyclicTask"], + to: ["AdoptedAcyclicTask"], + ...(acyclic ? { acyclic: true } : {}), + }, + }, + }); +} const standaloneKindExtension = defineGraphExtension({ nodes: { AdoptedStandaloneTag: { @@ -130,6 +180,77 @@ const standaloneKindExtension = defineGraphExtension({ }, }); +function ontologyTighteningViolations( + error: unknown, +): MigrationErrorDetails & { reason: "ontology-tightening-violated" } { + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + return details; +} + +/** + * Plans `tightening` against `store`, then asserts the adopted apply refuses + * with exactly the refusal `store.evolve(tightening)` raises, before the + * callback runs and without publishing a schema version. + */ +async function assertAdoptedTighteningRefusedLikeEvolve< + G extends GraphDef, + TNativeTransaction, +>( + backend: AdapterBackend, + store: AdapterStore, + tightening: GraphExtension, +): Promise { + const graphId = store.graphId; + const baseline = await backend.getActiveSchema(graphId); + const evolveDetails = ontologyTighteningViolations( + await store.evolve(tightening).catch((error: unknown) => error), + ); + const plan = await store.planEvolution(tightening); + if (plan.status !== "change") throw new Error("Expected tightening plan."); + expect( + plan.requirements.filter( + (requirement) => requirement.kind === "ontology-tightening", + ), + ).toEqual([ + { + kind: "ontology-tightening", + changes: evolveDetails.changes, + edgeCardinalityAxes: [], + }, + ]); + if (backend.adoptSchemaWriteTransaction === undefined) { + await assertUnsupportedAdoption(() => + backend.transactionWithNative(async (_target, nativeTx) => + store.withEvolvedTransaction(nativeTx, plan, async () => { + await Promise.resolve(); + }), + ), + ); + return; + } + let callbackRan = false; + const adoptedError = await backend + .transactionWithNative(async (_target, nativeTx) => + store.withEvolvedTransaction(nativeTx, plan, async () => { + callbackRan = true; + await Promise.resolve(); + }), + ) + .catch((error: unknown) => error); + const adoptedDetails = ontologyTighteningViolations(adoptedError); + expect(adoptedDetails.violations).toEqual(evolveDetails.violations); + expect(adoptedDetails.changes).toEqual(evolveDetails.changes); + expect(callbackRan).toBe(false); + const active = await backend.getActiveSchema(graphId); + expect(active?.version).toBe(baseline?.version); +} + async function assertUnsupportedAdoption( run: () => Promise, ): Promise { @@ -147,9 +268,60 @@ function identityTablesForBackend( ), identityClosure: requireDefined(names.identityClosure), identitySeparation: requireDefined(names.identitySeparation), + identityTransitions: requireDefined(names.identityTransitions), + identityTransitionRetention: requireDefined( + names.identityTransitionRetention, + ), }; } +/** + * Replaces the stored document of `version` with one stating a different + * schema, as a concurrent writer committing other content at the same + * version would, and returns the original document to restore. A + * conflicting version is a different DOCUMENT: the stored `schema_hash` + * column is the writer's record, and comparisons hash the document itself. + */ +async function storeConflictingSchemaDocument( + backend: GraphBackend, + graphId: string, + version: number, +): Promise { + const execute = requireDefined(backend.executeStatement); + const row = requireDefined(await backend.getSchemaVersion(graphId, version)); + const document = JSON.parse(row.schema_doc) as { + nodes: Record; + }; + for (const node of Object.values(document.nodes)) { + node.description = "written by a conflicting writer"; + } + const tableName = + backend.tableNames?.schemaVersions ?? "typegraph_schema_versions"; + await execute( + asCompiledStatementSql(sql` + UPDATE ${sql.raw(tableName)} SET schema_doc = ${JSON.stringify(document)} + WHERE graph_id = ${graphId} AND version = ${version} + `), + ); + return row.schema_doc; +} + +async function restoreSchemaDocument( + backend: GraphBackend, + graphId: string, + version: number, + schemaDocument: string, +): Promise { + const tableName = + backend.tableNames?.schemaVersions ?? "typegraph_schema_versions"; + await requireDefined(backend.executeStatement)( + asCompiledStatementSql(sql` + UPDATE ${sql.raw(tableName)} SET schema_doc = ${schemaDocument} + WHERE graph_id = ${graphId} AND version = ${version} + `), + ); +} + export function registerAdoptedEvolutionIntegrationTests( context: IntegrationTestContext, ): void { @@ -594,13 +766,10 @@ export function registerAdoptedEvolutionIntegrationTests( ); return; } - const tableName = - backend.tableNames?.schemaVersions ?? "typegraph_schema_versions"; - await backend.executeStatement( - asCompiledStatementSql(sql` - UPDATE ${sql.raw(tableName)} SET schema_hash = ${"0".repeat(64)} - WHERE graph_id = ${graph.id} AND version = ${plan.baseline.version} - `), + const originalDocument = await storeConflictingSchemaDocument( + backend, + graph.id, + plan.baseline.version, ); try { await expect( @@ -612,11 +781,11 @@ export function registerAdoptedEvolutionIntegrationTests( ).rejects.toThrow(SchemaContentConflictError); expect(await store.nodes.AdoptedPerson.find()).toEqual([]); } finally { - await backend.executeStatement( - asCompiledStatementSql(sql` - UPDATE ${sql.raw(tableName)} SET schema_hash = ${plan.baseline.hash} - WHERE graph_id = ${graph.id} AND version = ${plan.baseline.version} - `), + await restoreSchemaDocument( + backend, + graph.id, + plan.baseline.version, + originalDocument, ); } }); @@ -628,13 +797,10 @@ export function registerAdoptedEvolutionIntegrationTests( await store.evolve(extension); const plan = await store.planEvolution(extension); if (plan.status !== "noop") throw new Error("Expected no-op plan."); - const tableName = - backend.tableNames?.schemaVersions ?? "typegraph_schema_versions"; - await backend.executeStatement( - asCompiledStatementSql(sql` - UPDATE ${sql.raw(tableName)} SET schema_hash = ${"0".repeat(64)} - WHERE graph_id = ${graph.id} AND version = ${plan.baseline.version} - `), + const originalDocument = await storeConflictingSchemaDocument( + backend, + graph.id, + plan.baseline.version, ); try { await expect( @@ -646,11 +812,11 @@ export function registerAdoptedEvolutionIntegrationTests( ).rejects.toThrow(SchemaContentConflictError); expect(await store.nodes.AdoptedPerson.find()).toEqual([]); } finally { - await backend.executeStatement( - asCompiledStatementSql(sql` - UPDATE ${sql.raw(tableName)} SET schema_hash = ${plan.baseline.hash} - WHERE graph_id = ${graph.id} AND version = ${plan.baseline.version} - `), + await restoreSchemaDocument( + backend, + graph.id, + plan.baseline.version, + originalDocument, ); } }); @@ -797,6 +963,43 @@ export function registerAdoptedEvolutionIntegrationTests( expect(active?.version).toBe(plan.baseline.version); }); + it("refuses a disjointWith tightening existing rows violate, exactly as evolve() does", async () => { + const backend = context.getBackend(); + const [store] = await createAdapterStoreWithSchema( + disjointTighteningGraph, + backend, + ); + await store.nodes.AdoptedDisjointLeft.create({}, { id: "shared" }); + await store.nodes.AdoptedDisjointRight.create({}, { id: "shared" }); + + await assertAdoptedTighteningRefusedLikeEvolve( + backend, + store, + disjointTighteningExtension, + ); + }); + + it("refuses an acyclic: true tightening over an existing cycle, exactly as evolve() does", async () => { + const backend = context.getBackend(); + const [store] = await createAdapterStoreWithSchema( + acyclicTighteningGraph, + backend, + ); + const evolved = await store.evolve(acyclicTighteningExtension(false)); + const tasks = evolved.getNodeCollectionOrThrow("AdoptedAcyclicTask"); + const first = await tasks.create({}); + const second = await tasks.create({}); + const dependsOn = evolved.getEdgeCollectionOrThrow("adoptedDependsOn"); + await dependsOn.create(first, second, {}); + await dependsOn.create(second, first, {}); + + await assertAdoptedTighteningRefusedLikeEvolve( + backend, + evolved, + acyclicTighteningExtension(true), + ); + }); + it("accepts required-empty tightening when the kind has no rows", async () => { const backend = context.getBackend(); const [store] = await createAdapterStoreWithSchema(graph, backend); diff --git a/packages/typegraph/tests/backends/integration/algorithms.ts b/packages/typegraph/tests/backends/integration/algorithms.ts index 1a43825ab..bff48fc73 100644 --- a/packages/typegraph/tests/backends/integration/algorithms.ts +++ b/packages/typegraph/tests/backends/integration/algorithms.ts @@ -16,6 +16,7 @@ import { defineGraph, defineNode, GraphAlgorithmConvergenceError, + KindNotFoundError, type Store, } from "../../../src"; import { @@ -1899,5 +1900,62 @@ export function registerAlgorithmIntegrationTests( ).rejects.toThrow(/asOf/); }); }); + + describe("unregistered kinds", () => { + it("refuses an unregistered edge kind or node-kind scope instead of answering over nothing", async () => { + const store = context.getStore(); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + await store.edges.knows.create(alice, bob); + const unknownEdges = { edges: ["knowz"] } as never; + const unknownScope = { + edges: ["knows"], + nodeKinds: ["Persn"], + } as never; + const reads: Record Promise> = { + shortestPath: () => + store.algorithms.shortestPath(alice, bob, unknownEdges), + weightedShortestPath: () => + store.algorithms.weightedShortestPath(alice, bob, { + edges: ["knowz"], + weight: "since", + } as never), + reachable: () => store.algorithms.reachable(alice, unknownEdges), + canReach: () => store.algorithms.canReach(alice, bob, unknownEdges), + neighbors: () => store.algorithms.neighbors(alice, unknownEdges), + degree: () => store.algorithms.degree(alice, unknownEdges), + labelPropagation: () => + store.algorithms.labelPropagation(unknownEdges), + weaklyConnectedComponents: () => + store.algorithms.weaklyConnectedComponents(unknownEdges), + pageRank: () => store.algorithms.pageRank(unknownEdges), + "labelPropagation nodeKinds": () => + store.algorithms.labelPropagation(unknownScope), + "weaklyConnectedComponents nodeKinds": () => + store.algorithms.weaklyConnectedComponents(unknownScope), + "pageRank nodeKinds": () => store.algorithms.pageRank(unknownScope), + }; + + const refusals: Record = {}; + for (const [name, read] of Object.entries(reads)) { + refusals[name] = await Promise.resolve() + .then(() => read()) + .then( + () => "answered", + (error: unknown) => + error instanceof KindNotFoundError ? error.kindName : error, + ); + } + + expect(refusals).toEqual( + Object.fromEntries( + Object.keys(reads).map((name) => [ + name, + name.endsWith("nodeKinds") ? "Persn" : "knowz", + ]), + ), + ); + }); + }); }); } diff --git a/packages/typegraph/tests/backends/integration/batch-write-verdicts.ts b/packages/typegraph/tests/backends/integration/batch-write-verdicts.ts new file mode 100644 index 000000000..2c4d1b713 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/batch-write-verdicts.ts @@ -0,0 +1,1007 @@ +/** + * Batch writes reach every item's verdict before their first write, on every + * backend. + * + * `bulkUpsertById`, `bulkReplaceById` and `bulkGetOrCreateByEndpoints` write + * row by row: the creates first, then the updates, restores and + * resurrections. Each row's own write + * plan orders its reads before its statement, which is enough for one row and + * not for several: inside a caller's `store.transaction(...)` there is no + * nested frame to roll back, so a refusal reached at a later row would leave + * every earlier row committed with a batch that threw. Each case below catches + * the refusal inside a transaction and asserts nothing was left behind — and, + * for the batches that must still succeed, that the batch is judged as the + * state its earlier rows leave rather than the stored one. + * + * Fixture shape: + * + * BvClip --(bvClipOf, partOf, required, oneActive)-- BvShow + * BvPerson --(bvTagged, many)----------------------- BvDesk + * BvPerson --(bvOwns, cardinality "one")---------- BvDesk + * BvPerson --(bvSeatedAt, cardinality "oneActive")---- BvDesk + * BvPerson --(bvAssignedTo, targetCardinality "one")---- BvDesk + * + * BvStamp carries a unique `label`; BvGhost is disjoint with BvSpirit, so a + * tombstoned ghost whose id a spirit now holds cannot be restored. A second + * graph adds identity (`sameIdAcrossKinds: "fold"`) for the verdicts only + * an identity graph reaches, with a BvWisp (unique `slug`, disjoint with + * BvSpirit) for the writes that match a row by a unique key. + * + * Each case states, in a comment, the mutation/revert that must make it + * fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + asNodeId, + CardinalityError, + CompositionExistenceError, + defineEdge, + defineGraph, + defineNode, + DisjointError, + disjointWith, + IdentityContradictionError, + IdentityEndpointValidityError, + partOf, + UniquenessError, + ValidationError, +} from "../../../src"; +import { FORMAT_VERSION, importGraph } from "../../../src/interchange"; +import { transactionReviveNode } from "../../../src/store/runtime-port"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const BvShow = defineNode("BvShow", { schema: z.object({}) }); +const BvClip = defineNode("BvClip", { schema: z.object({}) }); +const BvPerson = defineNode("BvPerson", { schema: z.object({}) }); +const BvDesk = defineNode("BvDesk", { schema: z.object({}) }); +const BvStamp = defineNode("BvStamp", { + schema: z.object({ label: z.string() }), +}); +const BvGhost = defineNode("BvGhost", { + schema: z.object({ note: z.string().optional() }), +}); +const BvSpirit = defineNode("BvSpirit", { schema: z.object({}) }); +const BvWisp = defineNode("BvWisp", { schema: z.object({ slug: z.string() }) }); +const BvOther = defineNode("BvOther", { schema: z.object({}) }); + +const noteSchema = z.object({ note: z.string().optional() }); +const bvClipOf = defineEdge("bvClipOf", { schema: noteSchema }); +const bvTagged = defineEdge("bvTagged", { schema: noteSchema }); +const bvOwns = defineEdge("bvOwns", { schema: z.object({}) }); +const bvSeatedAt = defineEdge("bvSeatedAt", { schema: z.object({}) }); +const bvAssignedTo = defineEdge("bvAssignedTo", { schema: noteSchema }); + +/** The caller-chosen ids the batches below create. */ +const NEW_ASSIGNMENT_ID = asEdgeId("bv-new-edge"); +const CREATED_TAG_ID = asEdgeId("bv-created"); +const NEW_STAMP_ID = asNodeId("bv-new-stamp"); +const FOLDING_GHOST_ID = "bv-folding-ghost"; +const RESTORED_FOLD_ID = "bv-restored-fold"; + +const BV_STAMP_LABEL_UNIQUE = { + name: "bv_stamp_label", + fields: ["label"], + scope: "kind", + collation: "binary", +} as const; + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + BvShow: { type: BvShow }, + BvClip: { type: BvClip }, + BvPerson: { type: BvPerson }, + BvDesk: { type: BvDesk }, + BvStamp: { type: BvStamp, unique: [BV_STAMP_LABEL_UNIQUE] }, + BvGhost: { type: BvGhost }, + BvSpirit: { type: BvSpirit }, + }, + edges: { + bvClipOf: { + type: bvClipOf, + from: [BvClip], + to: [BvShow], + cardinality: "oneActive", + }, + bvTagged: { type: bvTagged, from: [BvPerson], to: [BvDesk] }, + bvOwns: { + type: bvOwns, + from: [BvPerson], + to: [BvDesk], + cardinality: "one", + }, + bvSeatedAt: { + type: bvSeatedAt, + from: [BvPerson], + to: [BvDesk], + cardinality: "oneActive", + }, + bvAssignedTo: { + type: bvAssignedTo, + from: [BvPerson], + to: [BvDesk], + targetCardinality: "one", + }, + }, + ontology: [ + partOf(BvClip, BvShow, { via: bvClipOf, existence: "required" }), + disjointWith(BvGhost, BvSpirit), + ], + }); +} + +const BV_WISP_SLUG_UNIQUE = { + name: "bv_wisp_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; + +/** The identity graph: a ghost and a wisp disjoint with a spirit, folded by id. */ +function buildIdentityGraph(id: string) { + return defineGraph({ + id, + nodes: { + BvGhost: { type: BvGhost }, + BvSpirit: { type: BvSpirit }, + BvOther: { type: BvOther }, + BvWisp: { type: BvWisp, unique: [BV_WISP_SLUG_UNIQUE] }, + }, + edges: {}, + ontology: [disjointWith(BvGhost, BvSpirit), disjointWith(BvWisp, BvSpirit)], + identity: { sameIdAcrossKinds: "fold" }, + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `batch_write_verdicts_${graphIdCounter}`; +} + +/** + * An identity graph where creating a BvGhost at `FOLDING_GHOST_ID` folds it + * with a live BvOther whose class holds a BvSpirit, which is disjoint with + * BvGhost: no read of the node rows at that id sees the conflict, only the + * identity fold does. + */ +async function createFoldConflictStore(context: IntegrationTestContext) { + const store = await context.createStore(buildIdentityGraph(nextGraphId())); + const other = await store.nodes.BvOther.create({}, { id: FOLDING_GHOST_ID }); + const spirit = await store.nodes.BvSpirit.create({}); + await store.identity.assertSame(other, spirit); + return store; +} + +/** + * An identity graph whose `RESTORED_FOLD_ID` names a tombstoned BvGhost and a + * tombstoned BvWisp, and a live BvOther whose class holds a BvSpirit: bringing + * either tombstone back folds it into a class holding a kind disjoint with its + * own. No read of the node rows at that id sees the conflict, only the + * identity fold does. + */ +async function createRestoreFoldConflictStore(context: IntegrationTestContext) { + const store = await context.createStore(buildIdentityGraph(nextGraphId())); + const ghost = await store.nodes.BvGhost.create( + { note: "before" }, + { id: RESTORED_FOLD_ID }, + ); + const wisp = await store.nodes.BvWisp.create( + { slug: "restored" }, + { id: RESTORED_FOLD_ID }, + ); + await store.nodes.BvGhost.delete(ghost.id); + await store.nodes.BvWisp.delete(wisp.id); + const other = await store.nodes.BvOther.create({}, { id: RESTORED_FOLD_ID }); + const spirit = await store.nodes.BvSpirit.create({}); + await store.identity.assertSame(other, spirit); + return { store, other }; +} + +type RestoreFoldConflict = Awaited< + ReturnType +>; + +/** + * The tombstones and the identity class a refused restore must leave as they + * were: each tombstone's stored version, and the live BvOther's class. + */ +async function restoreFoldState({ store, other }: RestoreFoldConflict) { + const tombstones = { temporalMode: "includeTombstones" } as const; + const ghost = await store.nodes.BvGhost.getById( + asNodeId(RESTORED_FOLD_ID), + tombstones, + ); + const wisp = await store.nodes.BvWisp.getById( + asNodeId(RESTORED_FOLD_ID), + tombstones, + ); + return { + ghost: { + deleted: ghost?.meta.deletedAt !== undefined, + version: ghost?.meta.version, + }, + wisp: { + deleted: wisp?.meta.deletedAt !== undefined, + version: wisp?.meta.version, + }, + otherClass: await store.identity.membersOf(other), + }; +} + +async function ghostIds( + store: Awaited>, +): Promise { + const ghosts = await store.nodes.BvGhost.find(); + return ghosts.map((ghost) => ghost.id).toSorted(); +} + +export function registerBatchWriteVerdictIntegrationTests( + context: IntegrationTestContext, +): void { + describe("node batch writes decide every item before the first write", () => { + for (const method of ["bulkUpsertById", "bulkReplaceById"] as const) { + it(`${method} keeps no earlier update when a later one loses a unique key`, async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.BvStamp.create({ label: "one" }); + const second = await store.nodes.BvStamp.create({ label: "two" }); + const holder = await store.nodes.BvStamp.create({ label: "taken" }); + + // MUTATION CHECK: write each entry of `executeNodeUpsertUpdateBatch` + // right after preparing it (`writeNodeUpdateSet` without its + // `prepareNodeUpdateSet` frame, src/store/operations/node-operations.ts) + // — the first stamp is then renamed before the second loses its key, + // and keeps the new label after the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvStamp[method]([ + { id: first.id, props: { label: "renamed" } }, + { id: second.id, props: { label: "taken" } }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(holder.id); + const after = requireDefined( + await store.nodes.BvStamp.getById(first.id), + ); + expect(after.label).toBe("one"); + expect(after.meta.version).toBe(first.meta.version); + }); + + it(`${method} creates nothing when an update in the same batch loses a unique key`, async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const second = await store.nodes.BvStamp.create({ label: "two" }); + await store.nodes.BvStamp.create({ label: "taken" }); + + // MUTATION CHECK: hand `executeNodeCreateBatch` no `pairedUpdates` + // from the collection (src/store/collections/node-collection.ts) — + // the new stamp is then inserted before the update loses its key, + // and survives the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvStamp[method]([ + { id: NEW_STAMP_ID, props: { label: "fresh" } }, + { id: second.id, props: { label: "taken" } }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect(await store.nodes.BvStamp.getById(NEW_STAMP_ID)).toBeUndefined(); + expect( + requireDefined(await store.nodes.BvStamp.getById(second.id)).label, + ).toBe("two"); + }); + } + + it("bulkUpsertById refuses an update a create of the same batch takes the key from, writing neither", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const existing = await store.nodes.BvStamp.create({ label: "two" }); + + // The create is written first, so the update that asks for its label + // after loses it — judged before either. + // + // MUTATION CHECK: leave the pending creates' claim transitions out of + // `prepareNodeUpdateSet` (src/store/operations/node-operations.ts) — + // the update is then judged against the stored keys alone, the create + // is inserted, and the update is refused after it. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvStamp.bulkUpsertById([ + { id: NEW_STAMP_ID, props: { label: "shared" } }, + { id: existing.id, props: { label: "shared" } }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(NEW_STAMP_ID); + expect(await store.nodes.BvStamp.getById(NEW_STAMP_ID)).toBeUndefined(); + expect( + requireDefined(await store.nodes.BvStamp.getById(existing.id)).label, + ).toBe("two"); + }); + + it("bulkUpsertById keeps no earlier update when a later restore's id is held by a disjoint kind", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const live = await store.nodes.BvGhost.create({ note: "before" }); + const gone = await store.nodes.BvGhost.create({}); + await store.nodes.BvGhost.delete(gone.id); + await store.nodes.BvSpirit.create({}, { id: gone.id }); + + // MUTATION CHECK: drop the `checkDisjointnessConstraint` read for a + // restoring entry in `prepareNodeUpdateSet` + // (src/store/operations/node-operations.ts) — "live" is then updated + // before the restore's claim refuses, and survives the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkUpsertById([ + { id: live.id, props: { note: "changed" } }, + { id: gone.id, props: {} }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(DisjointError); + const after = requireDefined(await store.nodes.BvGhost.getById(live.id)); + expect(after.note).toBe("before"); + expect(await store.nodes.BvGhost.getById(gone.id)).toBeUndefined(); + }); + + it("bulkUpsertById keeps no earlier update when a later restore's identity fold would contradict", async () => { + const store = await context.createStore( + buildIdentityGraph(nextGraphId()), + ); + const live = await store.nodes.BvGhost.create({ note: "before" }); + const gone = await store.nodes.BvGhost.create({}); + await store.nodes.BvGhost.delete(gone.id); + // The tombstone's id now names a live BvOther whose class holds a + // BvSpirit, which is disjoint with BvGhost: only the identity fold + // sees that, no read of the node rows at that id does. + const other = await store.nodes.BvOther.create({}, { id: gone.id }); + const spirit = await store.nodes.BvSpirit.create({}); + await store.identity.assertSame(other, spirit); + + // MUTATION CHECK: drop the `ctx.identity?.assertFoldable` read from + // `prepareNodeUpdateSet` (src/store/operations/node-operations.ts) — + // "live" is then updated before the restore's fold refuses. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkUpsertById([ + { id: live.id, props: { note: "changed" } }, + { id: gone.id, props: {} }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + const after = requireDefined(await store.nodes.BvGhost.getById(live.id)); + expect(after.note).toBe("before"); + expect(after.meta.version).toBe(live.meta.version); + }); + + // ======================================================== + // A created row's own identity fold, on caller-chosen ids + // ======================================================== + // + // MUTATION CHECK for every case below: in the create frame named in the + // case (src/store/operations/node-operations.ts), let the fold decide its + // verdict when it writes (`prepareNodeCreatesFold` called after the + // insert) instead of before it — the rows are then written before their + // fold refuses, and the caught refusal leaves them. + + it("bulkUpsertById keeps no update or create when a create's own identity fold would contradict", async () => { + // Create frame: `executeNodeCreateBatch`. + const store = await createFoldConflictStore(context); + const live = await store.nodes.BvGhost.create({ note: "before" }); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkUpsertById([ + { id: live.id, props: { note: "changed" } }, + { id: "bv-fresh-ghost", props: {} }, + { id: FOLDING_GHOST_ID, props: {} }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([live.id]); + const after = requireDefined(await store.nodes.BvGhost.getById(live.id)); + expect(after.note).toBe("before"); + expect(after.meta.version).toBe(live.meta.version); + }); + + it("bulkCreate creates nothing when a row's own identity fold would contradict", async () => { + // Create frame: `executeNodeCreateBatch`. + const store = await createFoldConflictStore(context); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkCreate([ + { props: {} }, + { props: {}, id: FOLDING_GHOST_ID }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + }); + + it("bulkInsert creates nothing when a row's own identity fold would contradict", async () => { + // Create frame: `executeNodeCreateNoReturnBatch`. + const store = await createFoldConflictStore(context); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkInsert([ + { props: {}, id: "bv-fresh-ghost" }, + { props: {}, id: FOLDING_GHOST_ID }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + }); + + it("create leaves no row when its own identity fold would contradict", async () => { + // Create frame: `executeNodeCreateInternal`. + const store = await createFoldConflictStore(context); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.create({}, { id: FOLDING_GHOST_ID }); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + }); + + it("bulkCreate resurrects no tombstone when its own identity fold would contradict", async () => { + // Create frame: `executeNodeCreateBatch`, its resurrection leg. + const store = await context.createStore( + buildIdentityGraph(nextGraphId()), + ); + const gone = await store.nodes.BvGhost.create({}); + await store.nodes.BvGhost.delete(gone.id); + const other = await store.nodes.BvOther.create({}, { id: gone.id }); + const spirit = await store.nodes.BvSpirit.create({}); + await store.identity.assertSame(other, spirit); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkCreate([ + { props: {} }, + { props: {}, id: gone.id }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await store.nodes.BvGhost.find()).toEqual([]); + }); + + // ======================================================== + // A restored row's own identity fold + // ======================================================== + // + // MUTATION CHECK for every case below: in the restore frame named in the + // case (src/store/operations/node-operations.ts), decide the fold after + // the row write instead of before it (`prepareNodeFold` moved below the + // write) — the tombstone is then brought back before its fold refuses, + // and the caught refusal leaves it live. + + it("upsertById brings back no tombstone when its identity fold would contradict", async () => { + // Restore frame: `executeNodeUpdate`. + const conflict = await createRestoreFoldConflictStore(context); + const { store } = conflict; + const before = await restoreFoldState(conflict); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.upsertById(RESTORED_FOLD_ID, { + note: "changed", + }); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + expect(await restoreFoldState(conflict)).toEqual(before); + }); + + it("getOrCreateByConstraint brings back no tombstone when its identity fold would contradict", async () => { + // Restore frame: `executeNodeUpsertUpdate`. + const conflict = await createRestoreFoldConflictStore(context); + const { store } = conflict; + const before = await restoreFoldState(conflict); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvWisp.getOrCreateByConstraint("bv_wisp_slug", { + slug: "restored", + }); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await store.nodes.BvWisp.find()).toEqual([]); + expect(await restoreFoldState(conflict)).toEqual(before); + }); + + it("a revive brings back no tombstone when its identity fold would contradict", async () => { + // Restore frame: `executeNodeRevive`, through `reviveNodeRowInFrame`. + const conflict = await createRestoreFoldConflictStore(context); + const { store } = conflict; + const before = await restoreFoldState(conflict); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await transactionReviveNode(tx, { + kind: "BvGhost", + id: RESTORED_FOLD_ID, + }); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + expect(await restoreFoldState(conflict)).toEqual(before); + }); + + it("importGraph writes none of its rows when an imported node's identity fold would contradict", async () => { + // The import folds its nodes after writing them, in its own + // transaction: a fold may join two rows of the same document, which no + // read before their inserts can see, so the refusal rolls that + // transaction back rather than being reached before the first row. + // + // MUTATION CHECK: run `foldImportedIdentityNodes` after the import's + // write plan returns instead of inside `runImportWritePlanAttempt` + // (src/interchange/import.ts) — the rows then commit before the fold + // refuses, and the fresh ghost survives the rejected import. + const store = await createFoldConflictStore(context); + + await expect( + importGraph( + store, + { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external" as const }, + nodes: [ + { kind: "BvGhost", id: "bv-fresh-ghost", properties: {} }, + { kind: "BvGhost", id: FOLDING_GHOST_ID, properties: {} }, + ], + edges: [], + }, + { onConflict: "error" }, + ), + ).rejects.toBeInstanceOf(IdentityContradictionError); + expect(await ghostIds(store)).toEqual([]); + }); + + it("bulkUpsertById keeps no earlier update when a later window end would strand an identity assertion", async () => { + const store = await context.createStore( + buildIdentityGraph(nextGraphId()), + ); + const live = await store.nodes.BvGhost.create({ note: "before" }); + const asserted = await store.nodes.BvGhost.create({}); + const other = await store.nodes.BvOther.create({}); + await store.identity.assertSame(asserted, other); + + // MUTATION CHECK: drop the window-end identity verdict from + // `prepareNodeUpdateSet` (src/store/operations/node-operations.ts) — + // "live" is then updated before the second entry's window end is + // refused. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.BvGhost.bulkUpsertById([ + { id: live.id, props: { note: "changed" } }, + { + id: asserted.id, + props: {}, + validTo: new Date(Date.now() + 60_000).toISOString(), + }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityEndpointValidityError); + const after = requireDefined(await store.nodes.BvGhost.getById(live.id)); + expect(after.note).toBe("before"); + }); + }); + + describe("edge batch writes decide every item before the first write", () => { + // ======================================================== + // bulkUpsertById: the update half on its own + // ======================================================== + + it("bulkUpsertById keeps no earlier update when a later item would detach a required part", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.BvShow.create({}); + const first = await store.nodes.BvClip.create( + {}, + { partOf: { whole: show } }, + ); + const second = await store.nodes.BvClip.create( + {}, + { partOf: { whole: show } }, + ); + const [firstEdge] = await store.edges.bvClipOf.findFrom(first); + const [secondEdge] = await store.edges.bvClipOf.findFrom(second); + + // MUTATION CHECK: write each entry of `executeEdgeUpsertUpdateBatch` + // right after preparing it (`performEdgeUpdateConverging` per entry in + // place of `writeEdgeUpdateSet`, src/store/operations/edge-operations.ts) + // — the first clip's note is then written before the second entry's + // existence refusal, and survives the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvClipOf.bulkUpsertById([ + { + id: requireDefined(firstEdge).id, + from: first, + to: show, + props: { note: "changed" }, + }, + { + id: requireDefined(secondEdge).id, + from: second, + to: show, + validTo: new Date().toISOString(), + }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + const after = await store.edges.bvClipOf.getById( + requireDefined(firstEdge).id, + ); + expect(after?.note).toBeUndefined(); + }); + + it("bulkUpsertById keeps no earlier update when a later item's window would end before it starts", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const person = await store.nodes.BvPerson.create({}); + const desk = await store.nodes.BvDesk.create({}); + const other = await store.nodes.BvDesk.create({}); + const first = await store.edges.bvTagged.create(person, desk, {}); + const second = await store.edges.bvTagged.create(person, other, {}); + + // MUTATION CHECK: as above — the first edge's note is then written + // before the second entry's window verdict refuses. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvTagged.bulkUpsertById([ + { + id: first.id, + from: person, + to: desk, + props: { note: "changed" }, + }, + { + id: second.id, + from: person, + to: other, + validTo: "2000-01-01T00:00:00.000Z", + }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(ValidationError); + const after = await store.edges.bvTagged.getById(first.id); + expect(after?.note).toBeUndefined(); + const secondAfter = await store.edges.bvTagged.getById(second.id); + expect(secondAfter?.meta.validTo).toBeUndefined(); + }); + + it("bulkUpsertById resurrects neither of two tombstones that would share one source allowance", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const person = await store.nodes.BvPerson.create({}); + const desk = await store.nodes.BvDesk.create({}); + const other = await store.nodes.BvDesk.create({}); + const first = await store.edges.bvOwns.create(person, desk, {}); + await store.edges.bvOwns.delete(first.id); + const second = await store.edges.bvOwns.create(person, other, {}); + await store.edges.bvOwns.delete(second.id); + + // Each resurrection alone fits the allowance; together they do not. + // + // MUTATION CHECK: drop the `registerEdgeTransition` call after each + // entry in `prepareEdgeUpdateSet` + // (src/store/operations/edge-operations.ts) — both entries then pass + // against the stored counts, the first is resurrected, and the second + // is refused at its write. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvOwns.bulkUpsertById([ + { id: first.id, from: person, to: desk }, + { id: second.id, from: person, to: other }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + expect(await store.edges.bvOwns.findFrom(person)).toEqual([]); + }); + + it("bulkUpsertById lets a later resurrection take the allowance an earlier item's window end frees", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const person = await store.nodes.BvPerson.create({}); + const desk = await store.nodes.BvDesk.create({}); + const other = await store.nodes.BvDesk.create({}); + const tombstone = await store.edges.bvSeatedAt.create(person, other, {}); + await store.edges.bvSeatedAt.delete(tombstone.id); + const current = await store.edges.bvSeatedAt.create(person, desk, {}); + + // MUTATION CHECK: count only what a transition ADDS in + // `createEdgeTransitionValidationBackend` + // (src/store/operations/edge-batch-validation.ts) — the resurrection is + // then judged against the open window the first item ends, and refused. + const results = await store.edges.bvSeatedAt.bulkUpsertById([ + { + id: current.id, + from: person, + to: desk, + validTo: new Date().toISOString(), + }, + { id: tombstone.id, from: person, to: other }, + ]); + + expect(results.map((edge) => edge.id)).toEqual([ + current.id, + tombstone.id, + ]); + expect(results[0]?.meta.validTo).toBeDefined(); + expect(results[1]?.meta.validTo).toBeUndefined(); + }); + + // ======================================================== + // bulkUpsertById and bulkGetOrCreateByEndpoints: the creates, then the + // update half + // ======================================================== + + it("bulkUpsertById creates nothing when a resurrection in the same batch is refused", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const [a, b, c] = await Promise.all([ + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + ]); + const taken = await store.nodes.BvDesk.create({}); + const free = await store.nodes.BvDesk.create({}); + const tombstone = await store.edges.bvAssignedTo.create(b, taken, {}); + await store.edges.bvAssignedTo.delete(tombstone.id); + await store.edges.bvAssignedTo.create(c, taken, {}); + + // MUTATION CHECK: drop the paired-update preparation from + // `executeEdgeCreateBatch` (`prepareEdgeUpdateSet` over + // `pairedUpdates`, src/store/operations/edge-operations.ts) — the new + // edge is then inserted before the resurrection's target allowance + // refuses, and survives the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvAssignedTo.bulkUpsertById([ + { id: NEW_ASSIGNMENT_ID, from: a, to: free }, + { id: tombstone.id, from: b, to: taken }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + expect(await store.edges.bvAssignedTo.getById(NEW_ASSIGNMENT_ID)).toBe( + undefined, + ); + expect(await store.edges.bvAssignedTo.findTo(free)).toEqual([]); + }); + + it("bulkUpsertById refuses a resurrection a create of the same batch takes the allowance from, writing neither", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const a = await store.nodes.BvPerson.create({}); + const b = await store.nodes.BvPerson.create({}); + const desk = await store.nodes.BvDesk.create({}); + const tombstone = await store.edges.bvAssignedTo.create(b, desk, {}); + await store.edges.bvAssignedTo.delete(tombstone.id); + + // The create is written first, so the resurrection that follows it + // finds the desk taken — judged before either. + // + // MUTATION CHECK: leave `pendingCreates` out of the paired-update + // preparation in `executeEdgeCreateBatch` + // (src/store/operations/edge-operations.ts) — the resurrection is then + // judged against the stored desk alone, the create is inserted, and + // the resurrection is refused after it. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvAssignedTo.bulkUpsertById([ + { id: NEW_ASSIGNMENT_ID, from: a, to: desk }, + { id: tombstone.id, from: b, to: desk }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + expect(await store.edges.bvAssignedTo.findTo(desk)).toEqual([]); + }); + + it("bulkUpsertById still updates, in the same batch, a row it creates", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const person = await store.nodes.BvPerson.create({}); + const desk = await store.nodes.BvDesk.create({}); + const other = await store.nodes.BvDesk.create({}); + const tombstone = await store.edges.bvTagged.create(person, other, {}); + await store.edges.bvTagged.delete(tombstone.id); + + // The second item updates the row the first creates: the update half + // is prepared against the row the create will write. + const results = await store.edges.bvTagged.bulkUpsertById([ + { id: CREATED_TAG_ID, from: person, to: desk, props: {} }, + { + id: CREATED_TAG_ID, + from: person, + to: desk, + props: { note: "later" }, + }, + { id: tombstone.id, from: person, to: other, props: {} }, + ]); + + expect(results.map((edge) => edge.note)).toEqual([ + undefined, + "later", + undefined, + ]); + const created = await store.edges.bvTagged.getById(CREATED_TAG_ID); + expect(created?.note).toBe("later"); + }); + + it("bulkGetOrCreateByEndpoints creates nothing when a resurrection in the same batch is refused", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const [a, b, c] = await Promise.all([ + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + ]); + const taken = await store.nodes.BvDesk.create({}); + const free = await store.nodes.BvDesk.create({}); + const tombstone = await store.edges.bvAssignedTo.create(b, taken, {}); + await store.edges.bvAssignedTo.delete(tombstone.id); + await store.edges.bvAssignedTo.create(c, taken, {}); + + // MUTATION CHECK: hand `executeEdgeCreateBatch` no `pairedUpdates` in + // `executeEdgeBulkGetOrCreateByEndpoints` + // (src/store/operations/edge-operations.ts) — the new edge is then + // inserted before the resurrection refuses, and survives the caught + // refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvAssignedTo.bulkGetOrCreateByEndpoints([ + { from: a, to: free, props: {} }, + { from: b, to: taken, props: {} }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + expect(await store.edges.bvAssignedTo.findTo(free)).toEqual([]); + }); + + for (const coalesceUnchangedUpserts of [false, true]) { + it(`bulkGetOrCreateByEndpoints with ifExists "update" updates no earlier match when a later resurrection is refused${coalesceUnchangedUpserts ? " (coalescing store)" : ""}`, async () => { + const store = await context.createStore(buildGraph(nextGraphId()), { + coalesceUnchangedUpserts, + }); + const [a, b, c] = await Promise.all([ + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + store.nodes.BvPerson.create({}), + ]); + const taken = await store.nodes.BvDesk.create({}); + const mine = await store.nodes.BvDesk.create({}); + const matched = await store.edges.bvAssignedTo.create(a, mine, { + note: "before", + }); + const tombstone = await store.edges.bvAssignedTo.create(b, taken, {}); + await store.edges.bvAssignedTo.delete(tombstone.id); + await store.edges.bvAssignedTo.create(c, taken, {}); + + // MUTATION CHECK: without a coalescing store, write each entry of + // `executeEdgeUpsertUpdateBatch` right after preparing it; with + // one, drop the `prepareEdgeUpdateSet` call over `owedUpdates` in + // `executeEdgeBulkGetOrCreateByEndpoints` + // (src/store/operations/edge-operations.ts) — the matched edge's note + // is then written before the resurrection refuses. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.bvAssignedTo.bulkGetOrCreateByEndpoints( + [ + { from: a, to: mine, props: { note: "after" } }, + { from: b, to: taken, props: {} }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + const matchedAfter = await store.edges.bvAssignedTo.getById(matched.id); + expect(matchedAfter?.note).toBe("before"); + }); + } + }); +} diff --git a/packages/typegraph/tests/backends/integration/bind-budget.ts b/packages/typegraph/tests/backends/integration/bind-budget.ts new file mode 100644 index 000000000..5cb3e00c8 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/bind-budget.ts @@ -0,0 +1,63 @@ +/** + * A backend that declares a small bind-parameter budget and refuses any + * row-returning statement that exceeds it — what an engine with a low limit + * (Cloudflare Durable Object SQLite accepts 100) does to an unsliced batch + * statement, reproduced on every lane. + * + * Only `execute` is policed: the batch inserts are sized by the engine + * profile the backend was built with, while the statements a store composes + * itself read the budget off `capabilities` at the moment they run. + */ +import { type GraphBackend } from "../../../src"; +import { deriveBackend } from "../../../src/backend/derive-backend"; +import { + type TransactionBackend, + type TransactionOptions, +} from "../../../src/backend/types"; +import { countSqlParameters } from "../../../src/query/sql-fragment"; +import { type CompiledRowsSql } from "../../../src/query/sql-intent"; + +function assertWithinBindBudget( + statement: CompiledRowsSql, + maxBindParameters: number, +): void { + const bound = countSqlParameters(statement); + if (bound > maxBindParameters) { + throw new Error( + `Statement bound ${String(bound)} parameters; the budget is ${String(maxBindParameters)}.`, + ); + } +} + +export function withBindBudget( + backend: GraphBackend, + maxBindParameters: number, +): GraphBackend { + return deriveBackend(backend, { + capabilities: { ...backend.capabilities, maxBindParameters }, + execute(statement: CompiledRowsSql): Promise { + assertWithinBindBudget(statement, maxBindParameters); + return backend.execute(statement); + }, + transaction( + fn: (tx: TransactionBackend) => Promise, + options?: TransactionOptions, + ): Promise { + return backend.transaction( + (tx) => + fn( + deriveBackend(tx, { + capabilities: { ...tx.capabilities, maxBindParameters }, + execute( + statement: CompiledRowsSql, + ): Promise { + assertWithinBindBudget(statement, maxBindParameters); + return tx.execute(statement); + }, + }), + ), + options, + ); + }, + }); +} diff --git a/packages/typegraph/tests/backends/integration/clear.ts b/packages/typegraph/tests/backends/integration/clear.ts index d88c7d6f6..3b7761787 100644 --- a/packages/typegraph/tests/backends/integration/clear.ts +++ b/packages/typegraph/tests/backends/integration/clear.ts @@ -24,12 +24,18 @@ import { afterEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { + createStore, defineGraph, defineNode, DEPLOYMENT_CONTRIBUTION_GRAPH_ID, resolveGraphVectorSlots, searchable, + UnsupportedBackendCapabilityError, } from "../../../src"; +import { + deriveBackend, + projectBackendWithout, +} from "../../../src/backend/derive-backend"; import type { StrategyTableContribution } from "../../../src/backend/table-contribution"; import type { ContributionMaterializationIdentity, @@ -117,6 +123,35 @@ function deploymentMarkerIdentity( return graphMarkerIdentity(DEPLOYMENT_CONTRIBUTION_GRAPH_ID, contribution); } +const PRESERVING_CLEAR_MEMBER = + "clearGraphPreservingContributionMaterializations"; + +/** + * The shape of a custom backend that implements `clearGraph` only: neither the + * backend nor the transactions it opens carry the preserving clear. + */ +function withoutPreservingClear(backend: GraphBackend): GraphBackend { + return deriveBackend( + projectBackendWithout(backend, [PRESERVING_CLEAR_MEMBER]), + { + transaction: (fn, options) => + backend.transaction( + (tx) => fn(projectBackendWithout(tx, [PRESERVING_CLEAR_MEMBER])), + options, + ), + }, + ); +} + +function seedArticle(store: IntegrationStore) { + return store.nodes.Article.create({ + title: "Kept or cleared", + body: "A row the clear either removes or never reaches.", + category: "health", + published: true, + }); +} + // Postgres returns COUNT(*) as a string/bigint, SQLite as a number, so the // value is genuinely not statically a number. type CountRow = Readonly<{ cnt: unknown }>; @@ -320,6 +355,76 @@ export function registerClearIntegrationTests( expect(hits.map((hit) => hit.node.id)).toContain(article.id); }); + describe("on a backend without the preserving clear", () => { + it("refuses a stated preserve and deletes nothing", async () => { + const store = context.getStore(); + const read = requireDefined( + store.backend.getContributionMaterialization, + "backend must read contribution markers", + ); + const markerIdentity = graphMarkerIdentity( + store.graphId, + fulltextContribution(store.backend), + ); + const markerBefore = await read(markerIdentity); + expect(markerBefore).toBeDefined(); + const article = await seedArticle(store); + const limited = createStore( + integrationTestGraph, + withoutPreservingClear(store.backend), + ); + + const refusal = await limited + .clear({ preserveContributionMaterializations: true }) + .catch((error: unknown) => error); + + console.log("stated-preserve refusal", refusal); + expect(refusal).toBeInstanceOf(UnsupportedBackendCapabilityError); + expect( + (refusal as UnsupportedBackendCapabilityError).details, + ).toMatchObject({ capability: PRESERVING_CLEAR_MEMBER }); + await expect(read(markerIdentity)).resolves.toEqual(markerBefore); + await expect( + store.nodes.Article.getById(article.id), + ).resolves.toMatchObject({ id: article.id }); + expect(await countFulltextRows(store, store.graphId)).toBe(1); + }); + + it.each([ + ["an omitted option", undefined], + ["a stated false", { preserveContributionMaterializations: false }], + ] as const)( + "clears through clearGraph for %s", + async (_label, options) => { + const store = context.getStore(); + const read = requireDefined( + store.backend.getContributionMaterialization, + "backend must read contribution markers", + ); + const markerIdentity = graphMarkerIdentity( + store.graphId, + fulltextContribution(store.backend), + ); + expect(await read(markerIdentity)).toBeDefined(); + const article = await seedArticle(store); + const limited = createStore( + integrationTestGraph, + withoutPreservingClear(store.backend), + ); + + await limited.clear(options); + + await expect( + store.nodes.Article.getById(article.id), + ).resolves.toBeUndefined(); + expect(await countFulltextRows(store, store.graphId)).toBe(0); + // The bundled clearGraph removes graph-local markers, and this + // backend has no member that would keep them. + await expect(read(markerIdentity)).resolves.toBeUndefined(); + }, + ); + }); + it("removes graph-local contribution markers when explicitly requested", async () => { const store = context.getStore(); const backend = store.backend; diff --git a/packages/typegraph/tests/backends/integration/composition-attachment.ts b/packages/typegraph/tests/backends/integration/composition-attachment.ts new file mode 100644 index 000000000..4c0c16995 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-attachment.ts @@ -0,0 +1,3094 @@ +/** + * The composition ATTACHMENT surface, on every backend: how a part names the + * whole it belongs to (`partOf: { whole, via?, props? }`), how it MOVES + * between wholes (`nodes..reparent`), and what `getOrCreateByConstraint` + * guarantees about a node it resolved rather than created. + * + * Fixture shape — the (CaChapter, CaBook) pair is deliberately realized by + * TWO edge kinds, which is what makes `via` load-bearing rather than + * cosmetic: + * + * CaChapter --(caChapterOf, partOf, part->whole)-- CaBook + * CaChapter --(caDraftChapterOf, partOf, part->whole)-- CaBook + * CaChapter --(caIncludedIn, partOf, part->whole)-- CaAnthology + * CaPage --(caPageOf, partOf, part->whole)-- CaChapter + * CaClip --(caClipOf, partOf, part->whole, oneActive)-- CaShow + * CaTrack --(caHasTrack, hasPart, whole->part)-- CaAlbum + * CaFolder --(caParentFolder, partOf, part->whole, reflexive)-- CaFolder + * CaRelic --(caRelicOf, partOf, part->whole, oneActive)-- CaVault + * CaReel --(caReelOf, partOf, part->whole, oneActive)-- CaShow + * CaCell --(caCellUnder, partOf, part->whole, oneActive, acyclic)-- CaCell + * CaNest --(caNestUnder, partOf, part->whole, reflexive)-- CaNest + * + * CaSlot --(caSlotIn, partOf, part->whole, one both ways)-- CaRack + * CaEngine --(caEngineOf, partOf, part->whole, oneActive, whole holds one)-- CaCar + * + * CaNest carries a unique key, so a get-or-create batch can name one of its + * own matched items as the batch's shared whole. CaStamp is no part at all: + * it carries TWO unique constraints, so a get-or-create update leg can be + * refused on the one the lookup did not use. CaSlot's whole holds ONE part + * (`targetCardinality: "one"`), so a matched part and a new one cannot both + * attach to it. CaGhost is no part either: it is disjoint with CaSpirit, so a + * tombstoned ghost whose id a spirit now holds cannot be resurrected. + * + * CaVault's OWN schema declares properties named `via`, `props`, `validFrom` + * and `validTo` — the attachment's option names — so a whole passed as a + * node object proves only its `kind` and `id` are read. + * + * Each case states, in a comment, the mutation/revert that must make it + * fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + CardinalityError, + CompositionError, + CompositionExistenceError, + ConfigurationError, + createStore, + defineEdge, + defineGraph, + defineNode, + DisjointError, + disjointWith, + EdgeAcyclicityError, + hasPart, + NodeNotFoundError, + partOf, + UniquenessError, + ValidationError, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; +import { matchingObject } from "../../test-utils"; +import { withBindBudget } from "./bind-budget"; +import { type IntegrationTestContext } from "./test-context"; + +const CaBook = defineNode("CaBook", { schema: z.object({}) }); +const CaAnthology = defineNode("CaAnthology", { schema: z.object({}) }); +const CaChapter = defineNode("CaChapter", { + schema: z.object({ slug: z.string() }), +}); +const CA_CHAPTER_SLUG_UNIQUE = { + name: "ca_chapter_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +const CaPage = defineNode("CaPage", { schema: z.object({}) }); +const CaShow = defineNode("CaShow", { schema: z.object({}) }); +const CaClip = defineNode("CaClip", { schema: z.object({}) }); +const CaAlbum = defineNode("CaAlbum", { schema: z.object({}) }); +const CaTrack = defineNode("CaTrack", { schema: z.object({}) }); +/** A reflexive composition pair — the acyclicity coverage below. */ +const CaFolder = defineNode("CaFolder", { schema: z.object({}) }); +/** A reflexive part whose moves leave ended history rows (`oneActive`). */ +const CaUnit = defineNode("CaUnit", { schema: z.object({}) }); +/** A reflexive `oneActive` part whose realizing edge is also `acyclic: true`. */ +const CaCell = defineNode("CaCell", { schema: z.object({}) }); +/** Declares no composition pair at all — `reparent`'s not-a-part refusal. */ +const CaReader = defineNode("CaReader", { schema: z.object({}) }); + +/** A whole whose own property names collide with every attachment option. */ +const CaVault = defineNode("CaVault", { + schema: z.object({ + via: z.string(), + props: z.object({ order: z.number() }), + validFrom: z.string(), + validTo: z.string(), + }), +}); +const CaRelic = defineNode("CaRelic", { schema: z.object({}) }); +/** An OPTIONAL `oneActive` part with a unique key, for get-or-create. */ +const CaReel = defineNode("CaReel", { + schema: z.object({ slug: z.string() }), +}); +const CA_REEL_SLUG_UNIQUE = { + name: "ca_reel_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +/** A reflexive OPTIONAL part with a unique key, for get-or-create batches. */ +const CaNest = defineNode("CaNest", { + schema: z.object({ slug: z.string() }), +}); +const CA_NEST_SLUG_UNIQUE = { + name: "ca_nest_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +/** Two unique keys: the lookup reads `code`, an update can collide on `label`. */ +const CaStamp = defineNode("CaStamp", { + schema: z.object({ code: z.string(), label: z.string() }), +}); +const CA_STAMP_CODE_UNIQUE = { + name: "ca_stamp_code", + fields: ["code"], + scope: "kind", + collation: "binary", +} as const; +const CA_STAMP_LABEL_UNIQUE = { + name: "ca_stamp_label", + fields: ["label"], + scope: "kind", + collation: "binary", +} as const; +/** A part whose whole holds at most one part. */ +const CaSlot = defineNode("CaSlot", { schema: z.object({ slug: z.string() }) }); +const CaRack = defineNode("CaRack", { schema: z.object({}) }); +const CA_SLOT_SLUG_UNIQUE = { + name: "ca_slot_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +/** + * A required `oneActive` part whose whole holds at most one part row for the + * row's whole life (`targetCardinality: "one"`): a move ENDS the incumbent's + * window, and the ended row still occupies the whole's slot. + */ +const CaEngine = defineNode("CaEngine", { schema: z.object({}) }); +const CaCar = defineNode("CaCar", { schema: z.object({}) }); +/** Disjoint with CaSpirit: a ghost cannot share an id with a live spirit. */ +const CaGhost = defineNode("CaGhost", { + schema: z.object({ slug: z.string(), note: z.string().optional() }), +}); +const CaSpirit = defineNode("CaSpirit", { schema: z.object({}) }); +const CA_GHOST_SLUG_UNIQUE = { + name: "ca_ghost_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +const CA_VAULT_PROPS = { + via: "caChapterOf", + props: { order: 7 }, + validFrom: "1999-01-01T00:00:00.000Z", + validTo: "1999-06-01T00:00:00.000Z", +} as const; + +const caChapterOf = defineEdge("caChapterOf", { + schema: z.object({ order: z.number().int() }), +}); +/** A SECOND realizing edge for the same (CaChapter, CaBook) pair — what makes `via` load-bearing. */ +const caDraftChapterOf = defineEdge("caDraftChapterOf", { + schema: z.object({}), +}); +const caIncludedIn = defineEdge("caIncludedIn", { schema: z.object({}) }); +const caPageOf = defineEdge("caPageOf", { schema: z.object({}) }); +const caClipOf = defineEdge("caClipOf", { schema: z.object({}) }); +const caHasTrack = defineEdge("caHasTrack", { schema: z.object({}) }); +const caParentFolder = defineEdge("caParentFolder", { schema: z.object({}) }); +const caUnitUnder = defineEdge("caUnitUnder", { schema: z.object({}) }); +const caCellUnder = defineEdge("caCellUnder", { schema: z.object({}) }); +const caRelicOf = defineEdge("caRelicOf", { + schema: z.object({ order: z.number().optional() }), +}); + +const caReelOf = defineEdge("caReelOf", { schema: z.object({}) }); +const caSlotIn = defineEdge("caSlotIn", { schema: z.object({}) }); +const caNestUnder = defineEdge("caNestUnder", { schema: z.object({}) }); +const caEngineOf = defineEdge("caEngineOf", { schema: z.object({}) }); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + CaBook: { type: CaBook }, + CaAnthology: { type: CaAnthology }, + CaChapter: { type: CaChapter, unique: [CA_CHAPTER_SLUG_UNIQUE] }, + CaPage: { type: CaPage }, + CaShow: { type: CaShow }, + CaClip: { type: CaClip }, + CaAlbum: { type: CaAlbum }, + CaTrack: { type: CaTrack }, + CaFolder: { type: CaFolder, onDelete: "cascade" }, + CaUnit: { type: CaUnit }, + CaCell: { type: CaCell }, + CaReader: { type: CaReader }, + CaVault: { type: CaVault }, + CaRelic: { type: CaRelic }, + CaReel: { type: CaReel, unique: [CA_REEL_SLUG_UNIQUE] }, + CaNest: { type: CaNest, unique: [CA_NEST_SLUG_UNIQUE] }, + CaSlot: { type: CaSlot, unique: [CA_SLOT_SLUG_UNIQUE] }, + CaRack: { type: CaRack }, + CaEngine: { type: CaEngine }, + CaCar: { type: CaCar }, + CaGhost: { type: CaGhost, unique: [CA_GHOST_SLUG_UNIQUE] }, + CaSpirit: { type: CaSpirit }, + CaStamp: { + type: CaStamp, + unique: [CA_STAMP_CODE_UNIQUE, CA_STAMP_LABEL_UNIQUE], + }, + }, + edges: { + caChapterOf: { + type: caChapterOf, + from: [CaChapter], + to: [CaBook], + cardinality: "one", + }, + caDraftChapterOf: { + type: caDraftChapterOf, + from: [CaChapter], + to: [CaBook], + cardinality: "one", + }, + caIncludedIn: { + type: caIncludedIn, + from: [CaChapter], + to: [CaAnthology], + cardinality: "one", + }, + caPageOf: { + type: caPageOf, + from: [CaPage], + to: [CaChapter], + cardinality: "one", + }, + caClipOf: { + type: caClipOf, + from: [CaClip], + to: [CaShow], + cardinality: "oneActive", + }, + caHasTrack: { + type: caHasTrack, + from: [CaAlbum], + to: [CaTrack], + targetCardinality: "one", + }, + caParentFolder: { + type: caParentFolder, + from: [CaFolder], + to: [CaFolder], + cardinality: "one", + }, + caUnitUnder: { + type: caUnitUnder, + from: [CaUnit], + to: [CaUnit], + cardinality: "oneActive", + }, + caCellUnder: { + type: caCellUnder, + from: [CaCell], + to: [CaCell], + cardinality: "oneActive", + acyclic: true, + }, + caRelicOf: { + type: caRelicOf, + from: [CaRelic], + to: [CaVault], + cardinality: "oneActive", + }, + caReelOf: { + type: caReelOf, + from: [CaReel], + to: [CaShow], + cardinality: "oneActive", + }, + caNestUnder: { + type: caNestUnder, + from: [CaNest], + to: [CaNest], + cardinality: "one", + }, + caSlotIn: { + type: caSlotIn, + from: [CaSlot], + to: [CaRack], + cardinality: "one", + targetCardinality: "one", + }, + caEngineOf: { + type: caEngineOf, + from: [CaEngine], + to: [CaCar], + cardinality: "oneActive", + targetCardinality: "one", + }, + }, + ontology: [ + partOf(CaChapter, CaBook, { via: caChapterOf }), + partOf(CaChapter, CaBook, { via: caDraftChapterOf }), + partOf(CaChapter, CaAnthology, { via: caIncludedIn }), + partOf(CaPage, CaChapter, { via: caPageOf }), + partOf(CaClip, CaShow, { via: caClipOf, existence: "required" }), + hasPart(CaAlbum, CaTrack, { via: caHasTrack }), + partOf(CaFolder, CaFolder, { + via: caParentFolder, + partSide: "from", + }), + partOf(CaUnit, CaUnit, { via: caUnitUnder, partSide: "from" }), + partOf(CaCell, CaCell, { via: caCellUnder, partSide: "from" }), + partOf(CaRelic, CaVault, { via: caRelicOf }), + partOf(CaReel, CaShow, { via: caReelOf }), + partOf(CaNest, CaNest, { via: caNestUnder, partSide: "from" }), + partOf(CaSlot, CaRack, { via: caSlotIn }), + partOf(CaEngine, CaCar, { via: caEngineOf, existence: "required" }), + disjointWith(CaGhost, CaSpirit), + ], + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `composition_attachment_${graphIdCounter}`; +} + +export function registerCompositionAttachmentIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Composition attachment (via / props / reparent / get-or-create)", () => { + // ======================================================== + // The attachment names its realizing edge: `via` and `props` + // ======================================================== + + it("refuses an ambiguous partOf with COMPOSITION_VIA_AMBIGUOUS and writes no row", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + + // MUTATION CHECK: make `resolveCompositionAttachment` + // (src/store/operations/composition-create.ts) return `declared[0]` + // instead of refusing when `declared.length > 1` and no `via` is + // stated — the create then silently succeeds through whichever pair + // sorts first, and both assertions below fail. + await expect( + store.nodes.CaChapter.create( + { slug: "one" }, + { partOf: { whole: { kind: "CaBook", id: book.id } } }, + ), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_VIA_AMBIGUOUS" }), + }), + ); + expect(await store.nodes.CaChapter.count()).toBe(0); + }); + + it("refuses a `via` that realizes no declared pair between the two kinds", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + + // MUTATION CHECK: drop the `via`-named-but-unknown arm and fall + // through to `declared[0]` — the create then attaches through + // `caChapterOf` while the caller asked for `caIncludedIn`. + await expect( + store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caIncludedIn", + }, + }, + ), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_VIA_NOT_DECLARED" }), + }), + ); + expect(await store.nodes.CaChapter.count()).toBe(0); + }); + + it("attaches through the named `via` and validates its props like edges..create", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + + // MUTATION CHECK: revert `buildCompositionCreateEdgeInput` to + // `props: {}` — the realizing edge is written with no `order` and the + // `order` assertion below fails (a required schema field would also + // make the create throw). + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 3 }, + }, + }, + ); + + const edges = await store.edges.caChapterOf.find({}); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).fromId).toBe(chapter.id); + expect(requireDefined(edges[0]).order).toBe(3); + }); + + it("validates the realizing edge's props against its own schema", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + + await expect( + store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: "third" }, + }, + }, + ), + ).rejects.toThrow(expect.objectContaining({ code: "VALIDATION_ERROR" })); + expect(await store.nodes.CaChapter.count()).toBe(0); + }); + + it("omitting `via` is fine when exactly one pair is declared", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const album = await store.nodes.CaAlbum.create({}); + const track = await store.nodes.CaTrack.create( + {}, + { partOf: { whole: { kind: "CaAlbum", id: album.id } } }, + ); + const edges = await store.edges.caHasTrack.find({}); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).toId).toBe(track.id); + }); + + // ======================================================== + // Moving a part: `reparent` + // ======================================================== + + it('reparent moves a `population: "one"` part, keeping its id and descendants', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const anthology = await store.nodes.CaAnthology.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + const page = await store.nodes.CaPage.create( + {}, + { partOf: { whole: { kind: "CaChapter", id: chapter.id } } }, + ); + + // MUTATION CHECK: skip the retire (drop the retire loop in + // `writeCompositionAttachmentMoves`, + // src/store/operations/node-operations.ts) + // — the attach then loses the composition claim and this rejects with + // COMPOSITION_WHOLE_OCCUPIED instead of moving the chapter. + await store.nodes.CaChapter.reparent(chapter.id, { + whole: { kind: "CaAnthology", id: anthology.id }, + via: "caIncludedIn", + }); + + const moved = await store.edges.caIncludedIn.find({}); + expect(moved).toHaveLength(1); + expect(requireDefined(moved[0]).fromId).toBe(chapter.id); + expect(requireDefined(moved[0]).toId).toBe(anthology.id); + // `population: "one"` retires by DELETE — an ended row would still + // read as an attachment under that population. + expect(await store.edges.caChapterOf.find({})).toHaveLength(0); + // Same node, same descendants: nothing below the part was rewritten. + const reloaded = await store.nodes.CaChapter.getById(chapter.id); + expect(reloaded?.slug).toBe("one"); + const pages = await store.edges.caPageOf.find({}); + expect(pages).toHaveLength(1); + expect(requireDefined(pages[0]).fromId).toBe(page.id); + expect(requireDefined(pages[0]).toId).toBe(chapter.id); + }); + + it('reparent ends the window of a `population: "oneActive"` required part instead of deleting it', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const showA = await store.nodes.CaShow.create({}); + const showB = await store.nodes.CaShow.create({}); + const clip = await store.nodes.CaClip.create( + {}, + { partOf: { whole: { kind: "CaShow", id: showA.id } } }, + ); + + // MUTATION CHECK: remove the `reattachedPart` arm from + // `assertCompositionExistencePreserved` + // (src/store/operations/composition-create.ts) — the window end is + // then read as a detach of a live required part and this rejects with + // CompositionExistenceError (`situation: "detach"`). + await store.nodes.CaClip.reparent(clip.id, { + whole: { kind: "CaShow", id: showB.id }, + }); + + const all = await store.edges.caClipOf.find( + {}, + { + temporalMode: "includeEnded", + }, + ); + expect(all).toHaveLength(2); + const ended = all.filter((edge) => edge.toId === showA.id); + const open = all.filter((edge) => edge.toId === showB.id); + expect(ended).toHaveLength(1); + expect(requireDefined(ended[0]).meta.validTo).toBeDefined(); + expect(open).toHaveLength(1); + expect(requireDefined(open[0]).meta.validTo).toBeUndefined(); + }); + + it.each(["bulkUpsertById", "getOrCreateByEndpoints"] as const)( + "resurrecting an ended `oneActive` history row through %s attaches nothing and is not refused", + async (entry) => { + const store = await context.createStore(buildGraph(nextGraphId())); + const showA = await store.nodes.CaShow.create({}); + const showB = await store.nodes.CaShow.create({}); + const reel = await store.nodes.CaReel.create( + { slug: "reel-1" }, + { partOf: { whole: { kind: "CaShow", id: showA.id } } }, + ); + await store.nodes.CaReel.reparent(reel.id, { + whole: { kind: "CaShow", id: showB.id }, + }); + const afterMove = await store.edges.caReelOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const history = requireDefined( + afterMove.find((edge) => edge.toId === showA.id), + ); + expect(history.meta.validTo).toBeDefined(); + await store.edges.caReelOf.delete(history.id); + + // MUTATION CHECK: returning the composition claim unconditionally for a + // resurrection in `compositionReentryClaim` refuses this with + // CompositionError COMPOSITION_WHOLE_OCCUPIED, although the row would + // stay ended — verified and reverted. + if (entry === "bulkUpsertById") { + await store.edges.caReelOf.bulkUpsertById([ + { id: history.id, from: reel, to: showA, props: {} }, + ]); + } else { + await store.edges.caReelOf.getOrCreateByEndpoints(reel, showA, {}); + } + + const afterRestore = await store.edges.caReelOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const restored = requireDefined( + afterRestore.find((edge) => edge.id === history.id), + ); + expect(restored.meta.validTo).toBe(history.meta.validTo); + const live = await store.edges.caReelOf.find({}); + expect(live.map((edge) => edge.toId)).toEqual([showB.id]); + expect(await store.verifyConstraintFences()).toEqual([]); + }, + ); + + it("resurrecting a deleted attachment after the part took another whole through another edge kind is refused", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const anthology = await store.nodes.CaAnthology.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "chapter-1" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + const [incumbent] = await store.edges.caChapterOf.find({}); + const incumbentId = requireDefined(incumbent).id; + await store.edges.caChapterOf.delete(incumbentId); + const replacement = await store.edges.caIncludedIn.create( + chapter, + anthology, + {}, + ); + + const refusal = await store.edges.caChapterOf + .bulkUpsertById([ + { id: incumbentId, from: chapter, to: book, props: { order: 1 } }, + ]) + .catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(CompositionError); + expect((refusal as CompositionError).details).toMatchObject({ + situation: "occupied", + incumbentEdgeId: replacement.id, + }); + expect(await store.edges.caChapterOf.find({})).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: make `compositionReentryClaim` + // (src/store/claims/composition-claims.ts) answer `undefined` — the + // resurrection then re-attaches the chapter to the book while the + // anthology still holds it. + + it("reparent is ONE move instant: the ended window and the new one abut", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const showA = await store.nodes.CaShow.create({}); + const showB = await store.nodes.CaShow.create({}); + const clip = await store.nodes.CaClip.create( + {}, + { partOf: { whole: { kind: "CaShow", id: showA.id } } }, + ); + + // This case asserts the invariant on every backend, but a two-read + // implementation can pass it by luck: two `nowIso()` calls a few + // statements apart often land in the same millisecond. The mutation + // check for the SINGLE read lives in + // `tests/composition-reparent-instant.test.ts`, which advances the + // clock between the retire and the attach so a second read is + // guaranteed to sample a later instant. + await store.nodes.CaClip.reparent(clip.id, { + whole: { kind: "CaShow", id: showB.id }, + }); + + const all = await store.edges.caClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const ended = requireDefined( + all.find((edge) => edge.toId === showA.id), + "the former attachment", + ); + const open = requireDefined( + all.find((edge) => edge.toId === showB.id), + "the new attachment", + ); + const moveInstant = requireDefined( + ended.meta.validTo, + "the ended window's validTo", + ); + expect(open.meta.validFrom).toBe(moveInstant); + + // The windows are half-open, so the move instant belongs to exactly + // one of them: no coordinate shows the part with zero wholes, and none + // shows it with two. + const atMove = await store.asOf(moveInstant).edges.caClipOf.find({}); + expect(atMove).toHaveLength(1); + expect(requireDefined(atMove[0]).toId).toBe(showB.id); + }); + + it("reparent refuses an `at` earlier than the `oneActive` incumbent's start, before any write, and accepts one equal to it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const showA = await store.nodes.CaShow.create({}); + const showB = await store.nodes.CaShow.create({}); + const attachedAt = "2024-06-01T00:00:00.000Z"; + const earlier = "2024-01-01T00:00:00.000Z"; + const [first, second] = [ + await store.nodes.CaClip.create( + {}, + { + partOf: { + whole: { kind: "CaShow", id: showA.id }, + validFrom: attachedAt, + }, + }, + ), + await store.nodes.CaClip.create( + {}, + { partOf: { whole: { kind: "CaShow", id: showA.id } } }, + ), + ]; + const attachments = await store.edges.caClipOf.find({}); + const incumbent = requireDefined( + attachments.find((edge) => edge.fromId === first.id), + "the first clip's attachment", + ); + const whole = { kind: "CaShow", id: showB.id } as const; + + // MUTATION CHECK: make `resolveCompositionMoveInstant` + // (src/store/operations/composition-create.ts) return a stated `at` + // unjudged — the window end then reaches the edge update and is refused + // with an inverted-window ValidationError instead of this code. + const error = await store.nodes.CaClip.reparent(first.id, { + whole, + at: earlier, + }).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionError); + expect(error).toMatchObject({ + code: "COMPOSITION_MOVE_BEFORE_INCUMBENT", + details: { + situation: "moveBeforeIncumbent", + partKind: "CaClip", + partId: first.id, + wholeKind: "CaShow", + wholeId: showB.id, + incumbentEdgeKind: "caClipOf", + incumbentEdgeId: incumbent.id, + incumbentValidFrom: attachedAt, + at: earlier, + }, + }); + + // The batch's first item is legal on its own; the refusal of its last + // leaves it unmoved, also caught on `tx` inside a committing transaction. + await store.transaction(async (tx) => { + await expect( + tx.nodes.CaClip.bulkReparent([ + { id: second.id, options: { whole } }, + { id: first.id, options: { whole, at: earlier } }, + ]), + ).rejects.toMatchObject({ code: "COMPOSITION_MOVE_BEFORE_INCUMBENT" }); + }); + const untouched = await store.edges.caClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(untouched.map((edge) => [edge.toId, edge.meta.validTo])).toEqual([ + [showA.id, undefined], + [showA.id, undefined], + ]); + + // MUTATION CHECK: refuse an `at` that is not LATER than the incumbent's + // start — this zero-width retire is then refused. + const moved = await store.nodes.CaClip.reparent(first.id, { + whole, + at: attachedAt, + }); + expect(moved.edge.meta.validFrom).toBe(attachedAt); + const history = await store.edges.caClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = requireDefined( + history.find((edge) => edge.id === incumbent.id), + "the retired attachment", + ); + expect([retired.meta.validFrom, retired.meta.validTo]).toEqual([ + attachedAt, + attachedAt, + ]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("reparent to the whole the part already holds is an accepted no-op", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + const beforeEdges = await store.edges.caChapterOf.find({}); + const before = requireDefined(beforeEdges[0]); + + await store.nodes.CaChapter.reparent(chapter.id, { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + }); + + const after = await store.edges.caChapterOf.find({}); + expect(after).toHaveLength(1); + // No write at all: the SAME row, not a retire-and-recreate. + expect(requireDefined(after[0]).id).toBe(before.id); + expect(requireDefined(after[0]).meta.updatedAt).toBe( + before.meta.updatedAt, + ); + }); + + it("reparent to the whole the part already holds refuses a stated `at` other than the incumbent's start, and honors that start", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.CaShow.create({}); + const attachedAt = "2024-06-01T00:00:00.000Z"; + const clip = await store.nodes.CaClip.create( + {}, + { + partOf: { + whole: { kind: "CaShow", id: show.id }, + validFrom: attachedAt, + }, + }, + ); + const [incumbent] = await store.edges.caClipOf.find({}); + if (incumbent === undefined) throw new Error("the clip's attachment"); + const whole = { kind: "CaShow", id: show.id } as const; + + // MUTATION CHECK: drop the `assertSatisfiedMoveInstantHonored` call + // from `prepareCompositionAttachmentMoves` + // (src/store/operations/node-operations.ts) — every call below then + // answers `{ moved: false }`, dropping the stated instant. + const calls: readonly (() => Promise)[] = [ + "2024-01-01T00:00:00.000Z", + "2025-01-01T00:00:00.000Z", + ].flatMap((at) => [ + () => store.nodes.CaClip.reparent(clip.id, { whole, at }), + () => + store.nodes.CaClip.bulkReparent([ + { id: clip.id, options: { whole, at } }, + ]), + ]); + for (const call of calls) { + const error = await call().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).details.issues).toEqual([ + expect.objectContaining({ + path: "options.at", + code: "COMPOSITION_ATTACHMENT_WINDOW_CONFLICT", + }), + ]); + } + + const restated = await store.nodes.CaClip.reparent(clip.id, { + whole, + at: attachedAt, + }); + expect(restated.moved).toBe(false); + expect(restated.edge.id).toBe(incumbent.id); + const after = await store.edges.caClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect( + after.map((edge) => [edge.id, edge.meta.validFrom, edge.meta.validTo]), + ).toEqual([[incumbent.id, attachedAt, undefined]]); + }); + + it("reparent refuses a kind that is not a composition part", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const reader = await store.nodes.CaReader.create({}); + await expect( + store.nodes.CaReader.reparent(reader.id, { + // @ts-expect-error CaReader is no declared part, so no whole kind is accepted + whole: { kind: "CaBook", id: "whatever" }, + }), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_NOT_A_PART" }), + }), + ); + }); + + it("reparent refuses an undeclared target pair and a missing part", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + const page = await store.nodes.CaPage.create( + {}, + { partOf: { whole: { kind: "CaChapter", id: chapter.id } } }, + ); + + await expect( + store.nodes.CaPage.reparent(page.id, { + // @ts-expect-error CaPage declares CaChapter as its only whole kind + whole: { kind: "CaBook", id: book.id }, + }), + ).rejects.toBeInstanceOf(ConfigurationError); + + await expect( + store.nodes.CaChapter.reparent(asNodeId("no-such-chapter"), { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + }), + ).rejects.toBeInstanceOf(NodeNotFoundError); + }); + + it("reparent validates acyclicity over the composition union against the FINAL state", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaFolder.create({}); + const child = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: root.id } } }, + ); + const grandchild = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: child.id } } }, + ); + + // MUTATION CHECK: drop the `assertPreparedEdgeCreatesAcyclic` call in + // `prepareCompositionAttachmentMoves` + // (src/store/operations/node-operations.ts) — the move then succeeds and leaves a three-node composition ring + // that no ordinary delete can unwind (`CompositionCycleError`). + await expect( + store.nodes.CaFolder.reparent(root.id, { + whole: { kind: "CaFolder", id: grandchild.id }, + }), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + + // The refused move left the graph exactly as it was: the retire and + // the attach are one transaction, so a failing attach rolls the + // retire back too. Without that, `root` would now be detached. + const links = await store.edges.caParentFolder.find({}); + expect(links).toHaveLength(2); + expect( + new Set(links.map((edge) => `${edge.fromId}->${edge.toId}`)), + ).toEqual( + new Set([`${child.id}->${root.id}`, `${grandchild.id}->${child.id}`]), + ); + }); + + it("a `oneActive` move's ended history row does not constrain later moves", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaUnit.create({}); + const former = await store.nodes.CaUnit.create( + {}, + { partOf: { whole: { kind: "CaUnit", id: root.id } } }, + ); + const report = await store.nodes.CaUnit.create( + {}, + { partOf: { whole: { kind: "CaUnit", id: former.id } } }, + ); + // `report` leaves `former`: the edge between them is ended, not deleted. + await store.nodes.CaUnit.reparent(report.id, { + whole: { kind: "CaUnit", id: root.id }, + }); + + // MUTATION CHECK: counting every non-deleted row of a `oneActive` + // realizing edge in `compositionAcyclicRelation` (membership + // `"allRows"`) refuses this swap with EdgeAcyclicityError "already + // reaches", through the ended row — verified and reverted. + await store.nodes.CaUnit.reparent(former.id, { + whole: { kind: "CaUnit", id: report.id }, + }); + + const live = await store.edges.caUnitUnder.find({}); + expect( + new Set(live.map((edge) => `${edge.fromId}->${edge.toId}`)), + ).toEqual( + new Set([`${report.id}->${root.id}`, `${former.id}->${report.id}`]), + ); + expect(await store.verifyConstraintFences()).toEqual([]); + + // A cycle among the LIVE rows is still refused. + await expect( + store.nodes.CaUnit.reparent(report.id, { + whole: { kind: "CaUnit", id: former.id }, + }), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + }); + + it("an `acyclic: true` declaration on a `oneActive` realizing edge keeps counting its ended rows", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaCell.create({}); + const former = await store.nodes.CaCell.create( + {}, + { partOf: { whole: { kind: "CaCell", id: root.id } } }, + ); + const report = await store.nodes.CaCell.create( + {}, + { partOf: { whole: { kind: "CaCell", id: former.id } } }, + ); + await store.nodes.CaCell.reparent(report.id, { + whole: { kind: "CaCell", id: root.id }, + }); + + // MUTATION CHECK: dropping the `standaloneAcyclicEdgeKinds` exemption in + // `compositionAcyclicRelation` accepts this move, and the audit then + // reports the cycle the kind's own `acyclic: true` forbids — verified + // and reverted. + await expect( + store.nodes.CaCell.reparent(former.id, { + whole: { kind: "CaCell", id: report.id }, + }), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses a composition window that starts or ends later than now on every write path, writing nothing", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const later = "2999-01-01T00:00:00.000Z"; + const book = await store.nodes.CaBook.create({}); + const show = await store.nodes.CaShow.create({}); + const otherShow = await store.nodes.CaShow.create({}); + const clip = await store.nodes.CaClip.create( + {}, + { partOf: { whole: show } }, + ); + const [held] = await store.edges.caClipOf.find({}); + if (held === undefined) throw new Error("expected the clip's edge"); + const chapterOf = { whole: book, via: "caChapterOf" } as const; + + // MUTATION CHECK: make `compositionWindowRefusal` + // (src/store/operations/composition-create.ts) return `undefined` — + // every write below then lands, leaving the clip with two current + // wholes behind a released claim, or a chapter whose attachment starts + // in the future, and the assertions after the loop fail. + const refused: readonly (readonly [string, () => Promise])[] = [ + [ + "COMPOSITION_VALIDITY_FUTURE_START", + () => + store.nodes.CaChapter.create( + { slug: "create" }, + { partOf: { ...chapterOf, validFrom: later } }, + ), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_START", + () => + store.nodes.CaChapter.bulkCreate([ + { + props: { slug: "bulk" }, + partOf: { ...chapterOf, validFrom: later }, + }, + ]), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_START", + () => + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "get-or-create" }, + { partOf: { ...chapterOf, validFrom: later } }, + ), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => + store.nodes.CaChapter.create( + { slug: "bounded" }, + { partOf: { ...chapterOf, validTo: later } }, + ), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => + store.edges.caClipOf.create( + clip, + otherShow, + {}, + { validTo: later }, + ), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_START", + () => + store.edges.caClipOf.create( + clip, + otherShow, + {}, + { validFrom: later }, + ), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => + store.edges.caClipOf.bulkCreate([ + { from: clip, to: otherShow, props: {}, validTo: later }, + ]), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => store.edges.caClipOf.update(held.id, {}, { validTo: later }), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => + store.edges.caClipOf.bulkUpsertById([ + { id: held.id, from: clip, to: show, props: {}, validTo: later }, + ]), + ], + [ + "COMPOSITION_VALIDITY_FUTURE_END", + () => + store.edges.caClipOf.getOrCreateByEndpoints( + clip, + show, + {}, + { ifExists: "update", validTo: later }, + ), + ], + [ + "COMPOSITION_MOVE_FUTURE", + () => + store.nodes.CaClip.reparent(clip.id, { + whole: otherShow, + at: later, + }), + ], + [ + "COMPOSITION_MOVE_FUTURE", + () => + store.nodes.CaClip.bulkReparent([ + { id: clip.id, options: { whole: otherShow, at: later } }, + ]), + ], + ]; + for (const [code, write] of refused) { + const error = await write().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionError); + expect(error).toMatchObject({ code }); + } + + expect(await store.nodes.CaChapter.count()).toBe(0); + const rows = await store.edges.caClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(rows.map((edge) => [edge.id, edge.meta.validTo])).toEqual([ + [held.id, undefined], + ]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("judges an ended `oneActive` history row outside the composition relation on every write that proposes it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const whole = await store.nodes.CaUnit.create({}); + const part = await store.nodes.CaUnit.create({}); + // `whole` under `part` is live; `part` under `whole` is history only. + await store.edges.caUnitUnder.create(whole, part, {}); + const history = { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }; + + // MUTATION CHECK: drop the `acyclicRelationCountsRow` filter from + // `groupProposedByAcyclicRelation` (src/store/acyclicity.ts) — each + // write below is then refused with EdgeAcyclicityError "would close a + // cycle in the composition relation", although none of them puts a + // member into the relation. + const created = await store.edges.caUnitUnder.create( + part, + whole, + {}, + history, + ); + await store.edges.caUnitUnder.bulkCreate([ + { from: part, to: whole, props: {}, ...history }, + ]); + await store.edges.caUnitUnder.delete(created.id); + const [revived] = await store.edges.caUnitUnder.bulkUpsertById([ + { id: created.id, from: part, to: whole, props: {} }, + ]); + expect(requireDefined(revived).meta.validTo).toBe(history.validTo); + + const live = await store.edges.caUnitUnder.find({}); + expect(live.map((edge) => `${edge.fromId}->${edge.toId}`)).toEqual([ + `${whole.id}->${part.id}`, + ]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses reopening an ended `oneActive` history row that would close a composition cycle, on every reopen surface", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const whole = await store.nodes.CaUnit.create({}); + const part = await store.nodes.CaUnit.create({}); + const ended = await store.edges.caUnitUnder.create( + part, + whole, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + await store.edges.caUnitUnder.create(whole, part, {}); + + // MUTATION CHECK: probe only resurrections in `prepareEdgeUpdate` + // (`edgeUpdateReentersAcyclicRelation` answering `false` for a reopen, + // src/store/operations/edge-operations.ts) — every reopen below then + // lands and the audit reports the cycle. + const reopens: readonly (() => Promise)[] = [ + () => + store.edges.caUnitUnder.update(ended.id, {}, { clearValidTo: true }), + () => + store.edges.caUnitUnder.bulkUpsertById([ + { + id: ended.id, + from: part, + to: whole, + props: {}, + clearValidTo: true, + }, + ]), + () => + store.edges.caUnitUnder.getOrCreateByEndpoints( + part, + whole, + {}, + { ifExists: "update", clearValidTo: true }, + ), + ]; + for (const reopen of reopens) { + await expect(reopen()).rejects.toBeInstanceOf(EdgeAcyclicityError); + } + const stillEnded = await store.edges.caUnitUnder.getById(ended.id, { + temporalMode: "includeEnded", + }); + expect(stillEnded?.meta.validTo).toBe("2021-01-01T00:00:00.000Z"); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses two reopens that close a composition cycle together before either is written", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const left = await store.nodes.CaUnit.create({}); + const right = await store.nodes.CaUnit.create({}); + const history = { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }; + const leftUnderRight = await store.edges.caUnitUnder.create( + left, + right, + {}, + history, + ); + const rightUnderLeft = await store.edges.caUnitUnder.create( + right, + left, + {}, + history, + ); + + // MUTATION CHECK: drop the reopen arm from + // `proposedRelationEdgesFromReentries` + // (src/store/operations/edge-operations.ts) — the batch then writes the + // first reopen before the second one's own probe refuses, and the + // caught transaction commits it. + await store.transaction(async (tx) => { + await expect( + tx.edges.caUnitUnder.bulkUpsertById([ + { + id: leftUnderRight.id, + from: left, + to: right, + props: {}, + clearValidTo: true, + }, + { + id: rightUnderLeft.id, + from: right, + to: left, + props: {}, + clearValidTo: true, + }, + ]), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + }); + expect(await store.edges.caUnitUnder.find({})).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("bulkReparent counts an ended incumbent against a whole-side `one` bound, refusing a swap before any write", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const carA = await store.nodes.CaCar.create({}); + const carB = await store.nodes.CaCar.create({}); + const engineX = await store.nodes.CaEngine.create( + {}, + { partOf: { whole: carA } }, + ); + const engineY = await store.nodes.CaEngine.create( + {}, + { partOf: { whole: carB } }, + ); + const openRows = async () => { + const open = await store.edges.caEngineOf.find({}); + return new Set(open.map((edge) => `${edge.fromId}->${edge.toId}`)); + }; + const before = await openRows(); + + // A move ENDS the incumbent's window, and the ended row still occupies + // its car's one slot, so the single move is refused... + await expect( + store.nodes.CaEngine.reparent(engineX.id, { whole: carB }), + ).rejects.toBeInstanceOf(CardinalityError); + + // ...and so is the swap, before it ends either window. MUTATION CHECK: + // subtract an `"endWindow"` retire from every count in + // `retireLeavesCount` (src/store/operations/edge-batch-validation.ts) — + // the swap then passes its preparation, ends both windows, and fails + // only at the insert's claim, so the caught transaction commits two + // required engines with no car. + await store.transaction(async (tx) => { + await expect( + tx.nodes.CaEngine.bulkReparent([ + { id: engineX.id, options: { whole: carB } }, + { id: engineY.id, options: { whole: carA } }, + ]), + ).rejects.toBeInstanceOf(CardinalityError); + }); + expect(await openRows()).toEqual(before); + expect( + await store.edges.caEngineOf.find({}, { temporalMode: "includeEnded" }), + ).toHaveLength(2); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("bulkReparent keeps an ended incumbent in an `acyclic: true` relation that counts it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaCell.create({}); + const former = await store.nodes.CaCell.create( + {}, + { partOf: { whole: root } }, + ); + const report = await store.nodes.CaCell.create( + {}, + { partOf: { whole: former } }, + ); + + // `report` leaves `former` (its row is ended, and still counted by + // `caCellUnder`'s own `acyclic: true`) while `former` moves under + // `report`. MUTATION CHECK: exclude every retired incumbent from the + // probe in `prepareCompositionAttachmentMoves` + // (`retireRemovesFromAcyclicRelation` answering `true`, + // src/store/operations/node-operations.ts) — the batch then commits + // the cycle the sequential moves refuse, and the audit reports it. + await expect( + store.nodes.CaCell.bulkReparent([ + { id: report.id, options: { whole: root } }, + { id: former.id, options: { whole: report } }, + ]), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + const live = await store.edges.caCellUnder.find({}); + expect( + new Set(live.map((edge) => `${edge.fromId}->${edge.toId}`)), + ).toEqual( + new Set([`${former.id}->${root.id}`, `${report.id}->${former.id}`]), + ); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + // `population: "oneActive"` retires by ending the incumbent's window, + // `population: "one"` by deleting it; both reflexive, so one batch can + // carry a cycle. + const reflexivePairs = [ + { population: "oneActive", kind: "CaUnit", via: "caUnitUnder" }, + { population: "one", kind: "CaFolder", via: "caParentFolder" }, + ] as const; + + describe.each(reflexivePairs)( + "bulkReparent on a `population: $population` pair", + ({ population, kind, via }) => { + async function seed() { + const store = await context.createStore(buildGraph(nextGraphId())); + const nodes = requireDefined(store.getNodeCollection(kind)); + const edges = requireDefined(store.getEdgeCollection(via)); + const rootA = await nodes.create({}); + const rootB = await nodes.create({}); + const first = await nodes.create( + {}, + { partOf: { whole: { kind, id: rootA.id } } }, + ); + const second = await nodes.create( + {}, + { partOf: { whole: { kind, id: rootA.id } } }, + ); + const liveLinks = async (): Promise> => { + const live = await edges.find({}); + return new Set(live.map((edge) => `${edge.fromId}->${edge.toId}`)); + }; + return { store, edges, rootA, rootB, first, second, liveLinks }; + } + + it("leaves no earlier move behind when a later item is refused and the caller catches it in a transaction", async () => { + const { store, edges, rootA, rootB, first, second, liveLinks } = + await seed(); + const before = await liveLinks(); + const refusedBatches = [ + // A destination that does not exist. + [ + { id: first.id, options: { whole: { kind, id: rootB.id } } }, + { id: second.id, options: { whole: { kind, id: "missing" } } }, + ], + // A cycle the batch's own moves close between them. + [ + { id: first.id, options: { whole: { kind, id: rootB.id } } }, + { id: rootB.id, options: { whole: { kind, id: first.id } } }, + ], + // A move instant that precedes the window it would end. Only a + // `oneActive` retire ends a window; a `one` retire deletes. + ...(population === "oneActive" ? + [ + [ + { id: first.id, options: { whole: { kind, id: rootB.id } } }, + { + id: second.id, + options: { + whole: { kind, id: rootB.id }, + at: "1999-01-01T00:00:00.000Z", + }, + }, + ], + ] + : []), + ]; + + // MUTATION CHECK: deciding and writing one item at a time in + // `executeNodeReparentBatch` leaves `first` under `rootB` after + // each of these — verified and reverted. + for (const items of refusedBatches) { + await store.transaction(async (tx) => { + const parts = requireDefined(tx.getNodeCollection(kind)); + await expect(parts.bulkReparent(items)).rejects.toThrow(); + }); + expect(await liveLinks()).toEqual(before); + } + expect(before).toEqual( + new Set([`${first.id}->${rootA.id}`, `${second.id}->${rootA.id}`]), + ); + expect( + await edges.find({}, { temporalMode: "includeEnded" }), + ).toHaveLength(2); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("judges acyclicity on the state the whole batch produces, in either item order", async () => { + const { store, rootA, first, liveLinks } = await seed(); + const nodes = requireDefined(store.getNodeCollection(kind)); + const leaf = await nodes.create( + {}, + { partOf: { whole: { kind, id: first.id } } }, + ); + + // `first` moves under its own child while that child moves out from + // under it: a cycle only if the first item is judged alone. + const results = await nodes.bulkReparent([ + { id: first.id, options: { whole: { kind, id: leaf.id } } }, + { id: leaf.id, options: { whole: { kind, id: rootA.id } } }, + ]); + + expect(results.map((result) => result.moved)).toEqual([true, true]); + const links = await liveLinks(); + expect(links.has(`${first.id}->${leaf.id}`)).toBe(true); + expect(links.has(`${leaf.id}->${rootA.id}`)).toBe(true); + expect(links.has(`${leaf.id}->${first.id}`)).toBe(false); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses a batch that names one part twice, moving nothing", async () => { + const { store, rootA, rootB, first, liveLinks } = await seed(); + const nodes = requireDefined(store.getNodeCollection(kind)); + const before = await liveLinks(); + + await expect( + nodes.bulkReparent([ + { id: first.id, options: { whole: { kind, id: rootB.id } } }, + { id: first.id, options: { whole: { kind, id: rootA.id } } }, + ]), + ).rejects.toBeInstanceOf(ValidationError); + + expect(await liveLinks()).toEqual(before); + }); + }, + ); + + it("bulkCreate attaches more parts than one acyclicity statement can carry", async () => { + const graph = buildGraph(nextGraphId()); + const store = await context.createStore(graph); + const bindBudget = 100; + const partCount = 30; + const budgeted = createStore( + graph, + withBindBudget(store.backend, bindBudget), + ); + const show = await store.nodes.CaShow.create({}); + + // MUTATION CHECK: sending every prepared composition edge in one probe + // statement (`readUnwrittenEdgeReaches` never slicing) binds more than + // the budget for these 30 parts, and the budgeted backend refuses the + // statement — verified and reverted. + await budgeted.nodes.CaClip.bulkCreate( + Array.from({ length: partCount }, () => ({ + props: {}, + partOf: { whole: { kind: "CaShow", id: show.id } }, + })), + ); + + expect(await store.edges.caClipOf.find({})).toHaveLength(partCount); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("reparent inside a transaction counts as ONE node write intent", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const anthology = await store.nodes.CaAnthology.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: drop `reparent` from `NODE_WRITE_NAMES` + // (src/store/collection-surface.ts) and the sealed transaction + // collection stops counting the move at all — `writes.nodes` comes back + // `{}` and `total` 0, while the move itself still lands. + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.CaChapter.reparent(chapter.id, { + whole: { kind: "CaAnthology", id: anthology.id }, + via: "caIncludedIn", + }); + }); + + // ONE intent for the caller's one call: the retire and the attach are + // this operation's own row work, not two collection-surface writes. + expect(receipt.writes.nodes).toEqual({ CaChapter: 1 }); + expect(receipt.writes.edges).toEqual({}); + expect(receipt.writes.total).toBe(1); + expect(receipt.cascadedParts).toEqual([]); + + const moved = await store.edges.caIncludedIn.find({}); + expect(moved).toHaveLength(1); + expect(requireDefined(moved[0]).toId).toBe(anthology.id); + expect(await store.edges.caChapterOf.find({})).toHaveLength(0); + }); + + it("bulkDelete naming a part before its own whole deletes each node once and reports each cascaded part once", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaFolder.create({}); + const child = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: root.id } } }, + ); + const leaf = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: child.id } } }, + ); + + // MUTATION CHECK: drop `withoutPlannedDeleteEffects` from + // `planCascadingNodeDelete` (src/store/operations/node-operations.ts) — + // the root's plan, read before any delete of the batch ran, still names + // the child and the leaf, so both are deleted a second time and + // `cascadedParts` reads [leaf, leaf, child]. + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.CaFolder.bulkDelete([child.id, root.id]); + }); + + expect(receipt.cascadedParts).toEqual([ + { + kind: "CaFolder", + id: leaf.id, + whole: { kind: "CaFolder", id: child.id }, + viaEdgeKind: "caParentFolder", + }, + ]); + expect(await store.nodes.CaFolder.find({})).toEqual([]); + expect(await store.edges.caParentFolder.find({})).toEqual([]); + }); + + it("bulkDelete naming a whole before its own part reports the part as cascaded once", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.CaFolder.create({}); + const child = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: root.id } } }, + ); + const leaf = await store.nodes.CaFolder.create( + {}, + { partOf: { whole: { kind: "CaFolder", id: child.id } } }, + ); + + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.CaFolder.bulkDelete([root.id, child.id]); + }); + + expect(receipt.cascadedParts).toEqual([ + { + kind: "CaFolder", + id: leaf.id, + whole: { kind: "CaFolder", id: child.id }, + viaEdgeKind: "caParentFolder", + }, + { + kind: "CaFolder", + id: child.id, + whole: { kind: "CaFolder", id: root.id }, + viaEdgeKind: "caParentFolder", + }, + ]); + expect(await store.nodes.CaFolder.find({})).toEqual([]); + }); + + // ======================================================== + // `partOf` on get-or-create is a POSTCONDITION + // ======================================================== + + it("getOrCreateByConstraint with partOf is idempotent when the whole already matches", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const attachment = { + whole: book, + via: "caChapterOf", + props: { order: 1 }, + }; + + const first = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { partOf: attachment }, + ); + expect(first.action).toBe("created"); + + // MUTATION CHECK: restore the unconditional refusal (throw + // `CompositionExistenceError` whenever `partOf` is stated against a + // found/updated node — `decideCompositionIncumbent`'s satisfied arm, + // src/store/operations/composition-create.ts) — this second call then + // rejects instead of returning `"found"`. + const second = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { partOf: attachment }, + ); + expect(second.action).toBe("found"); + expect(second.node.id).toBe(first.node.id); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + }); + + it("getOrCreateByConstraint with partOf refuses a DIFFERENT live whole, naming both", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const anthology = await store.nodes.CaAnthology.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + const error = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { partOf: { whole: { kind: "CaAnthology", id: anthology.id } } }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(CompositionExistenceError); + expect((error as CompositionExistenceError).code).toBe( + "COMPOSITION_WHOLE_CONFLICT", + ); + const details = (error as CompositionExistenceError).details; + expect(details.situation).toBe("existing"); + expect(details.partId).toBe(chapter.id); + expect(details.currentWhole).toEqual({ + kind: "CaBook", + id: book.id, + }); + expect(details.requestedWhole).toEqual({ + kind: "CaAnthology", + id: anthology.id, + }); + // Refused, not moved. + expect(await store.edges.caIncludedIn.find({})).toHaveLength(0); + }); + + it("getOrCreateByConstraint with partOf refuses the same whole reached through a different `via`", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: drop the realizing-edge conjunct from + // `incumbentHoldsRequestedAttachment` + // (src/store/operations/composition-create.ts) — the call then reports + // success while the node hangs off `caChapterOf`, not the + // `caDraftChapterOf` the caller asked for. + const error = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caDraftChapterOf", + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(CompositionExistenceError); + const details = (error as CompositionExistenceError).details; + expect(details.currentVia).toBe("caChapterOf"); + expect(details.requestedVia).toBe("caDraftChapterOf"); + expect(await store.edges.caDraftChapterOf.find({})).toHaveLength(0); + }); + + it("getOrCreateByConstraint with partOf attaches a found node that has NO live whole", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + // An optional part created bare — legal, and exactly the state the + // postcondition has to repair rather than refuse. + const chapter = await store.nodes.CaChapter.create({ slug: "one" }); + expect(await store.edges.caChapterOf.find({})).toHaveLength(0); + + // MUTATION CHECK: restore the unconditional refusal — this rejects + // with `CompositionExistenceError` and no edge is ever written. + const result = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 7 }, + }, + }, + ); + + expect(result.action).toBe("found"); + expect(result.node.id).toBe(chapter.id); + const edges = await store.edges.caChapterOf.find({}); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).fromId).toBe(chapter.id); + expect(requireDefined(edges[0]).order).toBe(7); + }); + + it("getOrCreateByConstraint refuses an AMBIGUOUS partOf on a found node, exactly as on a create", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + // Already attached through ONE of the two declared pairs — the state + // that used to let the satisfied arm answer "any via will do". + await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: resolve the attachment only after the incumbent is + // read — move `resolveGetOrCreateAttachmentRequest`'s call + // (src/store/operations/node-operations.ts) below the satisfied arm, or + // drop the `resolveCompositionAttachment` call out of + // `resolveCompositionCreate` (src/store/operations/composition-create.ts) + // — this call then returns + // `{ action: "found" }` with no error, while the same `partOf` on a + // create refuses. + await expect( + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { partOf: { whole: { kind: "CaBook", id: book.id } } }, + ), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_VIA_AMBIGUOUS" }), + }), + ); + }); + + it("getOrCreateByConstraint refuses an UNDECLARED whole kind on a found node as a ConfigurationError, not a contradiction", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const reader = await store.nodes.CaReader.create({}); + await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: as above — resolving only on the no-whole arm makes + // this a `CompositionExistenceError` (`situation: "existing"`) naming + // `CaReader` as the requested whole, and its suggestion tells the + // caller to `reparent` to a whole `reparent` itself refuses. + const error = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "one" }, + { + partOf: { + // @ts-expect-error CaReader is not a whole kind declared for CaChapter + whole: { kind: "CaReader", id: reader.id }, + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect(error).not.toBeInstanceOf(CompositionExistenceError); + expect((error as ConfigurationError).details).toEqual( + matchingObject({ code: "COMPOSITION_WHOLE_NOT_DECLARED" }), + ); + }); + + it("bulkGetOrCreateByConstraint discharges the postcondition per item: idempotent hit beside an unattached repair", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const attachment = { + whole: book, + via: "caChapterOf", + props: { order: 1 }, + }; + // Item "a" already holds this exact attachment — same via, same + // props; item "b" exists with NO whole at all. One batch, two + // different dispositions. + const attached = await store.nodes.CaChapter.create( + { slug: "a" }, + { partOf: attachment }, + ); + const bare = await store.nodes.CaChapter.create({ slug: "b" }); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + + // MUTATION CHECK: stop resolving the attachment on the bulk entry's + // existing-row legs (drop either + // `resolveGetOrCreateAttachmentRequest` call in + // `executeNodeBulkGetOrCreateByConstraint`, + // src/store/operations/node-operations.ts) — item "b" then comes back + // `"found"` with no edge written, and the edge count below stays 1. + const results = await store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "a" } }, { props: { slug: "b" } }], + { partOf: attachment }, + ); + expect(results.map((entry) => entry.action)).toEqual(["found", "found"]); + + const edges = await store.edges.caChapterOf.find({}); + expect(edges).toHaveLength(2); + // "a"'s satisfied hit is genuinely idempotent — the batch's stated + // props are the same value it already held — and "b"'s brand-new + // edge is written fresh with the same props. + expect( + requireDefined(edges.find((edge) => edge.fromId === attached.id)).order, + ).toBe(1); + expect( + requireDefined(edges.find((edge) => edge.fromId === bare.id)).order, + ).toBe(1); + }); + + it("getOrCreateByConstraint refuses a satisfied match's DIFFERENT props rather than silently keeping the stored value", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: remove the `assertSatisfiedPartOfPropsHonored` call + // from `decideCompositionIncumbent`'s satisfied arm + // (src/store/operations/composition-create.ts) — this then resolves + // `"found"` with the edge silently left at `order: 1`, and the + // assertions below fail. Widening the lock-free pre-check in + // `applyExistingPartOfPostcondition` (node-operations.ts) to skip the + // fence when `props` are stated does the same. + const error = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 2 }, + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(CompositionExistenceError); + expect((error as CompositionExistenceError).code).toBe( + "COMPOSITION_PROPS_CONFLICT", + ); + expect((error as CompositionExistenceError).details).toEqual( + matchingObject({ + situation: "props", + edgeKind: "caChapterOf", + currentProps: matchingObject({ order: 1 }), + requestedProps: matchingObject({ order: 2 }), + }), + ); + + // The refusal did not partially apply the stated props. + const chapterEdges = await store.edges.caChapterOf.find({}); + const edge = requireDefined( + chapterEdges.find((candidate) => candidate.fromId === chapter.id), + ); + expect(edge.order).toBe(1); + }); + + it("getOrCreateByConstraint validates a satisfied match's props against the edge schema even though it writes nothing", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + await store.nodes.CaChapter.create( + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: same call site as above — without the validation + // step this resolves `"found"` instead of throwing `ValidationError`. + const error = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: "not-a-number" }, + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ValidationError); + }); + + it("reparent's no-op arm honors stated props the same way: refuses a valid but DIFFERENT value", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + // MUTATION CHECK: remove the `assertSatisfiedPartOfPropsHonored` call + // from `decideCompositionIncumbent`'s satisfied arm + // (src/store/operations/composition-create.ts) — reparent's no-op arm + // then resolves silently, leaving the edge at `order: 1`. + const error = await store.nodes.CaChapter.reparent(chapter.id, { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 2 }, + }).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(CompositionExistenceError); + expect((error as CompositionExistenceError).details).toEqual( + matchingObject({ + situation: "props", + edgeKind: "caChapterOf", + currentProps: matchingObject({ order: 1 }), + requestedProps: matchingObject({ order: 2 }), + }), + ); + + const chapterEdges = await store.edges.caChapterOf.find({}); + const edge = requireDefined( + chapterEdges.find((candidate) => candidate.fromId === chapter.id), + ); + expect(edge.order).toBe(1); + }); + + it("bulkGetOrCreateByConstraint refuses the whole batch when ONE item's found node holds a different whole", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const anthology = await store.nodes.CaAnthology.create({}); + await store.nodes.CaChapter.create( + { slug: "a" }, + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + // "b" holds a DIFFERENT whole, through a different realizing edge. + const elsewhere = await store.nodes.CaChapter.create( + { slug: "b" }, + { partOf: { whole: { kind: "CaAnthology", id: anthology.id } } }, + ); + + // Same props "a" already holds (order: 1): the batch's mismatch is + // "b"'s whole alone, isolating `situation: "existing"` from the + // `situation: "props"` refusal covered separately above. + // + // MUTATION CHECK: as in the previous case — with the bulk + // postcondition call sites disabled this batch resolves silently to + // two `"found"` results and leaves "b" hanging off the anthology. + const error = await store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "a" } }, { props: { slug: "b" } }], + { + partOf: { + whole: { kind: "CaBook", id: book.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(CompositionExistenceError); + const details = (error as CompositionExistenceError).details; + expect(details.situation).toBe("existing"); + expect(details.partId).toBe(elsewhere.id); + expect(details.currentWhole).toEqual({ + kind: "CaAnthology", + id: anthology.id, + }); + expect(details.requestedWhole).toEqual({ kind: "CaBook", id: book.id }); + // Refused, not moved: "b" keeps the anthology and gains no book edge. + expect(await store.edges.caIncludedIn.find({})).toHaveLength(1); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + }); + + it("bulkGetOrCreateByConstraint creates nothing when a matched item's refusal is caught inside an enclosing transaction", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const held = await store.nodes.CaBook.create({}); + const requested = await store.nodes.CaBook.create({}); + await store.nodes.CaChapter.create( + { slug: "held" }, + { + partOf: { + whole: { kind: "CaBook", id: held.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + let caught: unknown; + // MUTATION CHECK: run the batch's creates before its matched items + // (swap steps 5 and 6 of `executeNodeBulkGetOrCreateByConstraint`, + // src/store/operations/node-operations.ts) — "fresh" is then created + // and attached before "held" refuses, and survives the caught refusal. + await store.transaction(async (tx) => { + try { + await tx.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "fresh" } }, { props: { slug: "held" } }], + { + partOf: { + whole: { kind: "CaBook", id: requested.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + const chapters = await store.nodes.CaChapter.find({}); + expect(chapters.map((chapter) => chapter.slug)).toEqual(["held"]); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + }); + + // -------------------------------------------------------- + // A batch reaches every matched item's verdict before its first write + // + // Each case catches the refusal INSIDE an enclosing transaction and + // commits: the operations layer has no savepoints, so anything the batch + // wrote before refusing would stay. MUTATION CHECK, shared by all of + // them: drop the `assertGetOrCreateBatchApplicable` call from + // `executeNodeBulkGetOrCreateByConstraint` + // (src/store/operations/node-operations.ts) — the item listed FIRST is + // then written before the later item refuses, and survives. + // -------------------------------------------------------- + + it("bulkGetOrCreateByConstraint attaches no earlier found item when a later one is refused and the caller catches it in a transaction", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const held = await store.nodes.CaBook.create({}); + const requested = await store.nodes.CaBook.create({}); + const bare = await store.nodes.CaChapter.create({ slug: "bare" }); + await store.nodes.CaChapter.create( + { slug: "held" }, + { + partOf: { + whole: { kind: "CaBook", id: held.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "bare" } }, { props: { slug: "held" } }], + { + partOf: { + whole: { kind: "CaBook", id: requested.id }, + via: "caChapterOf", + props: { order: 2 }, + }, + }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + expect((caught as CompositionExistenceError).details.situation).toBe( + "existing", + ); + // "bare" was never attached: the one edge is still "held"'s own. + const edges = await store.edges.caChapterOf.find({}); + expect(edges.map((edge) => edge.toId)).toEqual([held.id]); + expect(edges.some((edge) => edge.fromId === bare.id)).toBe(false); + }); + + it("bulkGetOrCreateByConstraint refuses a satisfied item's different props before attaching an earlier item", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const bare = await store.nodes.CaChapter.create({ slug: "bare" }); + await store.nodes.CaChapter.create( + { slug: "attached" }, + { + partOf: { whole: book, via: "caChapterOf", props: { order: 1 } }, + }, + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "bare" } }, { props: { slug: "attached" } }], + { + partOf: { whole: book, via: "caChapterOf", props: { order: 2 } }, + }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + expect((caught as CompositionExistenceError).details.situation).toBe( + "props", + ); + const edges = await store.edges.caChapterOf.find({}); + expect(edges).toHaveLength(1); + expect(edges.some((edge) => edge.fromId === bare.id)).toBe(false); + }); + + it("bulkGetOrCreateByConstraint judges acyclicity over the whole batch before attaching any item", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const outer = await store.nodes.CaNest.create({ slug: "outer" }); + const inner = await store.nodes.CaNest.create({ slug: "inner" }); + + // The batch's shared whole is one of its own matched items: "inner" + // may go under "outer", but "outer" under itself is a self-loop. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaNest.bulkGetOrCreateByConstraint( + "ca_nest_slug", + [{ props: { slug: "inner" } }, { props: { slug: "outer" } }], + { partOf: { whole: outer } }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(EdgeAcyclicityError); + expect(await store.edges.caNestUnder.find({})).toEqual([]); + expect(inner.id).not.toBe(outer.id); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" updates and attaches no earlier item when a later one is refused', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const held = await store.nodes.CaBook.create({}); + const requested = await store.nodes.CaBook.create({}); + const bare = await store.nodes.CaChapter.create({ slug: "bare" }); + await store.nodes.CaChapter.create( + { slug: "held" }, + { + partOf: { + whole: { kind: "CaBook", id: held.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "bare" } }, { props: { slug: "held" } }], + { + ifExists: "update", + partOf: { + whole: { kind: "CaBook", id: requested.id }, + via: "caChapterOf", + props: { order: 2 }, + }, + }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + // Neither half of "bare"'s leg landed: no property update (the row's + // version is the one its create wrote) and no attachment. + const after = requireDefined( + await store.nodes.CaChapter.getById(bare.id), + ); + expect(after.meta.version).toBe(bare.meta.version); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + }); + + it("bulkGetOrCreateByConstraint resurrects no earlier tombstone when a later item is refused", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const held = await store.nodes.CaBook.create({}); + const requested = await store.nodes.CaBook.create({}); + const gone = await store.nodes.CaChapter.create({ slug: "gone" }); + await store.nodes.CaChapter.delete(gone.id); + await store.nodes.CaChapter.create( + { slug: "held" }, + { + partOf: { + whole: { kind: "CaBook", id: held.id }, + via: "caChapterOf", + props: { order: 1 }, + }, + }, + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "gone" } }, { props: { slug: "held" } }], + { + partOf: { + whole: { kind: "CaBook", id: requested.id }, + via: "caChapterOf", + props: { order: 2 }, + }, + }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + expect(await store.nodes.CaChapter.getById(gone.id)).toBeUndefined(); + expect(await store.edges.caChapterOf.find({})).toHaveLength(1); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" updates no earlier item when a later update loses a unique key', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + await store.nodes.CaStamp.create({ code: "b", label: "two" }); + const holder = await store.nodes.CaStamp.create({ + code: "c", + label: "taken", + }); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "renamed" } }, + { props: { code: "b", label: "taken" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(holder.id); + const after = requireDefined(await store.nodes.CaStamp.getById(first.id)); + expect(after.label).toBe("one"); + expect(after.meta.version).toBe(first.meta.version); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" refuses two items claiming one unique key, updating neither', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + const second = await store.nodes.CaStamp.create({ + code: "b", + label: "two", + }); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "shared" } }, + { props: { code: "b", label: "shared" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(first.id); + const labels = await Promise.all( + [first, second].map(async (stamp) => { + const row = await store.nodes.CaStamp.getById(stamp.id); + return requireDefined(row).label; + }), + ); + expect(labels).toEqual(["one", "two"]); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" lets a later item take a unique key an earlier item of the batch gives up', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + const second = await store.nodes.CaStamp.create({ + code: "b", + label: "two", + }); + + // MUTATION CHECK: judge each update against the stored claims alone + // (drop the pending-release overlay in `assertNodeClaimTransitionsAvailable`, + // src/store/claims/node-claims.ts) — "b" is then refused for a key "a" + // stops holding in this same batch. + const results = await store.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "moved" } }, + { props: { code: "b", label: "one" } }, + ], + { ifExists: "update" }, + ); + + expect(results.map((entry) => entry.action)).toEqual([ + "updated", + "updated", + ]); + expect(results.map((entry) => entry.node.label)).toEqual([ + "moved", + "one", + ]); + expect(results.map((entry) => entry.node.id)).toEqual([ + first.id, + second.id, + ]); + }); + + // -------------------------------------------------------- + // The same rule for what a batch is about to CREATE, and for a + // resurrection a disjoint kind now blocks + // -------------------------------------------------------- + + it("bulkGetOrCreateByConstraint attaches no matched part when a new row's attachment exceeds the whole's bound", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const rack = await store.nodes.CaRack.create({}); + await store.nodes.CaSlot.create({ slug: "a" }); + + // The rack holds ONE slot: the matched "a" and the new row together + // exceed it, and only the batch counted as a whole sees that. + // + // MUTATION CHECK: leave the rows to create out of the moves + // `assertGetOrCreateBatchApplicable` counts + // (`newRowAttachmentMoves`, src/store/operations/node-operations.ts) — + // "a" is then attached before the create batch refuses "new", and + // survives the caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaSlot.bulkGetOrCreateByConstraint( + "ca_slot_slug", + [{ props: { slug: "a" } }, { props: { slug: "new" } }], + { partOf: { whole: rack } }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CardinalityError); + expect(await store.edges.caSlotIn.find({})).toEqual([]); + const slots = await store.nodes.CaSlot.find({}); + expect(slots.map((slot) => slot.slug)).toEqual(["a"]); + }); + + it("bulkGetOrCreateByConstraint resurrects and updates no earlier item when a later tombstone's id is held by a disjoint kind", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const live = await store.nodes.CaGhost.create({ + slug: "live", + note: "before", + }); + const gone = await store.nodes.CaGhost.create( + { slug: "gone" }, + { id: "shared-id" }, + ); + await store.nodes.CaGhost.delete(gone.id); + await store.nodes.CaSpirit.create({}, { id: gone.id }); + + // The refusal is a read (`checkDisjointnessConstraint`, the answer a + // create gets), so it must precede the "live" row's update. + // + // MUTATION CHECK: drop the `checkDisjointnessConstraint` read for a + // restoring leg in `assertGetOrCreateBatchApplicable` + // (src/store/operations/node-operations.ts) — "live" is then updated + // before the resurrection's claim refuses, and survives the caught + // refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaGhost.bulkGetOrCreateByConstraint( + "ca_ghost_slug", + [ + { props: { slug: "live", note: "changed" } }, + { props: { slug: "gone" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(DisjointError); + const after = requireDefined(await store.nodes.CaGhost.getById(live.id)); + expect(after.note).toBe("before"); + expect(after.meta.version).toBe(live.meta.version); + expect(await store.nodes.CaGhost.getById(gone.id)).toBeUndefined(); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" lets a new row take a unique key a matched item gives up', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + + // The create batch runs after the matched update, which is what frees + // the label "one" for the new row. A frame that prepared the new row + // against the stored claims alone would refuse it. + // + // MUTATION CHECK: prepare the rows to create inside + // `assertGetOrCreateBatchApplicable` against the stored claims + // (`prepareBatchCreates(ctx, creates, target, ...)`) — "new" is then + // refused for a key "a" stops holding in this same batch. + const results = await store.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "renamed" } }, + { props: { code: "new", label: "one" } }, + ], + { ifExists: "update" }, + ); + + expect(results.map((entry) => entry.action)).toEqual([ + "updated", + "created", + ]); + expect(results.map((entry) => entry.node.label)).toEqual([ + "renamed", + "one", + ]); + expect(results[0]?.node.id).toBe(first.id); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" updates no matched item when a new row loses a second unique key', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + const holder = await store.nodes.CaStamp.create({ + code: "b", + label: "taken", + }); + + // The lookup reads `code`; the new row loses on `label`, which only its + // own claims decide. + // + // MUTATION CHECK: leave the rows to create out of the claim transitions + // `assertGetOrCreateBatchApplicable` judges (`newRowClaimTransitions`, + // src/store/operations/node-operations.ts) — "a" is then updated before + // the create batch refuses "new", and keeps the new label after the + // caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "renamed" } }, + { props: { code: "new", label: "taken" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(holder.id); + const after = requireDefined(await store.nodes.CaStamp.getById(first.id)); + expect(after.label).toBe("one"); + expect(after.meta.version).toBe(first.meta.version); + const stamps = await store.nodes.CaStamp.find({}); + expect(stamps.map((stamp) => stamp.code).toSorted()).toEqual(["a", "b"]); + }); + + it('bulkGetOrCreateByConstraint with ifExists "update" refuses a new row a key a matched item of the batch takes, updating nothing', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const first = await store.nodes.CaStamp.create({ + code: "a", + label: "one", + }); + + // The matched update takes "fresh" first, so the new row that asks for + // it after loses — judged in the order the batch writes, before either. + // + // MUTATION CHECK: as above, leave the rows to create out of the claim + // transitions `assertGetOrCreateBatchApplicable` judges — "a" is then + // updated before the create batch refuses "new". + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.CaStamp.bulkGetOrCreateByConstraint( + "ca_stamp_code", + [ + { props: { code: "a", label: "fresh" } }, + { props: { code: "new", label: "fresh" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(UniquenessError); + expect((caught as UniquenessError).details.existingId).toBe(first.id); + const after = requireDefined(await store.nodes.CaStamp.getById(first.id)); + expect(after.label).toBe("one"); + expect(after.meta.version).toBe(first.meta.version); + expect(await store.nodes.CaStamp.find({})).toHaveLength(1); + }); + + // ======================================================== + // The attachment object itself: `whole`, and nothing unstated + // ======================================================== + + it("reads only kind and id from a whole passed as a node whose own properties are named like attachment options", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const vaultA = await store.nodes.CaVault.create(CA_VAULT_PROPS); + const vaultB = await store.nodes.CaVault.create(CA_VAULT_PROPS); + + // MUTATION CHECK: read the options off the whole as well — in + // `readStatedAttachment` (src/store/operations/composition-create.ts) + // build `attachment` from `{ ...stated.whole, ...stated }`. The vault's + // own `via` ("caChapterOf") then names a realizing edge that is not + // declared for this pair and the create refuses with + // COMPOSITION_VIA_NOT_DECLARED. + const relic = await store.nodes.CaRelic.create( + {}, + { partOf: { whole: vaultA } }, + ); + + const createdEdges = await store.edges.caRelicOf.find({}); + const created = requireDefined(createdEdges[0], "the realizing edge"); + expect(created.toKind).toBe("CaVault"); + expect(created.toId).toBe(vaultA.id); + expect(created.order).toBeUndefined(); + expect(created.meta.validFrom).not.toBe(CA_VAULT_PROPS.validFrom); + expect(created.meta.validTo).toBeUndefined(); + + const moved = await store.nodes.CaRelic.reparent(relic.id, { + whole: vaultB, + }); + expect(moved.moved).toBe(true); + expect(moved.edge.toId).toBe(vaultB.id); + expect(moved.edge.meta.validTo).toBeUndefined(); + + // The already-satisfied arm compares stated props against the stored + // ones; the vault's own `props` must not count as stated. + const again = await store.nodes.CaRelic.reparent(relic.id, { + whole: vaultB, + }); + expect(again.moved).toBe(false); + }); + + it("get-or-create states the attachment's window on every leg that writes the edge", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + // `caChapterOf` is `population: "one"`: a bounded window still attaches + // the part, for the row's whole life. + const window = { + validFrom: "2021-01-01T00:00:00.000Z", + validTo: "2022-01-01T00:00:00.000Z", + }; + const partOf = { + whole: book, + via: "caChapterOf", + props: { order: 1 }, + ...window, + }; + const windowOf = async (slug: string) => { + const chapter = await store.nodes.CaChapter.findByConstraint( + "ca_chapter_slug", + { slug }, + ); + const edges = await store.edges.caChapterOf.find( + { from: requireDefined(chapter) }, + { temporalMode: "includeEnded" }, + ); + const edge = requireDefined(edges[0], `the edge of "${slug}"`); + return { + validFrom: edge.meta.validFrom, + validTo: edge.meta.validTo, + }; + }; + + await store.nodes.CaChapter.create({ slug: "single" }); + await store.nodes.CaChapter.create({ slug: "bulk" }); + const doomed = await store.nodes.CaChapter.create({ slug: "revived" }); + await store.nodes.CaChapter.delete(doomed.id); + + // MUTATION CHECK: pass `{}` instead of + // `decided.request.work.edgeWindow` in + // `prepareCompositionAttachmentDecision` + // (src/store/operations/node-operations.ts) — each edge below is then + // opened at the clock and never ends. + const single = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "single" }, + { ifExists: "update", partOf }, + ); + expect(single.action).toBe("updated"); + expect(await windowOf("single")).toEqual(window); + + const bulk = await store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "bulk" } }], + { ifExists: "update", partOf }, + ); + expect(bulk.map((entry) => entry.action)).toEqual(["updated"]); + expect(await windowOf("bulk")).toEqual(window); + + const revived = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "revived" }, + { ifExists: "update", partOf }, + ); + expect(revived.action).toBe("resurrected"); + expect(await windowOf("revived")).toEqual(window); + }); + + it("verifies a stated window against an already-satisfied attachment instead of dropping it", async () => { + const stored = { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2022-01-01T00:00:00.000Z", + } as const; + const conflicting = { + validFrom: "2021-06-01T00:00:00.000Z", + validTo: "2023-06-01T00:00:00.000Z", + } as const; + const held = { via: "caChapterOf", props: { order: 1 } } as const; + const fields = ["validFrom", "validTo"] as const; + + for (const field of fields) { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf: { whole: book, ...held, ...stored } }, + ); + const windows = async () => { + const edges = await store.edges.caChapterOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + return edges.map((edge) => ({ + validFrom: edge.meta.validFrom, + validTo: edge.meta.validTo, + })); + }; + + // MUTATION CHECK: drop the window comparison from + // `judgeSatisfiedAttachment` + // (src/store/operations/composition-create.ts) — every call below + // then resolves, with the conflicting bound silently discarded. + const partOf = { + whole: book, + via: "caChapterOf", + [field]: conflicting[field], + }; + const calls: readonly (() => Promise)[] = [ + () => + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf }, + ), + () => + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { ifExists: "update", partOf }, + ), + () => + store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "a" } }], + { partOf }, + ), + () => + store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "a" } }], + { ifExists: "update", partOf }, + ), + () => + store.transaction((tx) => + tx.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf }, + ), + ), + ]; + for (const call of calls) { + const error = await call().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).details.issues).toEqual([ + expect.objectContaining({ + path: `partOf.${field}`, + code: "COMPOSITION_ATTACHMENT_WINDOW_CONFLICT", + }), + ]); + expect(await windows()).toEqual([stored]); + } + + // The stored window restated is verified equal: satisfied, no write. + const restated = await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf: { whole: book, ...held, ...stored } }, + ); + expect(restated.action).toBe("found"); + expect(await windows()).toEqual([stored]); + } + }); + + it("validates a stated window on an already-satisfied attachment exactly as create does", async () => { + const malformed: readonly Readonly<{ + validFrom?: string; + validTo?: string; + }>[] = [ + { validFrom: "garbage" }, + { validTo: "garbage" }, + { + validFrom: "2090-01-01T00:00:00.000Z", + validTo: "2020-01-01T00:00:00.000Z", + }, + ]; + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const base = { whole: book, via: "caChapterOf", props: { order: 1 } }; + await store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf: base }, + ); + + // MUTATION CHECK: remove the window validation from + // `readStatedAttachment` (src/store/operations/composition-create.ts) — + // the two get-or-create calls below then report a window CONFLICT with + // the held edge rather than the malformed input create refuses. + for (const window of malformed) { + const partOf = { ...base, ...window }; + const calls: readonly (() => Promise)[] = [ + () => store.nodes.CaChapter.create({ slug: "fresh" }, { partOf }), + () => + store.nodes.CaChapter.bulkCreate([ + { props: { slug: "fresh" }, partOf }, + ]), + () => + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "a" }, + { partOf }, + ), + () => + store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "a" } }], + { partOf }, + ), + ]; + const refusals: unknown[] = []; + for (const call of calls) { + const error = await call().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + refusals.push( + (error as ValidationError).details.issues.map((issue) => ({ + path: issue.path, + code: issue.code, + })), + ); + } + // One validation, so the satisfied legs name the same fault create does. + expect( + new Set(refusals.map((issues) => JSON.stringify(issues))), + ).toEqual(new Set([JSON.stringify(refusals[0])])); + } + expect(await store.nodes.CaChapter.find({})).toHaveLength(1); + }); + + it("refuses a bounded window on a oneActive pair on every partOf surface and writes nothing", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.CaShow.create({}); + const other = await store.nodes.CaShow.create({}); + const existing = await store.nodes.CaReel.create({ slug: "bare" }); + + // MUTATION CHECK: drop `assertStatedWindowAttachesPart` from + // `compositionCreateWork` (src/store/operations/composition-create.ts) — + // every call below then writes an edge the incumbent read cannot see, + // so the repeat inserts a second one and `other` is accepted beside it. + for (const validTo of [ + "2022-01-01T00:00:00.000Z", + "2023-06-01T00:00:00.000Z", + ]) { + const partOf = { whole: show, validTo }; + const calls: readonly (() => Promise)[] = [ + () => store.nodes.CaReel.create({ slug: "create" }, { partOf }), + () => + store.nodes.CaReel.bulkCreate([ + { props: { slug: "bulk" }, partOf }, + ]), + () => + store.nodes.CaReel.getOrCreateByConstraint( + "ca_reel_slug", + { slug: "get-or-create" }, + { partOf }, + ), + () => + store.nodes.CaReel.getOrCreateByConstraint( + "ca_reel_slug", + { slug: "bare" }, + { partOf }, + ), + () => + store.nodes.CaReel.bulkGetOrCreateByConstraint( + "ca_reel_slug", + [{ props: { slug: "bare" } }, { props: { slug: "bulk-get" } }], + { partOf }, + ), + ]; + for (const call of calls) { + const error = await call().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).details.issues).toEqual([ + expect.objectContaining({ + path: "partOf.validTo", + code: "COMPOSITION_ATTACHMENT_WINDOW_BOUNDED", + }), + ]); + } + } + const reels = await store.nodes.CaReel.find({}); + expect(reels.map((reel) => reel.id)).toEqual([existing.id]); + expect( + await store.edges.caReelOf.find({}, { temporalMode: "includeEnded" }), + ).toEqual([]); + + // The open attachment IS found again, and a different whole refuses. + const attach = () => + store.nodes.CaReel.getOrCreateByConstraint( + "ca_reel_slug", + { slug: "bare" }, + { partOf: { whole: show } }, + ); + await attach(); + await attach(); + expect( + await store.edges.caReelOf.find({}, { temporalMode: "includeEnded" }), + ).toHaveLength(1); + await expect( + store.nodes.CaReel.getOrCreateByConstraint( + "ca_reel_slug", + { slug: "bare" }, + { partOf: { whole: other } }, + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ situation: "existing" }), + }), + ); + }); + + it("refuses the flat { kind, id } attachment on every surface and writes nothing", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + const other = await store.nodes.CaBook.create({}); + const chapter = await store.nodes.CaChapter.create( + { slug: "held" }, + { partOf: { whole: book, via: caChapterOf, props: { order: 1 } } }, + ); + const flat = { kind: "CaBook", id: other.id, via: "caChapterOf" }; + + // MUTATION CHECK: in `readStatedAttachment` + // (src/store/operations/composition-create.ts) fall back to the + // attachment object itself when `whole` is absent and stop refusing + // unknown keys — every call below then attaches or moves a chapter. + const attempts: readonly (readonly [string, () => Promise])[] = [ + [ + "partOf", + () => + store.nodes.CaChapter.create( + { slug: "create" }, + // @ts-expect-error the flat form does not compile + { partOf: flat }, + ), + ], + [ + "partOf", + () => + store.nodes.CaChapter.bulkCreate([ + // @ts-expect-error the flat form does not compile + { props: { slug: "bulk" }, partOf: flat }, + ]), + ], + [ + "partOf", + () => + store.nodes.CaChapter.getOrCreateByConstraint( + "ca_chapter_slug", + { slug: "get-or-create" }, + // @ts-expect-error the flat form does not compile + { partOf: flat }, + ), + ], + [ + "partOf", + () => + store.nodes.CaChapter.bulkGetOrCreateByConstraint( + "ca_chapter_slug", + [{ props: { slug: "held" } }, { props: { slug: "bulk-get" } }], + // @ts-expect-error the flat form does not compile + { partOf: flat }, + ), + ], + [ + "options", + () => + // @ts-expect-error the flat form does not compile + store.nodes.CaChapter.reparent(chapter.id, flat), + ], + [ + "options", + () => + store.nodes.CaChapter.bulkReparent([ + // @ts-expect-error the flat form does not compile + { id: chapter.id, options: flat }, + ]), + ], + ]; + + for (const [base, attempt] of attempts) { + const error = await attempt().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + expect( + (error as ValidationError).details.issues.map((issue) => issue.path), + ).toEqual([`${base}.kind`, `${base}.id`, `${base}.whole`]); + } + + const chapters = await store.nodes.CaChapter.find({}); + expect(chapters.map((node) => node.slug)).toEqual(["held"]); + const edges = await store.edges.caChapterOf.find({}); + expect(edges.map((edge) => edge.toId)).toEqual([book.id]); + }); + + it("refuses an unknown top-level attachment key instead of dropping it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const book = await store.nodes.CaBook.create({}); + + // MUTATION CHECK: drop the unknown-key issues from + // `readStatedAttachment` (src/store/operations/composition-create.ts) — + // the misspelled `prop` is then ignored, the chapter is created with + // the edge schema's own refusal (a missing `order`) instead of this + // one, and the issue path below no longer matches. + const error = await store.nodes.CaChapter.create( + { slug: "one" }, + { + partOf: { + whole: book, + via: caChapterOf, + // @ts-expect-error `prop` is not an attachment option + prop: { order: 1 }, + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).details.issues).toEqual([ + expect.objectContaining({ path: "partOf.prop" }), + ]); + expect(await store.nodes.CaChapter.find({})).toHaveLength(0); + }); + + it("accepts any whole kind string on a dynamic collection and leaves the refusal to the runtime", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const reader = await store.nodes.CaReader.create({}); + const chapters = requireDefined( + store.getNodeCollection("CaChapter"), + "the dynamic chapter collection", + ); + + await expect( + chapters.create( + { slug: "one" }, + { partOf: { whole: { kind: "CaReader", id: reader.id } } }, + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ code: "COMPOSITION_WHOLE_NOT_DECLARED" }), + }), + ); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-delete-plan.ts b/packages/typegraph/tests/backends/integration/composition-delete-plan.ts new file mode 100644 index 000000000..b937861a9 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-delete-plan.ts @@ -0,0 +1,772 @@ +/** + * `nodes..planDelete(id)`: the read half of a node delete, on every + * backend. + * + * Fixture shape: + * + * DpPodcast <-(dpEpisodeOf, partOf, population "one")-- DpEpisode + * DpEpisode <-(dpSegmentOf, partOf, population "oneActive")-- DpSegment + * DpPodcast / DpEpisode / DpSegment --(dpTagged, ordinary)--> DpTag + * + * The two levels differ in population on purpose: a `one` row counts against + * its whole for its whole life, ended or not, while an ended `oneActive` row + * is the history a reparent leaves behind. A preview that read the closure + * in one temporal mode would get one of the two levels wrong; the plan the + * delete runs gets both right, and these cases pin that the preview IS that + * plan. + * + * Each case states, in a comment, the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + type CompositionCascadedPart, + defineEdge, + defineGraph, + defineNode, + type NodeDeletePlan, + NodeDeletePlanMismatchError, + partOf, + RestrictedDeleteError, + UnsupportedBackendCapabilityError, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; +import { matchingObject } from "../../test-utils"; +import { type IntegrationTestContext } from "./test-context"; + +const DpPodcast = defineNode("DpPodcast", { + schema: z.object({ title: z.string() }), +}); +const DpEpisode = defineNode("DpEpisode", { + schema: z.object({ title: z.string() }), +}); +const DpSegment = defineNode("DpSegment", { + schema: z.object({ title: z.string() }), +}); +const DpTag = defineNode("DpTag", { schema: z.object({ name: z.string() }) }); + +const dpEpisodeOf = defineEdge("dpEpisodeOf", { schema: z.object({}) }); +const dpSegmentOf = defineEdge("dpSegmentOf", { schema: z.object({}) }); +const dpTagged = defineEdge("dpTagged", { schema: z.object({}) }); + +type DeleteBehavior = "restrict" | "cascade" | "disconnect"; + +function buildGraph( + id: string, + onDelete: Readonly<{ + podcast?: DeleteBehavior; + episode?: DeleteBehavior; + segment?: DeleteBehavior; + }> = {}, +) { + return defineGraph({ + id, + nodes: { + DpPodcast: { type: DpPodcast, onDelete: onDelete.podcast ?? "restrict" }, + DpEpisode: { type: DpEpisode, onDelete: onDelete.episode ?? "restrict" }, + DpSegment: { type: DpSegment, onDelete: onDelete.segment ?? "restrict" }, + DpTag: { type: DpTag }, + }, + edges: { + dpEpisodeOf: { + type: dpEpisodeOf, + from: [DpEpisode], + to: [DpPodcast], + cardinality: "one", + }, + dpSegmentOf: { + type: dpSegmentOf, + from: [DpSegment], + to: [DpEpisode], + cardinality: "oneActive", + }, + dpTagged: { + type: dpTagged, + from: [DpPodcast, DpEpisode, DpSegment], + to: [DpTag], + }, + }, + ontology: [ + partOf(DpEpisode, DpPodcast, { via: dpEpisodeOf }), + partOf(DpSegment, DpEpisode, { via: dpSegmentOf }), + ], + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `composition_delete_plan_${graphIdCounter}`; +} + +function cascadedPart( + part: Readonly<{ kind: string; id: string }>, + whole: Readonly<{ kind: string; id: string }>, + viaEdgeKind: string, +): CompositionCascadedPart { + return { + kind: part.kind, + id: part.id, + whole: { kind: whole.kind, id: whole.id }, + viaEdgeKind, + }; +} + +async function rejectionOf( + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + return error; + } + return undefined; +} + +function endedNow(): Readonly<{ validTo: string }> { + return { validTo: new Date().toISOString() }; +} + +export function registerCompositionDeletePlanIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Composition delete plan (planDelete)", () => { + it("names a part bound by an ended population 'one' row, exactly as the delete then cascades", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const liveEpisode = await store.nodes.DpEpisode.create( + { title: "live" }, + { partOf: { whole: podcast } }, + ); + const endedEpisode = await store.nodes.DpEpisode.create({ + title: "ended", + }); + const endedEdge = await store.edges.dpEpisodeOf.create( + endedEpisode, + podcast, + {}, + ); + await store.edges.dpEpisodeOf.update(endedEdge.id, {}, endedNow()); + + // MUTATION CHECK: make `compositionEdgeCounts` + // (src/store/operations/composition-cascade.ts) count only open-ended + // rows, as a current-mode read would, and the ended episode drops out + // of both the plan and the delete's cascade — verified and reverted. + const expected = [liveEpisode, endedEpisode] + .toSorted((left, right) => (left.id < right.id ? -1 : 1)) + .map((episode) => cascadedPart(episode, podcast, "dpEpisodeOf")); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + expect(plan.cascadedParts).toEqual(expected); + + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.DpPodcast.delete(podcast.id); + }); + expect(receipt.cascadedParts).toEqual(plan.cascadedParts); + expect( + await store.nodes.DpEpisode.getById(endedEpisode.id), + ).toBeUndefined(); + }); + + it("leaves out a part reparented away under population 'oneActive', exactly as the delete leaves it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const otherPodcast = await store.nodes.DpPodcast.create({ + title: "other", + }); + const episode = await store.nodes.DpEpisode.create( + { title: "a" }, + { partOf: { whole: podcast } }, + ); + const otherEpisode = await store.nodes.DpEpisode.create( + { title: "b" }, + { partOf: { whole: otherPodcast } }, + ); + const stayed = await store.nodes.DpSegment.create( + { title: "stayed" }, + { partOf: { whole: episode } }, + ); + const moved = await store.nodes.DpSegment.create( + { title: "moved" }, + { partOf: { whole: episode } }, + ); + await store.nodes.DpSegment.reparent(moved.id, { + whole: otherEpisode, + }); + + // MUTATION CHECK: make `compositionEdgeCounts` count every + // non-deleted row, as an `includeEnded` read would, and the moved + // segment's reparent history pulls it into both the plan and the + // delete — verified and reverted. + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + expect(plan.cascadedParts).toEqual([ + cascadedPart(stayed, episode, "dpSegmentOf"), + cascadedPart(episode, podcast, "dpEpisodeOf"), + ]); + + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.DpPodcast.delete(podcast.id); + }); + expect(receipt.cascadedParts).toEqual(plan.cascadedParts); + expect(await store.nodes.DpSegment.getById(moved.id)).toBeDefined(); + }); + + it("lists every edge the delete removes, and only those", async () => { + const store = await context.createStore( + buildGraph(nextGraphId(), { + podcast: "disconnect", + episode: "cascade", + }), + ); + const tag = await store.nodes.DpTag.create({ name: "t" }); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const episode = await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const segment = await store.nodes.DpSegment.create( + { title: "s" }, + { partOf: { whole: episode } }, + ); + const podcastTag = await store.edges.dpTagged.create(podcast, tag, {}); + const episodeTag = await store.edges.dpTagged.create(episode, tag, {}); + const unrelated = await store.nodes.DpPodcast.create({ title: "other" }); + const unrelatedTag = await store.edges.dpTagged.create( + unrelated, + tag, + {}, + ); + const [episodeOf] = await store.edges.dpEpisodeOf.findFrom(episode); + const [segmentOf] = await store.edges.dpSegmentOf.findFrom(segment); + + // MUTATION CHECK: return `cascadePlan.consumedEdgeIds` alone from + // `planLiveNodeDelete` (src/store/operations/node-operations.ts) and + // the two `dpTagged` edges the deletes' own `onDelete` removes go + // missing — verified and reverted. + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + expect(plan.removedEdgeIds).toEqual( + [ + requireDefined(episodeOf).id, + requireDefined(segmentOf).id, + podcastTag.id, + episodeTag.id, + ].toSorted(), + ); + + await store.nodes.DpPodcast.delete(podcast.id); + for (const edgeId of plan.removedEdgeIds) { + expect( + (await store.edges.dpTagged.getById(edgeId as never)) ?? + (await store.edges.dpEpisodeOf.getById(edgeId as never)) ?? + (await store.edges.dpSegmentOf.getById(edgeId as never)), + ).toBeUndefined(); + } + expect(await store.edges.dpTagged.getById(unrelatedTag.id)).toBeDefined(); + }); + + it("refuses with the delete's own RestrictedDeleteError, for the whole and for a cascaded part", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const tag = await store.nodes.DpTag.create({ name: "t" }); + const taggedPodcast = await store.nodes.DpPodcast.create({ + title: "tagged", + }); + await store.edges.dpTagged.create(taggedPodcast, tag, {}); + + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const episode = await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const segment = await store.nodes.DpSegment.create( + { title: "s" }, + { partOf: { whole: episode } }, + ); + await store.edges.dpTagged.create(segment, tag, {}); + + // MUTATION CHECK: pass `judgeRoot: false` to `planCascadingNodeDelete` + // from `planLiveNodeDelete` and the part-less tagged podcast plans + // cleanly, since only its delete's own statement would judge it — + // verified and reverted. + const cases = [ + { + root: taggedPodcast, + refusedKind: "DpPodcast", + refused: taggedPodcast, + }, + { root: podcast, refusedKind: "DpSegment", refused: segment }, + ]; + for (const { root, refusedKind, refused } of cases) { + const refusal = matchingObject({ + name: "RestrictedDeleteError", + details: matchingObject({ + nodeKind: refusedKind, + nodeId: refused.id, + edgeKinds: ["dpTagged"], + }), + }); + await expect(store.nodes.DpPodcast.planDelete(root.id)).rejects.toThrow( + refusal, + ); + await expect( + store.transaction((tx) => tx.nodes.DpPodcast.planDelete(root.id)), + ).rejects.toBeInstanceOf(RestrictedDeleteError); + await expect(store.nodes.DpPodcast.delete(root.id)).rejects.toThrow( + refusal, + ); + } + expect(await store.nodes.DpSegment.getById(segment.id)).toBeDefined(); + }); + + it("answers undefined for an absent or already-deleted node, where delete is a no-op", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + await store.nodes.DpPodcast.delete(podcast.id); + + // MUTATION CHECK: drop the live-row gate from `planLiveNodeDelete` and + // the tombstoned podcast answers an empty plan instead — verified and + // reverted. + expect( + await store.nodes.DpPodcast.planDelete(podcast.id), + ).toBeUndefined(); + expect( + await store.nodes.DpPodcast.planDelete("missing" as never), + ).toBeUndefined(); + expect( + await store.transaction((tx) => + tx.nodes.DpPodcast.planDelete(podcast.id), + ), + ).toBeUndefined(); + }); + + it("agrees with a delete later in the same transaction, including a part attached earlier in it", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const committed = await store.nodes.DpEpisode.create( + { title: "committed" }, + { partOf: { whole: podcast } }, + ); + + const { receipt, result } = await store.transactionWithReceipt( + async (tx) => { + const pending = await tx.nodes.DpEpisode.create( + { title: "pending" }, + { partOf: { whole: podcast } }, + ); + const segment = await tx.nodes.DpSegment.create( + { title: "s" }, + { partOf: { whole: pending } }, + ); + const plan = await tx.nodes.DpPodcast.planDelete(podcast.id); + await tx.nodes.DpPodcast.delete(podcast.id); + return { plan: requireDefined(plan), pending, segment }; + }, + ); + + expect(result.plan.cascadedParts).toEqual([ + cascadedPart(result.segment, result.pending, "dpSegmentOf"), + ...[committed, result.pending] + .toSorted((left, right) => (left.id < right.id ? -1 : 1)) + .map((episode) => cascadedPart(episode, podcast, "dpEpisodeOf")), + ]); + expect(receipt.cascadedParts).toEqual(result.plan.cascadedParts); + // A plan is a read: it is not a write intent on the receipt. + expect(receipt.writes.nodes).toEqual({ + DpEpisode: 1, + DpSegment: 1, + DpPodcast: 1, + }); + }); + + it("writes no graph or revision row on either form: a revision-tracked store's revision does not move", async () => { + const store = await context.createStore(buildGraph(nextGraphId()), { + revisionTracking: true, + }); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const before = await store.revisionNow(); + + // MUTATION CHECK: drop `{ didWrite: () => false }` from the + // transactional branch of `executeNodeDeletePlan` + // (src/store/operations/node-operations.ts) and the transaction below + // is counted as a write, advancing the revision at commit — verified + // and reverted. + const plans: (NodeDeletePlan | undefined)[] = [ + await store.nodes.DpPodcast.planDelete(podcast.id), + await store.transaction((tx) => + tx.nodes.DpPodcast.planDelete(podcast.id), + ), + ]; + const { receipt } = await store.transactionWithReceipt((tx) => + tx.nodes.DpPodcast.planDelete(podcast.id), + ); + + expect(plans.map((plan) => plan?.cascadedParts.length)).toEqual([1, 1]); + expect(receipt.writes.total).toBe(0); + expect(await store.revisionNow()).toEqual(before); + expect(await store.nodes.DpPodcast.getById(podcast.id)).toBeDefined(); + }); + + it("refuses the transactional form inside a read-only transaction before any statement, for a whole and a non-whole kind", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const tag = await store.nodes.DpTag.create({ name: "t" }); + + // MUTATION CHECK: drop the `ctx.readOnlyTransaction` refusal from + // `executeNodeDeletePlan` (src/store/operations/node-operations.ts): + // SQLite then answers a plan, and server PostgreSQL rejects the whole's + // fence-row advance with a raw read-only driver error that also aborts + // the transaction, so the read after it fails — verified and reverted. + const refusals = await store.transactionWithReceipt( + async (tx) => { + const errors = [ + await rejectionOf(() => tx.nodes.DpPodcast.planDelete(podcast.id)), + await rejectionOf(() => tx.nodes.DpTag.planDelete(tag.id)), + await rejectionOf(() => + tx.measure((scoped) => + scoped.nodes.DpPodcast.planDelete(podcast.id), + ), + ), + ]; + // The refusal issued no statement, so the read-only transaction is + // still usable after it. + expect(await tx.nodes.DpPodcast.getById(podcast.id)).toBeDefined(); + return errors; + }, + { accessMode: "read_only" }, + ); + + const kinds = ["DpPodcast", "DpTag", "DpPodcast"]; + expect(refusals.result).toHaveLength(kinds.length); + for (const [index, refusal] of refusals.result.entries()) { + const kind = requireDefined(kinds[index]); + expect(refusal).toBeInstanceOf(UnsupportedBackendCapabilityError); + const error = refusal as UnsupportedBackendCapabilityError; + expect(error.code).toBe("UNSUPPORTED_BACKEND_CAPABILITY"); + expect(error.details).toMatchObject({ + operation: `tx.nodes.${kind}.planDelete()`, + capability: "write transaction access", + accessMode: "read_only", + }); + expect(error.suggestion).toContain( + `store.nodes.${kind}.planDelete(id)`, + ); + } + expect(refusals.receipt.writes.total).toBe(0); + const storeFormPlan = await store.nodes.DpPodcast.planDelete(podcast.id); + expect(storeFormPlan?.cascadedParts).toHaveLength(1); + }); + }); + + describe("Guarded delete (delete with expect)", () => { + it("deletes on a plan that still holds, whatever its order and after a JSON round trip", async () => { + const store = await context.createStore( + buildGraph(nextGraphId(), { podcast: "disconnect" }), + ); + const tag = await store.nodes.DpTag.create({ name: "t" }); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + for (const title of ["a", "b"]) { + const episode = await store.nodes.DpEpisode.create( + { title }, + { partOf: { whole: podcast } }, + ); + await store.nodes.DpSegment.create( + { title: `${title}-s` }, + { partOf: { whole: episode } }, + ); + } + await store.edges.dpTagged.create(podcast, tag, {}); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + expect(plan.cascadedParts).toHaveLength(4); + expect(plan.removedEdgeIds).toHaveLength(5); + + // MUTATION CHECK: compare the two plans positionally in + // `diffNodeDeletePlans` (src/store/operations/node-delete-expectation.ts) + // and the reversed lists refuse — verified and reverted. + // The plan crosses a request boundary as JSON text. + const wire = JSON.stringify({ + cascadedParts: plan.cascadedParts.toReversed(), + removedEdgeIds: plan.removedEdgeIds.toReversed(), + }); + const confirmed = JSON.parse(wire) as NodeDeletePlan; + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.DpPodcast.delete(podcast.id, { expect: confirmed }); + }); + + expect(receipt.cascadedParts).toEqual(plan.cascadedParts); + expect(await store.nodes.DpPodcast.getById(podcast.id)).toBeUndefined(); + expect(await store.edges.dpTagged.find()).toEqual([]); + }); + + it("refuses a part attached after the preview, before any write", async () => { + const store = await context.createStore(buildGraph(nextGraphId()), { + revisionTracking: true, + }); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const episode = await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + const late = await store.nodes.DpEpisode.create( + { title: "late" }, + { partOf: { whole: podcast } }, + ); + const [lateEdge] = await store.edges.dpEpisodeOf.findFrom(late); + const before = await store.revisionNow(); + + // MUTATION CHECK: drop the in-frame `assertNodeDeletePlanExpected` + // call from `executeNodeDelete` + // (src/store/operations/node-operations.ts) and the delete cascades + // through both episodes — verified and reverted. + const refusal = await rejectionOf(() => + store.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ); + + expect(refusal).toBeInstanceOf(NodeDeletePlanMismatchError); + const error = refusal as NodeDeletePlanMismatchError; + expect(error.code).toBe("NODE_DELETE_PLAN_MISMATCH"); + expect(error.details).toEqual({ + kind: "DpPodcast", + id: podcast.id, + situation: "changed", + addedParts: [cascadedPart(late, podcast, "dpEpisodeOf")], + droppedParts: [], + addedEdgeIds: [requireDefined(lateEdge).id], + droppedEdgeIds: [], + }); + expect(await store.revisionNow()).toEqual(before); + for (const node of [podcast, episode, late]) { + expect(await store.getNodeByRef(node)).toBeDefined(); + } + expect(await store.edges.dpEpisodeOf.find()).toHaveLength(2); + }); + + it("refuses a part detached after the preview", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const kept = await store.nodes.DpEpisode.create( + { title: "kept" }, + { partOf: { whole: podcast } }, + ); + const detached = await store.nodes.DpEpisode.create( + { title: "detached" }, + { partOf: { whole: podcast } }, + ); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + const [detachedEdge] = await store.edges.dpEpisodeOf.findFrom(detached); + await store.edges.dpEpisodeOf.delete(requireDefined(detachedEdge).id); + + await expect( + store.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ).rejects.toThrow( + matchingObject({ + name: "NodeDeletePlanMismatchError", + details: { + kind: "DpPodcast", + id: podcast.id, + situation: "changed", + addedParts: [], + droppedParts: [cascadedPart(detached, podcast, "dpEpisodeOf")], + addedEdgeIds: [], + droppedEdgeIds: [requireDefined(detachedEdge).id], + }, + }), + ); + expect(await store.nodes.DpEpisode.getById(kept.id)).toBeDefined(); + }); + + it("refuses an edge removed after the preview when the parts are unchanged", async () => { + const store = await context.createStore( + buildGraph(nextGraphId(), { podcast: "cascade" }), + ); + const tag = await store.nodes.DpTag.create({ name: "t" }); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const tagEdge = await store.edges.dpTagged.create(podcast, tag, {}); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + await store.edges.dpTagged.delete(tagEdge.id); + + // MUTATION CHECK: drop `diff.droppedEdgeIds.length === 0` from the + // `unchanged` decision in `diffNodeDeletePlans` + // (src/store/operations/node-delete-expectation.ts) and the delete runs + // — verified and reverted. + await expect( + store.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ).rejects.toThrow( + matchingObject({ + name: "NodeDeletePlanMismatchError", + details: { + kind: "DpPodcast", + id: podcast.id, + situation: "changed", + addedParts: [], + droppedParts: [], + addedEdgeIds: [], + droppedEdgeIds: [tagEdge.id], + }, + }), + ); + expect(await store.nodes.DpPodcast.getById(podcast.id)).toBeDefined(); + }); + + it("compares a part's whole, not only its id", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const episode = await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + const [part] = plan.cascadedParts; + const elsewhere = { + ...requireDefined(part), + whole: { kind: "DpPodcast", id: "elsewhere" }, + }; + + // MUTATION CHECK: key parts by `(kind, id)` alone in + // `cascadedPartKey` (src/store/operations/node-delete-expectation.ts) + // and the stated whole is ignored, so the delete runs — verified and + // reverted. + await expect( + store.nodes.DpPodcast.delete(podcast.id, { + expect: { ...plan, cascadedParts: [elsewhere] }, + }), + ).rejects.toThrow( + matchingObject({ + name: "NodeDeletePlanMismatchError", + details: matchingObject({ + addedParts: [requireDefined(part)], + droppedParts: [elsewhere], + }), + }), + ); + expect(await store.nodes.DpEpisode.getById(episode.id)).toBeDefined(); + }); + + it("compares the root's own removed edges, for a node with no parts", async () => { + const store = await context.createStore( + buildGraph(nextGraphId(), { podcast: "cascade" }), + ); + const tag = await store.nodes.DpTag.create({ name: "t" }); + const unchanged = await store.nodes.DpPodcast.create({ title: "a" }); + const tagged = await store.nodes.DpPodcast.create({ title: "b" }); + const keptEdge = await store.edges.dpTagged.create(unchanged, tag, {}); + const unchangedPlan = requireDefined( + await store.nodes.DpPodcast.planDelete(unchanged.id), + ); + const taggedPlan = requireDefined( + await store.nodes.DpPodcast.planDelete(tagged.id), + ); + const lateEdge = await store.edges.dpTagged.create(tagged, tag, {}); + + // MUTATION CHECK: pass `judgeRoot: false` to `planCascadingNodeDelete` + // from `executeNodeDelete` (src/store/operations/node-operations.ts) + // and the part-less root's own edge drops out of the delete's plan: + // the first delete refuses and the second runs — verified and + // reverted. + expect(unchangedPlan.removedEdgeIds).toEqual([keptEdge.id]); + await store.nodes.DpPodcast.delete(unchanged.id, { + expect: unchangedPlan, + }); + expect(await store.nodes.DpPodcast.getById(unchanged.id)).toBeUndefined(); + + await expect( + store.nodes.DpPodcast.delete(tagged.id, { expect: taggedPlan }), + ).rejects.toThrow( + matchingObject({ + name: "NodeDeletePlanMismatchError", + details: matchingObject({ addedEdgeIds: [lateEdge.id] }), + }), + ); + expect(await store.edges.dpTagged.getById(lateEdge.id)).toBeDefined(); + }); + + it("refuses a node deleted after the preview instead of a silent no-op", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + const plan = requireDefined( + await store.nodes.DpPodcast.planDelete(podcast.id), + ); + await store.nodes.DpPodcast.delete(podcast.id); + + // MUTATION CHECK: drop the gate's `assertNodeDeletePlanExpected` call + // from `executeNodeDelete` (src/store/operations/node-operations.ts) + // and the store form returns as a no-op — verified and reverted. + const notLive = matchingObject({ + name: "NodeDeletePlanMismatchError", + details: { kind: "DpPodcast", id: podcast.id, situation: "notLive" }, + }); + await expect( + store.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ).rejects.toThrow(notLive); + await expect( + store.transaction((tx) => + tx.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ), + ).rejects.toThrow(notLive); + await expect( + store.nodes.DpPodcast.delete("missing" as never, { expect: plan }), + ).rejects.toBeInstanceOf(NodeDeletePlanMismatchError); + }); + + it("refuses on a transaction's collections before the delete writes, leaving the transaction usable", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.DpPodcast.create({ title: "show" }); + await store.nodes.DpEpisode.create( + { title: "e" }, + { partOf: { whole: podcast } }, + ); + + const { receipt, result } = await store.transactionWithReceipt( + async (tx) => { + const plan = requireDefined( + await tx.nodes.DpPodcast.planDelete(podcast.id), + ); + await tx.nodes.DpEpisode.create( + { title: "pending" }, + { partOf: { whole: podcast } }, + ); + const refusal = await rejectionOf(() => + tx.nodes.DpPodcast.delete(podcast.id, { expect: plan }), + ); + const replanned = requireDefined( + await tx.nodes.DpPodcast.planDelete(podcast.id), + ); + await tx.nodes.DpPodcast.delete(podcast.id, { expect: replanned }); + return { refusal, replanned }; + }, + ); + + expect(result.refusal).toBeInstanceOf(NodeDeletePlanMismatchError); + expect(result.replanned.cascadedParts).toHaveLength(2); + expect(receipt.cascadedParts).toEqual(result.replanned.cascadedParts); + expect(receipt.writes.nodes).toEqual({ DpEpisode: 1, DpPodcast: 1 }); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-existence.ts b/packages/typegraph/tests/backends/integration/composition-existence.ts new file mode 100644 index 000000000..4ed27bfb7 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-existence.ts @@ -0,0 +1,1673 @@ +/** + * `existence: "required"`: a composition part that cannot exist + * without a live whole, on every backend. + * + * Covers the create-side owner (`resolveCompositionCreate`, one node/one + * composition edge in one write plan), the detach-side owner + * (`assertCompositionExistencePreserved`, the three work-assembly sites), + * the fused-program gates, and `store.verifyConstraintFences()`'s + * `compositionExistence` family. + * + * Each case states, in a comment, the mutation/revert that must make it + * fail (the revert/mutation check load-bearing tests require). + */ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + CompositionExistenceError, + ConfigurationError, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + EndpointNotFoundError, + ENTITY_ALREADY_EXISTS_CODE, + hasPart, + partOf, + RestrictedDeleteError, + subClassOf, + TypeGraphError, + ValidationError, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const EeSegment = defineNode("EeSegment", { schema: z.object({}) }); +/** A SUBCLASS of a required-existence part kind, never declared as its own composition pair (subclass existence inheritance). */ +const EeSubSegment = defineNode("EeSubSegment", { schema: z.object({}) }); +const EeEpisode = defineNode("EeEpisode", { schema: z.object({}) }); +const EePodcast = defineNode("EePodcast", { schema: z.object({}) }); +/** An optional-existence part kind, for the "honored as a convenience" case. */ +const EeTag = defineNode("EeTag", { schema: z.object({}) }); +const EeCollection = defineNode("EeCollection", { schema: z.object({}) }); +/** A `has_*`-shaped realizing edge (whole -> part), for orientation coverage. */ +const EeTrack = defineNode("EeTrack", { schema: z.object({}) }); +const EeAlbum = defineNode("EeAlbum", { schema: z.object({}) }); +/** + * A REFLEXIVE optional-existence composition kind: a folder may be part of + * another folder. The only shape in which a node batch can propose a + * composition CYCLE at all — every item of a create batch is a new node, so a + * cycle can only run through the batch's own items — which is what the + * batch-level acyclicity probe below is about. + */ +const EeFolder = defineNode("EeFolder", { schema: z.object({}) }); +/** A `population: "oneActive"` required part, for the temporal (valid-time) coverage below. */ +const EeLiveClip = defineNode("EeLiveClip", { schema: z.object({}) }); +const EeShow = defineNode("EeShow", { schema: z.object({}) }); + +/** A whole that holds at most ONE part: the whole-side cardinality coverage. */ +const EeEngine = defineNode("EeEngine", { schema: z.object({}) }); +const EeCar = defineNode("EeCar", { schema: z.object({}) }); + +/** A part that severs its own edges on delete, under a `restrict` folder. */ +const EeNote = defineNode("EeNote", { schema: z.object({}) }); + +const eeSegmentOf = defineEdge("eeSegmentOf", { schema: z.object({}) }); +const eeNoteOf = defineEdge("eeNoteOf", { schema: z.object({}) }); +/** A plain (non-composition) edge: the `restrict` obstacle of the cascade cases. */ +const eeCites = defineEdge("eeCites", { schema: z.object({}) }); +const eeEngineOf = defineEdge("eeEngineOf", { schema: z.object({}) }); +const eeTagOf = defineEdge("eeTagOf", { schema: z.object({}) }); +const eeHasTrack = defineEdge("eeHasTrack", { schema: z.object({}) }); +const eeLiveClipOf = defineEdge("eeLiveClipOf", { schema: z.object({}) }); +const eeFolderOf = defineEdge("eeFolderOf", { schema: z.object({}) }); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + EeSegment: { type: EeSegment }, + EeSubSegment: { type: EeSubSegment }, + EeEpisode: { type: EeEpisode }, + EePodcast: { type: EePodcast }, + EeTag: { type: EeTag }, + EeCollection: { type: EeCollection }, + EeTrack: { type: EeTrack }, + EeAlbum: { type: EeAlbum }, + EeLiveClip: { type: EeLiveClip }, + EeShow: { type: EeShow }, + EeFolder: { type: EeFolder }, + EeEngine: { type: EeEngine }, + EeCar: { type: EeCar }, + EeNote: { type: EeNote, onDelete: "disconnect" }, + }, + edges: { + eeSegmentOf: { + type: eeSegmentOf, + from: [EeSegment], + to: [EeEpisode], + cardinality: "one", + }, + eeTagOf: { + type: eeTagOf, + from: [EeTag], + to: [EeCollection], + cardinality: "one", + }, + eeHasTrack: { + type: eeHasTrack, + from: [EeAlbum], + to: [EeTrack], + targetCardinality: "one", + }, + eeLiveClipOf: { + type: eeLiveClipOf, + from: [EeLiveClip], + to: [EeShow], + cardinality: "oneActive", + }, + eeFolderOf: { + type: eeFolderOf, + from: [EeFolder], + to: [EeFolder], + cardinality: "one", + }, + eeNoteOf: { + type: eeNoteOf, + from: [EeNote], + to: [EeFolder], + cardinality: "one", + }, + eeCites: { + type: eeCites, + from: [EeFolder, EeNote], + to: [EePodcast, EeFolder], + }, + eeEngineOf: { + type: eeEngineOf, + from: [EeEngine], + to: [EeCar], + cardinality: "one", + targetCardinality: "one", + }, + }, + ontology: [ + // EeSegment is required-existence, declared only under EeEpisode — + // NOT under EePodcast, which is a valid concrete kind but an + // undeclared whole for this part. + partOf(EeSegment, EeEpisode, { + via: eeSegmentOf, + existence: "required", + }), + // EeSubSegment is never declared its own composition pair — it + // inherits EeSegment's required existence purely through subsumption + // (subclass existence inheritance, below). + subClassOf(EeSubSegment, EeSegment), + // EeTag is optional-existence. + partOf(EeTag, EeCollection, { via: eeTagOf }), + // EeTrack is required-existence under a `has_*`-shaped edge. + hasPart(EeAlbum, EeTrack, { via: eeHasTrack, existence: "required" }), + // `population: "oneActive"` (`cardinality: "oneActive"` above): + // ending the window is a genuine detachment, unlike EeSegment's + // `population: "one"` pair (the temporal coverage below). + partOf(EeLiveClip, EeShow, { + via: eeLiveClipOf, + existence: "required", + }), + // Reflexive, so the orientation must be stated explicitly. + partOf(EeFolder, EeFolder, { via: eeFolderOf, partSide: "from" }), + partOf(EeEngine, EeCar, { via: eeEngineOf, existence: "required" }), + partOf(EeNote, EeFolder, { via: eeNoteOf }), + ], + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `composition_existence_${graphIdCounter}`; +} + +export function registerCompositionExistenceIntegrationTests( + context: IntegrationTestContext, +): void { + describe('composition existence (`existence: "required"`)', () => { + it("case 1: refuses a bare create with no partOf, and writes no node row", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const error = await store.nodes.EeSegment.create({}).catch( + (error_: unknown) => error_, + ); + expect(error).toBeInstanceOf(CompositionExistenceError); + expect((error as CompositionExistenceError).code).toBe( + "COMPOSITION_WHOLE_REQUIRED", + ); + expect((error as CompositionExistenceError).details.situation).toBe( + "create", + ); + expect((error as CompositionExistenceError).details.partKind).toBe( + "EeSegment", + ); + expect(await store.nodes.EeSegment.count()).toBe(0); + }); + // MUTATION CHECK: make `resolveCompositionCreate` + // (src/store/operations/composition-create.ts) return `undefined` for + // the required-without-`partOf` arm instead of throwing. The create + // above then succeeds and leaves an orphan (count becomes 1). + + it("case 2: create with partOf writes both rows in one transaction, `from`-side orientation", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + expect(segment.id).toBeDefined(); + const edges = await store.edges.eeSegmentOf.find({}); + expect(edges).toHaveLength(1); + expect(edges[0]?.fromId).toBe(segment.id); + expect(edges[0]?.toId).toBe(episode.id); + }); + + it("case 2b: create with partOf writes both rows, `to`-side (`has_*`) orientation", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const album = await store.nodes.EeAlbum.create({}); + const track = await store.nodes.EeTrack.create( + {}, + { partOf: { whole: { kind: "EeAlbum", id: album.id } } }, + ); + const edges = await store.edges.eeHasTrack.find({}); + expect(edges).toHaveLength(1); + expect(edges[0]?.fromId).toBe(album.id); + expect(edges[0]?.toId).toBe(track.id); + }); + // MUTATION CHECK: invert `pair.partSide` in `resolveCompositionCreate`'s + // orientation branch. Case 2b then writes the edge backwards + // (fromId/toId swapped) and this assertion catches it; case 2 does not + // (it would happen to still pass, since inverting twice looks like + // nothing changed for a `from`-side pair — the `has_*` case is the one + // this mutation actually exposes). + + it("case 2c: a part create whose id is taken reports the id collision, on every create entry", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const attachment = { + partOf: { whole: { kind: "EeEpisode", id: episode.id } }, + } as const; + const segment = await store.nodes.EeSegment.create({}, attachment); + + // MUTATION CHECK: letting a composition create take the + // insert-if-absent path again (`finishNodeCreatePreparation`'s + // `allowInsertIfAbsent` true) makes the single create reject with + // CompositionError COMPOSITION_WHOLE_OCCUPIED, found on the incumbent's + // own edge — verified and reverted. + const attempts = [ + () => + store.nodes.EeSegment.create({}, { id: segment.id, ...attachment }), + () => + store.nodes.EeSegment.bulkCreate([ + { props: {}, id: segment.id, ...attachment }, + ]), + ]; + for (const attempt of attempts) { + const error = await attempt().catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ValidationError); + expect( + (error as ValidationError).details.issues.map((issue) => issue.code), + ).toContain(ENTITY_ALREADY_EXISTS_CODE); + } + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(1); + }); + + it("case 3: a failed edge (whole does not exist) aborts the node — no orphan row survives", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + // `resolveCompositionCreate` finds the declared pair and returns work + // (no synchronous refusal, unlike cases 3b/4): the failure is reached + // only once `runWritePlan` tries to insert the composition edge and + // its endpoint-liveness check finds no such `EeEpisode` row. Proving + // atomicity requires exactly this shape — a failure INSIDE the write + // plan, after the node row would otherwise have been prepared. + const before = await store.nodes.EeSegment.count(); + const error = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: "does-not-exist" } } }, + ).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EndpointNotFoundError); + expect(await store.nodes.EeSegment.count()).toBe(before); + }); + // MUTATION CHECK: build two separate write plans instead of one + // `mixedWritePlan` in `executeNodeCreateInternal` + // (src/store/operations/node-operations.ts) — the node row then commits + // in its own transaction before the edge leg's endpoint check ever + // runs, and `EeSegment.count()` grows past `before`. + + it("case 3b: a failed edge (whole does not exist) in a bulk create aborts the WHOLE batch — no orphan row survives", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const before = await store.nodes.EeSegment.count(); + const error = await store.nodes.EeSegment.bulkCreate([ + { props: {}, partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + { + props: {}, + partOf: { whole: { kind: "EeEpisode", id: "does-not-exist" } }, + }, + ]).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EndpointNotFoundError); + expect(await store.nodes.EeSegment.count()).toBe(before); + }); + // MUTATION CHECK: build separate write plans per item instead of one + // `mixedWritePlan` covering the whole batch in + // `executeNodeBulkCreateInternal` (src/store/operations/node-operations.ts) + // — the first item's node+edge rows then commit even though the second + // item's endpoint check throws, and `EeSegment.count()` grows to + // `before + 1` instead of staying at `before`. + + it("case 4: undeclared whole refused, no write", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.EePodcast.create({}); + const error = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EePodcast", id: podcast.id } } }, + ).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details["code"]).toBe( + "COMPOSITION_WHOLE_NOT_DECLARED", + ); + expect(await store.nodes.EeSegment.count()).toBe(0); + }); + // MUTATION CHECK: drop the `pair === undefined` arm in + // `resolveCompositionCreate` (return the work unconditionally instead). + // An edge of the wrong kind (segmentOf naming a Podcast id as its `to`) + // then gets written, and the endpoint-kind check downstream would be + // the only thing left to catch it — this test's `ConfigurationError` + // assertion fails first. + + it("case 5: partOf on an OPTIONAL pair is honored, not ignored", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const collection = await store.nodes.EeCollection.create({}); + const tag = await store.nodes.EeTag.create( + {}, + { partOf: { whole: { kind: "EeCollection", id: collection.id } } }, + ); + const edges = await store.edges.eeTagOf.find({}); + expect(edges).toHaveLength(1); + expect(edges[0]?.fromId).toBe(tag.id); + expect(edges[0]?.toId).toBe(collection.id); + }); + // MUTATION CHECK: return `undefined` for the "optional, pair found" + // arm in `resolveCompositionCreate` instead of the work. The edge above + // is never written and `edges` is empty. + + it("case 6: bulk create takes the whole per item, including a mixed batch with an optional-existence item", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const collection = await store.nodes.EeCollection.create({}); + const [segmentA, segmentB] = await store.nodes.EeSegment.bulkCreate([ + { props: {}, partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + { props: {}, partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ]); + const [tag] = await store.nodes.EeTag.bulkCreate([ + { + props: {}, + partOf: { whole: { kind: "EeCollection", id: collection.id } }, + }, + ]); + const segmentEdges = await store.edges.eeSegmentOf.find({}); + expect(segmentEdges).toHaveLength(2); + expect(new Set(segmentEdges.map((edge) => edge.fromId))).toEqual( + new Set([segmentA?.id, segmentB?.id]), + ); + const tagEdges = await store.edges.eeTagOf.find({}); + expect(tagEdges).toHaveLength(1); + expect(tagEdges[0]?.fromId).toBe(tag?.id); + }); + // MUTATION CHECK: read `items[0].partOf` for every item in the batch + // path instead of each item's own `partOf` — segmentB's edge would then + // be missing (or duplicated with segmentA's), which the + // `new Set(...)` equality catches. + + it("case 6b: a required-existence bulk create with no partOf refuses the WHOLE batch, no rows survive", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const error = await store.nodes.EeSegment.bulkCreate([ + { props: {}, partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + { props: {} }, + ]).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionExistenceError); + expect(await store.nodes.EeSegment.count()).toBe(0); + }); + + it("case 6c: the batch's ONE acyclicity probe attributes a self-attaching item to that item's own edge, and no row of the batch survives", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.EeFolder.create({}); + const error = await store.nodes.EeFolder.bulkCreate([ + { + id: "ee-folder-ok", + props: {}, + partOf: { whole: { kind: "EeFolder", id: root.id } }, + }, + // Names ITSELF as its whole: the cycle this batch closes. + { + id: "ee-folder-loop", + props: {}, + partOf: { whole: { kind: "EeFolder", id: "ee-folder-loop" } }, + }, + ]).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EdgeAcyclicityError); + expect((error as EdgeAcyclicityError).details).toMatchObject({ + selfLoop: true, + fromKind: "EeFolder", + fromId: "ee-folder-loop", + toKind: "EeFolder", + toId: "ee-folder-loop", + }); + // The whole batch rolled back: neither item's node row, and no edge. + expect(await store.nodes.EeFolder.count()).toBe(1); + expect(await store.edges.eeFolderOf.find({})).toHaveLength(0); + }); + // MUTATION CHECK: drop the `assertPreparedEdgeCreatesAcyclic` call at the + // end of `prepareBatchCompositionCreateEdges` + // (src/store/operations/node-operations.ts) — nothing probes a batch's + // composition edges at all (each item prepares with + // `validateAcyclicity: false`), so this batch commits the self-attaching + // folder and `count()` reads 3. + + it("case 6d: an in-batch composition cycle spanning two items is refused, and no row of the batch survives", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const error = await store.nodes.EeFolder.bulkCreate([ + { + id: "ee-cycle-a", + props: {}, + partOf: { whole: { kind: "EeFolder", id: "ee-cycle-b" } }, + }, + { + id: "ee-cycle-b", + props: {}, + partOf: { whole: { kind: "EeFolder", id: "ee-cycle-a" } }, + }, + ]).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EdgeAcyclicityError); + const details = (error as EdgeAcyclicityError).details; + expect(details.selfLoop).toBe(false); + // Both items' edges lie on the cycle; the probe names the first of them + // it is given, which is the batch's own input order. + expect(details.edgeKind).toBe("eeFolderOf"); + expect(details.fromId).toBe("ee-cycle-a"); + expect(details.toId).toBe("ee-cycle-b"); + expect(await store.nodes.EeFolder.count()).toBe(0); + expect(await store.edges.eeFolderOf.find({})).toHaveLength(0); + }); + // MUTATION CHECK: same as case 6c — with the batch-level probe gone, the + // mutual cycle commits and `count()` reads 2. (Probing per item BEFORE + // each insert cannot see this cycle either: the first item's walk runs + // before the second item's edge exists.) + + it("case 7a: soft-delete detach refused while the required part is live", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const [edge] = await store.edges.eeSegmentOf.find({}); + const error = await store.edges.eeSegmentOf + .delete(requireDefined(edge).id) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionExistenceError); + expect((error as CompositionExistenceError).code).toBe( + "COMPOSITION_DETACH_REFUSED", + ); + expect((error as CompositionExistenceError).details.situation).toBe( + "detach", + ); + }); + // MUTATION CHECK: remove the `assertCompositionExistencePreserved` call + // from the `EdgeDeleteWork` assembly site in `executeEdgeDelete` + // (src/store/operations/edge-operations.ts). This case starts passing + // through (the delete succeeds) while 7b/7c still refuse — proving the + // three sites are independent, not one shared short-circuit. + + it("case 7b: hard-delete detach refused while the required part is live", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const [edge] = await store.edges.eeSegmentOf.find({}); + const error = await store.edges.eeSegmentOf + .hardDelete(requireDefined(edge).id) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionExistenceError); + }); + // MUTATION CHECK: remove the call from `executeEdgeHardDelete`'s + // assembly site only. + + it("case 7c: a `validTo` that ends the open window is refused while the required part is live", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const [edge] = await store.edges.eeSegmentOf.find({}); + const error = await store.edges.eeSegmentOf + .update( + requireDefined(edge).id, + {}, + { validTo: new Date().toISOString() }, + ) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CompositionExistenceError); + }); + // MUTATION CHECK: remove the call from `performEdgeUpdate`'s + // `EdgeUpdateWork` assembly site only (the `work.validTo !== undefined` + // branch in `edge-operations.ts`). + + it("case 7d: an edge bulkDelete holding a live required part's edge is refused and retires no edge of the batch, inside and outside a transaction", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const retired = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const live = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + // The retired part's edge comes FIRST, so a batch that only judged its + // first member, or wrote as it went, would retire it before refusing. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeSegment", + id: retired.id, + }); + const segmentEdges = async () => { + const edges = await store.edges.eeSegmentOf.find({}); + return edges.map((edge) => ({ id: edge.id, partId: edge.fromId })); + }; + const before = await segmentEdges(); + const edgeIdOf = (partId: string) => + requireDefined(before.find((edge) => edge.partId === partId)).id; + const batch = [edgeIdOf(retired.id), edgeIdOf(live.id)]; + + const outside = await store.edges.eeSegmentOf + .bulkDelete(batch) + .catch((error: unknown) => error); + let inside: unknown; + await store.transaction(async (tx) => { + inside = await tx.edges.eeSegmentOf + .bulkDelete(batch) + .catch((error: unknown) => error); + }); + + for (const refusal of [outside, inside]) { + expect(refusal).toBeInstanceOf(CompositionExistenceError); + expect((refusal as CompositionExistenceError).details).toMatchObject({ + situation: "detach", + partId: live.id, + edgeId: batch[1], + }); + } + expect(await segmentEdges()).toEqual(before); + expect(await store.verifyConstraintFences()).toEqual([]); + + // The retired part's edge alone is no detachment, so the same batch + // minus the live member goes through. + await store.edges.eeSegmentOf.bulkDelete([requireDefined(batch[0])]); + expect(await segmentEdges()).toEqual([{ id: batch[1], partId: live.id }]); + }); + + it('case 7e: re-stating `validTo` on a live required part\'s already-ended `population: "one"` edge is not a detachment', async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const firstEnd = "2099-02-01T00:00:00.000Z"; + const tighterEnd = "2099-01-15T00:00:00.000Z"; + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2099-01-01T00:00:00.000Z")); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const [edge] = await store.edges.eeSegmentOf.find({}); + const edgeId = requireDefined(edge).id; + // Ending the window is refused while the part is live (case 7c), so + // it is ended while the raw backend member has the part retired, and + // the part is restored afterwards: a `population: "one"` edge still + // attaches its part once its window has ended, so the restore finds + // a live whole. + vi.setSystemTime(new Date("2099-03-01T00:00:00.000Z")); + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeSegment", + id: segment.id, + }); + await store.edges.eeSegmentOf.update(edgeId, {}, { validTo: firstEnd }); + await store.nodes.EeSegment.upsertById(segment.id, {}); + expect(await store.nodes.EeSegment.getById(segment.id)).toBeDefined(); + + for (const validTo of [firstEnd, tighterEnd]) { + const updated = await store.edges.eeSegmentOf.update( + edgeId, + {}, + { validTo }, + ); + expect(updated.meta.validTo).toBe(validTo); + } + expect(await store.verifyConstraintFences()).toEqual([]); + } finally { + vi.useRealTimers(); + } + }); + // MUTATION CHECK: make `edgeWriteEndsOpenWindow` (composition-create.ts) + // ignore `existing.valid_to` — the first re-statement is then refused + // with `CompositionExistenceError` although the window closed long ago. + + // MUTATION CHECK: drop the per-member `assertCompositionExistencePreserved` + // call from `executeEdgeDeleteBatch` (edge-operations.ts) — both bulk + // deletes then resolve and orphan the live segment. The fused-command + // decline for the same delete is pinned on the one backend that can + // reach the fused path, `composition-edge-delete-pglite.test.ts`. + + it("case 8: a retired part frees its composition edge", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const [edge] = await store.edges.eeSegmentOf.find({}); + // The ORDINARY node-delete path (`store.nodes.EeSegment.delete`) + // already removes a part's own composition edge as part of the SAME + // operation (`enforceNodeDeleteBehavior`'s restrict-exemption sweep, + // decision 3's cascade-exempt-by-construction machinery) — there is + // no window where an ordinary delete leaves the part retired AND the + // edge live. The scenario this case exists to prove — "a part + // retired by some OTHER route (a dirty pre-existing database, a + // bypassed-validation import) still lets its edge be deleted" — is + // reproduced directly: soft-delete the part via the RAW backend + // member, bypassing the store's own edge cleanup entirely, then + // delete the (still-live) edge through the ordinary store path. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeSegment", + id: segment.id, + }); + const stillLive = await store.edges.eeSegmentOf.find({}); + expect(stillLive).toHaveLength(1); + await expect( + store.edges.eeSegmentOf.delete(requireDefined(edge).id), + ).resolves.toBeUndefined(); + }); + // MUTATION CHECK: drop the liveness read in + // `assertCompositionExistencePreserved` (refuse unconditionally once the + // edge is a required-existence composition edge, regardless of the + // part's `deleted_at`). This case then throws instead of resolving. + + it("case 9: whole delete cascades and does not raise the detach refusal", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + await expect( + store.nodes.EeEpisode.delete(episode.id), + ).resolves.toBeUndefined(); + const remaining = await store.nodes.EeSegment.getById(segment.id); + expect(remaining).toBeUndefined(); + }); + // MUTATION CHECK (decision 3's pin): route + // `enforceNodeDeleteBehavior`'s composition sweep + // (`node-write-pipeline.ts`) through `applyEdgeSoftDelete`/ + // `applyEdgeHardDelete` instead of `backend.deleteEdge`/ + // `hardDeleteEdgesBatch` directly. Every cascade of a required part then + // starts refusing (this test throws instead of resolving). + + it("case 9a: a whole delete removes the composition edge of a part that is already dead", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + // Tombstone the part by a route that leaves its composition edge live + // (the raw backend member, as in case 8), so the cascade discovers one + // member, finds it dead, and has no live member to delete. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeSegment", + id: segment.id, + }); + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(1); + + await store.nodes.EeEpisode.delete(episode.id); + + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(0); + }); + + it("case 9e: a whole's plan and cascade walk past a dead part whose edge survived, and report only the live parts", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const live = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const dead = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + // The raw backend member tombstones the part and leaves its edge live. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeSegment", + id: dead.id, + }); + const edges = await store.edges.eeSegmentOf.find({}); + expect(edges).toHaveLength(2); + + const plan = requireDefined( + await store.nodes.EeEpisode.planDelete(episode.id), + ); + expect(plan).toEqual({ + cascadedParts: [ + { + kind: "EeSegment", + id: live.id, + whole: { kind: "EeEpisode", id: episode.id }, + viaEdgeKind: "eeSegmentOf", + }, + ], + removedEdgeIds: edges.map((edge) => edge.id).toSorted(), + }); + + await store.nodes.EeEpisode.delete(episode.id, { expect: plan }); + expect(await store.edges.eeSegmentOf.find({})).toEqual([]); + expect(await store.nodes.EeSegment.count()).toBe(0); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: drop the liveness filter from `liveDiscoveredMembers` + // (src/store/operations/composition-cascade.ts) — the dead segment then + // appears in `cascadedParts` beside the live one. + + it("case 9b: a composition edge is not resurrected onto a deleted part", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + await store.nodes.EeSegment.delete(segment.id); + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(0); + + // A resurrection owes the create's endpoint verdict, so the part's own + // deleted edge stays deleted while the part is tombstoned. + await expect( + store.edges.eeSegmentOf.getOrCreateByEndpoints(segment, episode, {}), + ).rejects.toBeInstanceOf(EndpointNotFoundError); + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(0); + + await store.nodes.EeEpisode.delete(episode.id); + expect(await store.nodes.EeEpisode.getById(episode.id)).toBeUndefined(); + }); + // MUTATION CHECK: restoring the early return on an empty `plan.members` + // in `applyCompositionCascade` leaves case 9a's edge live between two + // tombstoned nodes — verified and reverted. + + it("case 9b: a whole delete a part's `restrict` refuses, caught in a transaction, deletes nothing", async () => { + type ExistenceStore = Awaited< + ReturnType>> + >; + const root = { kind: "EeFolder", id: "ee-root" } as const; + const deletes: Readonly< + Record Promise> + > = { + delete: (tx) => tx.nodes.EeFolder.delete(root.id as never), + hardDelete: (tx) => tx.nodes.EeFolder.hardDelete(root.id as never), + bulkDelete: (tx) => tx.nodes.EeFolder.bulkDelete([root.id as never]), + // The refused whole comes second: the free folder before it must + // survive the batch's refusal too. + "bulkDelete after a deletable item": (tx) => + tx.nodes.EeFolder.bulkDelete(["ee-free" as never, root.id as never]), + }; + + // MUTATION CHECK: drop the member loop from `planCascadingNodeDelete` + // (src/store/operations/node-operations.ts) — the middle folder's + // refusal is then reached only as the cascade deletes it, after the + // leaf beneath it is gone, and the caught transaction commits that. + for (const [name, run] of Object.entries(deletes)) { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.EePodcast.create({}); + await store.nodes.EeFolder.create({}, { id: "ee-free" }); + await store.nodes.EeFolder.create({}, { id: root.id }); + const middle = await store.nodes.EeFolder.create( + {}, + { id: "ee-middle", partOf: { whole: root } }, + ); + await store.nodes.EeFolder.create( + {}, + { id: "ee-leaf", partOf: { whole: middle } }, + ); + // The middle folder is `restrict` (the default) and holds a plain + // edge, so deleting the root must refuse — leaf-first, after the leaf. + await store.edges.eeCites.create(middle, podcast, {}); + + let refusal: unknown; + await store.transaction(async (tx) => { + refusal = await run(tx as unknown as ExistenceStore).catch( + (error: unknown) => error, + ); + }); + + const folders = await store.nodes.EeFolder.find({}); + const attachments = await store.edges.eeFolderOf.find({}); + expect({ + name, + refused: + refusal instanceof RestrictedDeleteError ? + refusal.details.nodeId + : refusal, + folders: folders.map((node) => node.id).toSorted(), + attachments: attachments.length, + violations: await store.verifyConstraintFences(), + }).toEqual({ + name, + refused: "ee-middle", + folders: ["ee-free", "ee-leaf", "ee-middle", "ee-root"], + attachments: 2, + violations: [], + }); + } + }); + + it("case 9d: a whole delete its own `restrict` refuses, caught in a transaction, removes no part's edge", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const podcast = await store.nodes.EePodcast.create({}); + const root = await store.nodes.EeFolder.create({}); + const part = await store.nodes.EeFolder.create( + {}, + { partOf: { whole: root } }, + ); + await store.edges.eeCites.create(root, podcast, {}); + // The part is already dead with its composition edge still live, so the + // cascade has no live member and one consumed edge. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeFolder", + id: part.id, + }); + + let refusal: unknown; + await store.transaction(async (tx) => { + refusal = await tx.nodes.EeFolder.delete(root.id).catch( + (error: unknown) => error, + ); + }); + + // MUTATION CHECK: judge the root only when the cascade has a live member + // (`cascadeWritesBeforeRoot` in `planCascadingNodeDelete`, + // src/store/operations/node-operations.ts) and the consumed edge is + // removed before the root's refusal — verified and reverted. + expect(refusal).toBeInstanceOf(RestrictedDeleteError); + expect(await store.edges.eeFolderOf.find({})).toHaveLength(1); + expect(await store.nodes.EeFolder.getById(root.id)).toBeDefined(); + }); + + it("case 9c: a part's `restrict` is judged against the edges the parts deleted before it remove", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const root = await store.nodes.EeFolder.create({}); + const middle = await store.nodes.EeFolder.create( + {}, + { partOf: { whole: root } }, + ); + const note = await store.nodes.EeNote.create( + {}, + { partOf: { whole: middle } }, + ); + // The note is deleted first and, being `disconnect`, takes this edge + // with it — so by the middle folder's turn nothing restricts it. + await store.edges.eeCites.create(note, middle, {}); + + // MUTATION CHECK: in `planCascadingNodeDelete`, judge every node + // against the frame's starting graph (stop folding + // `verdict.removedEdgeIds` into `effects`) — the middle folder is then + // refused for an edge its own cascade removes first. + await store.nodes.EeFolder.delete(root.id); + + expect(await store.nodes.EeFolder.count()).toBe(0); + expect(await store.nodes.EeNote.count()).toBe(0); + expect(await store.edges.eeCites.find({})).toEqual([]); + }); + + it("case 10: a required-existence bulk create takes the portable path (no partial row, no fused executor)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const collection = await store.nodes.EeCollection.create({}); + const [segment, tag] = await Promise.all([ + store.nodes.EeSegment.bulkCreate([ + { + props: {}, + partOf: { whole: { kind: "EeEpisode", id: episode.id } }, + }, + ]), + store.nodes.EeTag.bulkCreate([ + { + props: {}, + partOf: { whole: { kind: "EeCollection", id: collection.id } }, + }, + ]), + ]); + expect(segment).toHaveLength(1); + expect(tag).toHaveLength(1); + const segmentEdges = await store.edges.eeSegmentOf.find({}); + expect(segmentEdges).toHaveLength(1); + }); + // MUTATION CHECK: let `resolveAtomicNodeBatchExecutor` + // (src/store/operations/atomic-mutation-program.ts) return the executor + // for a required-existence kind (drop the new guard clause). The + // composition edge above goes unwritten. + + it("case 11: verifyConstraintFences reports a required part planted with no whole", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + await store.backend.insertNode({ + graphId: store.graphId, + kind: "EeSegment", + id: "ee-orphan-1", + props: {}, + }); + const violations = await store.verifyConstraintFences(); + const violation = violations.find( + (candidate) => candidate.family === "compositionExistence", + ); + expect(violation).toBeDefined(); + if (violation?.family !== "compositionExistence") { + throw new Error("expected a compositionExistence violation"); + } + expect(violation.partKind).toBe("EeSegment"); + expect(violation.parts).toEqual([ + { kind: "EeSegment", id: "ee-orphan-1" }, + ]); + }); + // MUTATION CHECK: remove the `compositionExistenceViolations` arm from + // `verifyConstraintFences` (src/store/claims/verify.ts) — the graph + // reads clean even with the planted orphan above. + + it("case 11b: verifyConstraintFences reports an unattached SUBCLASS of a required part kind", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + // EeSubSegment is never named by any composition pair directly — it + // is required-existence only because it is a subclass of EeSegment. + await store.backend.insertNode({ + graphId: store.graphId, + kind: "EeSubSegment", + id: "ee-sub-orphan-1", + props: {}, + }); + const violations = await store.verifyConstraintFences(); + const violation = violations.find( + (candidate) => + candidate.family === "compositionExistence" && + candidate.partKind === "EeSubSegment", + ); + expect(violation).toBeDefined(); + if (violation?.family !== "compositionExistence") { + throw new Error("expected a compositionExistence violation"); + } + expect(violation.parts).toEqual([ + { kind: "EeSubSegment", id: "ee-sub-orphan-1" }, + ]); + }); + // MUTATION CHECK: in `requiredCompositionPartKinds` + // (src/store/operations/composition-create.ts), replace + // `registry.expandSubClasses(pair.partKind)` with `[pair.partKind]` — + // the planted `EeSubSegment` orphan above becomes invisible to + // `verifyConstraintFences` even though `EeSegment`'s own orphan (case + // 11) still reports. + + it("case 11c: verifyConstraintFences reports a live required part whose whole is tombstoned", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + // Tombstoned through the RAW backend member, bypassing the cascade that + // would otherwise take the part with it — the state a dirty database, a + // custom port, or a bypassed import can leave behind. The composition + // edge stays live, so judging attachment by the edge alone declared this + // part attached and the audit reported nothing. + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeEpisode", + id: episode.id, + }); + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(1); + + const violations = await store.verifyConstraintFences(); + const violation = violations.find( + (candidate) => + candidate.family === "compositionExistence" && + candidate.partKind === "EeSegment", + ); + expect(violation).toBeDefined(); + if (violation?.family !== "compositionExistence") { + throw new Error("expected a compositionExistence violation"); + } + expect(violation.parts).toEqual([{ kind: "EeSegment", id: segment.id }]); + }); + // MUTATION CHECK: drop the whole-liveness conjunct in + // `readCompositionUnattachedParts` (treat any attachment returned by + // `readCompositionAttachmentsForPage` as enough). The tombstoned whole + // above goes unreported, while case 11's unattached orphan still reports. + + it("case 11d: the write path still sees a tombstoned whole's incumbent attachment", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + const other = await store.nodes.EeEpisode.create({}); + await store.backend.deleteNode({ + graphId: store.graphId, + kind: "EeEpisode", + id: episode.id, + }); + // The audit's stronger verdict must NOT leak into the incumbent + // decision: the tombstoned whole still holds this part's attachment, so + // the move RETIRES that edge rather than treating the part as + // unattached and adding a second live one. + await store.nodes.EeSegment.reparent(segment.id, { + whole: { kind: "EeEpisode", id: other.id }, + }); + const live = await store.edges.eeSegmentOf.find({}); + expect(live).toHaveLength(1); + expect(requireDefined(live[0], "the moved attachment").toId).toBe( + other.id, + ); + // `population: "one"` binds for the row's whole life, so the move + // REMOVES the incumbent row rather than ending its window: one row + // total, not a second one beside it. + expect( + await store.edges.eeSegmentOf.find( + {}, + { temporalMode: "includeEnded" }, + ), + ).toHaveLength(1); + }); + // MUTATION CHECK: make the incumbent read whole-liveness-aware — in + // `findLiveCompositionAttachment`, drop a selected attachment whose whole + // `readLiveCompositionWholes` does not return. The move then finds no + // incumbent to retire and leaves TWO rows behind. + + it("case 12: a create whose `oneActive` composition edge would be born already-ended (unattaching) is refused, and writes no row", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.EeShow.create({}); + // The attachment's `validTo` is the composition edge's own window + // (`compositionEdgeWindow`): a `population: "oneActive"` edge born + // with a bounded window would never attach its part at all, which + // `resolveCompositionCreate` refuses for every part kind — for a + // required one, admitting it would leave a state + // `store.verifyConstraintFences()` immediately reports as a + // violation. + const error = await store.nodes.EeLiveClip.create( + {}, + { + partOf: { + whole: { kind: "EeShow", id: show.id }, + validTo: "2000-01-01T00:00:00.000Z", + }, + }, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ValidationError); + expect((error as ValidationError).details.issues).toEqual([ + expect.objectContaining({ + path: "partOf.validTo", + code: "COMPOSITION_ATTACHMENT_WINDOW_BOUNDED", + }), + ]); + + // No node row, and no composition edge row, survive the refused + // create — the whole write plan (node + composition edge) is one + // transaction. + expect(await store.nodes.EeLiveClip.find({})).toEqual([]); + const connectedEdges = await store.backend.findEdgesConnectedTo({ + graphId: store.graphId, + nodeKind: "EeShow", + nodeId: show.id, + }); + expect(connectedEdges).toEqual([]); + + // The graph never reaches a state `verifyConstraintFences` would + // have to report — refused at create time, not merely detected + // afterward. + const violations = await store.verifyConstraintFences(); + expect( + violations.some( + (candidate) => + candidate.family === "compositionExistence" && + candidate.partKind === "EeLiveClip", + ), + ).toBe(false); + }); + // MUTATION CHECK: in `compositionCreateWork` + // (src/store/operations/composition-create.ts), delete the + // `assertStatedWindowAttachesPart` call (the create then + // succeeds unconditionally once the composition edge is built). The + // `create` call above then resolves instead of rejecting, and the + // subsequent `find({})`/`verifyConstraintFences` assertions fail. + + it("case 12a: a node-level `validTo` alone never born-ends its `oneActive` composition edge", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.EeShow.create({}); + // The node's own window is closed, but the attachment states none, so + // the realizing edge takes the insert's own open window and attaches + // the part. + const clip = await store.nodes.EeLiveClip.create( + {}, + { + partOf: { whole: { kind: "EeShow", id: show.id } }, + validTo: "2000-01-01T00:00:00.000Z", + }, + ); + + const connectedEdges = await store.backend.findEdgesConnectedTo({ + graphId: store.graphId, + nodeKind: "EeLiveClip", + nodeId: clip.id, + }); + expect( + connectedEdges.map((edge) => ({ + kind: edge.kind, + toId: edge.to_id, + validTo: edge.valid_to, + })), + ).toEqual([{ kind: "eeLiveClipOf", toId: show.id, validTo: undefined }]); + }); + // MUTATION CHECK: in `executeNodeCreateInternal` + // (src/store/operations/node-operations.ts), spread the part node's + // `input.validTo` into the window passed to `prepareCompositionEdgeForCreate`. + // The edge is then born ended and the assertion above reads its + // `validTo`. + + it("case 12b: an already-unattached `oneActive` composition edge (planted through the RAW backend, bypassing case 12's create-time guard) is unattached, and cleaning it up is not refused", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const show = await store.nodes.EeShow.create({}); + // Born attaching, through the ordinary store path. + const clip = await store.nodes.EeLiveClip.create( + {}, + { partOf: { whole: { kind: "EeShow", id: show.id } } }, + ); + const [connectedEdge] = await store.backend.findEdgesConnectedTo({ + graphId: store.graphId, + nodeKind: "EeLiveClip", + nodeId: clip.id, + }); + const edgeId = requireDefined(connectedEdge).id; + + // Backdates the window through the RAW backend member, bypassing the + // store's own `assertCompositionExistencePreserved` refusal entirely + // (case 7c already proves the ORDINARY store path refuses this same + // mutation) — the dirty-database / bypassed-validation shape case 8 + // uses for the analogous node-side scenario. `population: "oneActive"` + // ends the attachment the moment the window closes, so this part is + // now unattached — verifyConstraintFences must say so, not report the + // graph clean. + await store.backend.updateEdge({ + graphId: store.graphId, + id: edgeId, + props: {}, + validTo: "2000-01-01T00:00:00.000Z", + }); + + const violations = await store.verifyConstraintFences(); + const violation = violations.find( + (candidate) => + candidate.family === "compositionExistence" && + candidate.partKind === "EeLiveClip", + ); + expect(violation).toBeDefined(); + if (violation?.family !== "compositionExistence") { + throw new Error("expected a compositionExistence violation"); + } + expect(violation.parts).toEqual([{ kind: "EeLiveClip", id: clip.id }]); + + // The edge is no longer an attachment, so deleting it is not what + // would orphan the part — it is already orphaned. + await expect( + store.edges.eeLiveClipOf.hardDelete(asEdgeId(edgeId)), + ).resolves.toBeUndefined(); + }); + // MUTATION CHECK: in `assertCompositionExistencePreserved` + // (src/store/operations/composition-create.ts), delete the + // `edgeCurrentlyAttachesPart` guard (refuse unconditionally once + // `compositionExistence(part.kind) === "required"` and the part is + // live, regardless of the edge's own valid-time window). The + // `hardDelete` above then rejects with `CompositionExistenceError` + // instead of resolving. + + it("case 13: an upsert never restores a required part without a live whole", async () => { + type ExistenceStore = Awaited< + ReturnType>> + >; + type SegmentId = Awaited< + ReturnType + >["id"]; + type EpisodeId = Awaited< + ReturnType + >["id"]; + const upserts: Readonly< + Record< + string, + (store: ExistenceStore, id: SegmentId) => Promise + > + > = { + upsertById: (store, id) => store.nodes.EeSegment.upsertById(id, {}), + bulkUpsertById: (store, id) => + store.nodes.EeSegment.bulkUpsertById([{ id, props: {} }]), + bulkReplaceById: (store, id) => + store.nodes.EeSegment.bulkReplaceById([{ id, props: {} }]), + "bulkUpsertById in a caught transaction": (store, id) => + store.transaction(async (tx) => { + await tx.nodes.EeSegment.bulkUpsertById([{ id, props: {} }]); + }), + }; + const retirements: Readonly< + Record< + string, + ( + store: ExistenceStore, + ids: Readonly<{ segment: SegmentId; episode: EpisodeId }>, + ) => Promise + > + > = { + "the part was deleted": (store, ids) => + store.nodes.EeSegment.delete(ids.segment), + "its whole was deleted": (store, ids) => + store.nodes.EeEpisode.delete(ids.episode), + }; + + // MUTATION CHECK: drop the `assertRestoredRequiredPartsAttached` calls + // from `executeNodeUpdate` and `executeNodeUpsertUpdateBatch` + // (src/store/operations/node-operations.ts) — each upsert then restores + // the segment with its composition edge still deleted, and + // `verifyConstraintFences` reports it. + for (const [retirement, retire] of Object.entries(retirements)) { + for (const [name, upsert] of Object.entries(upserts)) { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: episode } }, + ); + await retire(store, { segment: segment.id, episode: episode.id }); + + const error = await upsert(store, segment.id).catch( + (error_: unknown) => error_, + ); + const label = `${name} after ${retirement}`; + expect({ + label, + refused: + error instanceof CompositionExistenceError ? + error.details + : error, + restored: await store.nodes.EeSegment.getById(segment.id), + violations: await store.verifyConstraintFences(), + }).toEqual({ + label, + refused: { + situation: "create", + partKind: "EeSegment", + partId: segment.id, + }, + restored: undefined, + violations: [], + }); + } + } + + // An optional part is restored bare, as before. + const store = await context.createStore(buildGraph(nextGraphId())); + const collection = await store.nodes.EeCollection.create({}); + const tag = await store.nodes.EeTag.create( + {}, + { partOf: { whole: collection } }, + ); + await store.nodes.EeTag.delete(tag.id); + await store.nodes.EeTag.upsertById(tag.id, {}); + expect(await store.nodes.EeTag.getById(tag.id)).toBeDefined(); + }); + + it("case 13b: an upsert does not restore a required part whose surviving edge holds a tombstoned whole", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const segment = await store.nodes.EeSegment.create( + {}, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + // Both tombstones come from the raw backend member, so the segment's + // composition edge stays live and still names the dead episode. + for (const node of [segment, episode]) { + await store.backend.deleteNode({ + graphId: store.graphId, + kind: node.kind, + id: node.id, + }); + } + expect(await store.edges.eeSegmentOf.find({})).toHaveLength(1); + + const refusal = await store.nodes.EeSegment.upsertById( + segment.id, + {}, + ).catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(CompositionExistenceError); + expect((refusal as CompositionExistenceError).details).toMatchObject({ + situation: "create", + partId: segment.id, + }); + expect(await store.nodes.EeSegment.getById(segment.id)).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: in `assertRestoredRequiredPartsAttached` + // (composition-create.ts), accept any surviving attachment without asking + // whether its whole is live — the segment is then restored under a + // tombstoned episode. + + it("case 14: a composition create refused inside a caught transaction leaves no row of it behind", async () => { + type ExistenceStore = Awaited< + ReturnType>> + >; + const deadWholeId = "ee-dead-episode"; + const missing = { kind: "EeEpisode", id: "ee-missing-episode" } as const; + const dead = { kind: "EeEpisode", id: deadWholeId } as const; + const car = { kind: "EeCar", id: "ee-car" } as const; + const folder = (id: string) => ({ kind: "EeFolder", id }) as const; + + // Every refusal an attachment can reach on a create: a missing whole, a + // tombstoned whole, a self-attach, a cycle among the batch's own items, + // and a whole-side cardinality two items of one batch exceed together. + const refusedWrites: Readonly< + Record Promise> + > = { + "create, missing whole": (tx) => + tx.nodes.EeSegment.create({}, { partOf: { whole: missing } }), + "create, dead whole": (tx) => + tx.nodes.EeSegment.create({}, { partOf: { whole: dead } }), + "create, self-attach": (tx) => + tx.nodes.EeFolder.create( + {}, + { id: "ee-self", partOf: { whole: folder("ee-self") } }, + ), + "bulkCreate, missing whole": (tx) => + tx.nodes.EeSegment.bulkCreate([ + { props: {}, partOf: { whole: missing } }, + ]), + // `bulkInsert`'s item type states no `partOf`; the runtime shares + // `bulkCreate`'s write plan and honors one, which is what is probed. + "bulkInsert, dead whole": (tx) => + tx.nodes.EeSegment.bulkInsert([ + { props: {}, partOf: { whole: dead } }, + ] as never), + "bulkCreate, in-batch cycle": (tx) => + tx.nodes.EeFolder.bulkCreate([ + { props: {}, id: "ee-a", partOf: { whole: folder("ee-b") } }, + { props: {}, id: "ee-b", partOf: { whole: folder("ee-a") } }, + ]), + "bulkInsert, in-batch cycle": (tx) => + tx.nodes.EeFolder.bulkInsert([ + { props: {}, id: "ee-a", partOf: { whole: folder("ee-b") } }, + { props: {}, id: "ee-b", partOf: { whole: folder("ee-a") } }, + ] as never), + "bulkCreate, in-batch cardinality": (tx) => + tx.nodes.EeEngine.bulkCreate([ + { props: {}, partOf: { whole: car } }, + { props: {}, partOf: { whole: car } }, + ]), + }; + + // MUTATION CHECK: in `executeNodeCreateInternal` and both batch + // executors (src/store/operations/node-operations.ts), run the + // composition edge's preparation AFTER the node insert again — every + // write below is still refused, but the part rows it had already + // written stay in the transaction that caught the refusal. + for (const [name, write] of Object.entries(refusedWrites)) { + const store = await context.createStore(buildGraph(nextGraphId())); + await store.nodes.EeEpisode.create({}, { id: deadWholeId }); + await store.nodes.EeEpisode.delete(dead.id as never); + await store.nodes.EeCar.create({}, { id: car.id }); + + let refusal: unknown; + await store.transaction(async (tx) => { + refusal = await write(tx as unknown as ExistenceStore).catch( + (error: unknown) => error, + ); + }); + + expect({ + name, + refused: refusal instanceof TypeGraphError, + segments: await store.nodes.EeSegment.count(), + folders: await store.nodes.EeFolder.count(), + engines: await store.nodes.EeEngine.count(), + violations: await store.verifyConstraintFences(), + }).toEqual({ + name, + refused: true, + segments: 0, + folders: 0, + engines: 0, + violations: [], + }); + } + }); + + it("case 14b: a refused get-or-create attachment caught in a transaction leaves no part row", async () => { + const store = await context.createStore(buildKeyedGraph(nextGraphId())); + + // MUTATION CHECK: as case 14 — the single create's preparation moved + // back behind the node insert leaves the keyed segment live and + // unattached. + let refusal: unknown; + await store.transaction(async (tx) => { + refusal = await tx.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "k" }, + { partOf: { whole: { kind: "EeEpisode", id: "ee-missing" } } }, + ).catch((error: unknown) => error); + }); + + expect(refusal).toBeInstanceOf(EndpointNotFoundError); + expect(await store.nodes.EeKeyedSegment.count()).toBe(0); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("case 14c: a batch still attaches an item to a whole the same batch creates", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + + // MUTATION CHECK: in `prepareBatchCompositionCreateEdges`, judge every + // whole through the pre-insert read (drop the `wholeInFrame` evidence) — + // the child's whole has no row yet and the batch is refused. + await store.nodes.EeFolder.bulkCreate([ + { + props: {}, + id: "ee-child", + partOf: { whole: { kind: "EeFolder", id: "ee-parent" } }, + }, + { props: {}, id: "ee-parent" }, + ]); + + const edges = await store.edges.eeFolderOf.find({}); + expect(edges.map((edge) => [edge.fromId, edge.toId])).toEqual([ + ["ee-child", "ee-parent"], + ]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("getOrCreateByConstraint never resurrects a required part under a tombstoned whole it is still attached to", async () => { + const store = await context.createStore(buildKeyedGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const attachment = { + partOf: { whole: { kind: "EeEpisode", id: episode.id } }, + } as const; + const { node: segment } = + await store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + attachment, + ); + // Both ends tombstoned by a route that leaves the composition edge live + // (what a provenance retraction of a whole and its required part + // leaves), so the incumbent attachment is "satisfied" by a dead whole. + for (const node of [segment, episode]) { + await store.backend.deleteNode({ + graphId: store.graphId, + kind: node.kind, + id: node.id, + }); + } + expect(await store.edges.eeKeyedSegmentOf.find({})).toHaveLength(1); + + // MUTATION CHECK: reading the whole only for the "attach" and "replace" + // dispositions in `decideCompositionAttachmentUnderFence` makes this + // resolve with action "resurrected" — verified and reverted. + await expect( + store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + attachment, + ), + ).rejects.toBeInstanceOf(EndpointNotFoundError); + + expect( + await store.nodes.EeKeyedSegment.getById(segment.id), + ).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("getOrCreateByConstraint: partOf applied on created, refused on found/updated naming the current whole", async () => { + const store = await context.createStore(buildKeyedGraph(nextGraphId())); + const episodeA = await store.nodes.EeEpisode.create({}); + const episodeB = await store.nodes.EeEpisode.create({}); + + const created = await store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + { partOf: { whole: { kind: "EeEpisode", id: episodeA.id } } }, + ); + expect(created.action).toBe("created"); + const attachedEdges = await store.edges.eeKeyedSegmentOf.find({}); + expect(attachedEdges).toHaveLength(1); + expect(attachedEdges[0]?.fromId).toBe(created.node.id); + expect(attachedEdges[0]?.toId).toBe(episodeA.id); + + const foundError = + await store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + { partOf: { whole: { kind: "EeEpisode", id: episodeB.id } } }, + ).catch((error_: unknown) => error_); + expect(foundError).toBeInstanceOf(CompositionExistenceError); + expect((foundError as CompositionExistenceError).details.situation).toBe( + "existing", + ); + expect( + (foundError as CompositionExistenceError).details.currentWhole, + ).toEqual({ kind: "EeEpisode", id: episodeA.id }); + + const updatedError = + await store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + { + ifExists: "update", + partOf: { whole: { kind: "EeEpisode", id: episodeB.id } }, + }, + ).catch((error_: unknown) => error_); + expect(updatedError).toBeInstanceOf(CompositionExistenceError); + + await store.nodes.EeKeyedSegment.delete(created.node.id); + const resurrected = + await store.nodes.EeKeyedSegment.getOrCreateByConstraint( + "byKey", + { key: "seg-1" }, + { partOf: { whole: { kind: "EeEpisode", id: episodeB.id } } }, + ); + expect(resurrected.action).toBe("resurrected"); + const edgesAfterResurrect = await store.edges.eeKeyedSegmentOf.find({}); + expect(edgesAfterResurrect.map((edge) => edge.toId)).toEqual([ + episodeB.id, + ]); + }); + // MUTATION CHECK: stop resolving the attachment on the existing-row legs + // of `executeNodeGetOrCreateByConstraint` (drop both + // `resolveGetOrCreateAttachmentRequest` calls, + // src/store/operations/node-operations.ts) — the `"found"` and + // `ifExists: "update"` calls above then silently drop `partOf` instead of + // refusing the contradicting whole. (The postcondition replaced the + // blanket refusal this comment once named: a found node with NO live + // whole is now attached rather than refused, which is why the mutation is + // stated as removing the calls rather than as restoring a condition.) + + it("bulkGetOrCreateByConstraint: a within-batch duplicate of a row THIS CALL just created honors the same stated whole", async () => { + const store = await context.createStore(buildKeyedGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + + // Two items with the SAME constraint key, in ONE call, naming the + // whole this exact call is about to attach the first occurrence to — + // the duplicate must be a plain "found" hit, not a refusal. + const results = + await store.nodes.EeKeyedSegment.bulkGetOrCreateByConstraint( + "byKey", + [{ props: { key: "dup-1" } }, { props: { key: "dup-1" } }], + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + expect(results[0]?.action).toBe("created"); + expect(results[1]?.action).toBe("found"); + expect(results[1]?.node.id).toBe(results[0]?.node.id); + const edges = await store.edges.eeKeyedSegmentOf.find({}); + expect(edges).toHaveLength(1); + }); + // MUTATION CHECK: in step 7's duplicate-resolution loop in + // `executeNodeBulkGetOrCreateByConstraint` + // (src/store/operations/node-operations.ts), copy the source's own action + // (`action: sourceResult.action`) instead of `"found"` — the duplicate + // then reports `"created"` and the `results[1]` assertion above fails. + // + // The mutation this comment used to name — calling `refuseExistingPartOf` + // unconditionally here — is no longer performable: that function and its + // blanket refusal are gone. `partOf` on a found node is now a + // POSTCONDITION (`applyExistingPartOfPostcondition`, or the update leg's + // own write plan), discharged in steps 5/6, and re-running it for a + // duplicate of the row this same call just attached would be SATISFIED + // rather than a refusal. Step 7 deliberately + // does not re-run it; see the comment there. + + it("bulkGetOrCreateByConstraint refuses a new required part with no partOf before updating a matched item", async () => { + const store = await context.createStore(buildKeyedGraph(nextGraphId())); + const episode = await store.nodes.EeEpisode.create({}); + const existing = await store.nodes.EeKeyedSegment.create( + { key: "existing" }, + { partOf: { whole: { kind: "EeEpisode", id: episode.id } } }, + ); + + // MUTATION CHECK: drop the `resolveBatchCompositionWorks` call that + // precedes the matched items in + // `executeNodeBulkGetOrCreateByConstraint` + // (src/store/operations/node-operations.ts) — "existing" is then + // updated (its version advances) before the create batch refuses + // "fresh", and the caught refusal leaves that update committed. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.EeKeyedSegment.bulkGetOrCreateByConstraint( + "byKey", + [{ props: { key: "existing" } }, { props: { key: "fresh" } }], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(CompositionExistenceError); + const after = await store.nodes.EeKeyedSegment.getById(existing.id); + expect(after?.meta.version).toBe(existing.meta.version); + expect(await store.nodes.EeKeyedSegment.find({})).toHaveLength(1); + }); + }); +} + +const EeKeyedSegment = defineNode("EeKeyedSegment", { + schema: z.object({ key: z.string() }), +}); +const eeKeyedSegmentOf = defineEdge("eeKeyedSegmentOf", { + schema: z.object({}), +}); + +function buildKeyedGraph(id: string) { + return defineGraph({ + id, + nodes: { + EeEpisode: { type: EeEpisode }, + EeKeyedSegment: { + type: EeKeyedSegment, + unique: [ + { + name: "byKey", + fields: ["key"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + eeKeyedSegmentOf: { + type: eeKeyedSegmentOf, + from: [EeKeyedSegment], + to: [EeEpisode], + cardinality: "one", + }, + }, + ontology: [ + partOf(EeKeyedSegment, EeEpisode, { + via: eeKeyedSegmentOf, + existence: "required", + }), + ], + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-fence.ts b/packages/typegraph/tests/backends/integration/composition-fence.ts new file mode 100644 index 000000000..071d183e0 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-fence.ts @@ -0,0 +1,805 @@ +/** + * The composition claim, on every backend. + * + * One whole per part: a part holds exactly one whole across every declared `partOf`/ + * `hasPart` pair, enforced by one claim row on the reserved, relation-wide + * axis in `typegraph_edge_claims` — never one row per realizing edge kind. + * Union acyclicity: the union of every composition-realizing edge kind, oriented part -> + * whole, is ONE acyclic relation, checked by the exhaustive reachability + * probe with no code of its own. + * + * Each case states, in a comment, the mutation that must make it fail (the + * revert/mutation check load-bearing tests require). + * + * The genuine-concurrency case (below, "two concurrent attaches") is what + * stands in for a real-PostgreSQL contention test: this suite already runs + * against the repo's PGlite lane (`tests/backends/postgres/ + * pglite-integration-suite.test.ts`), which is a real PostgreSQL SQL engine, + * in-process. PGlite is single-connection and cannot demonstrate genuine + * OS-level interleaving (see `tests/constraint-write-fence.test.ts`'s + * docblock for the same caveat stated for cardinality), but composition's + * fence is a claim ROW under a primary key, not a TypeScript-level + * probe-then-write race window — correctness does not depend on genuine + * parallelism, only on the database key actually being asked to arbitrate, + * which `Promise.all` here does regardless of how PGlite schedules the two + * statements underneath. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CompositionError, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + hasPart, + partOf, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const CfChapter = defineNode("CfChapter", { schema: z.object({}) }); +const CfBook = defineNode("CfBook", { schema: z.object({}) }); +const CfAnthology = defineNode("CfAnthology", { schema: z.object({}) }); +const CfSection = defineNode("CfSection", { schema: z.object({}) }); +const CfCompendium = defineNode("CfCompendium", { schema: z.object({}) }); +/** + * A REFLEXIVE composition part/whole: the declaration-level hierarchical + * cycle check (`detectHierarchicalCycles`) refuses two OPPOSITE `partOf` + * declarations across two different kinds outright (a genuine kind-level + * cycle is refused at `defineGraph` time, never reaching a runtime check at + * all) — composition's instance-level cycle is only reachable through a + * reflexive pair on ONE kind, exactly the shape `isReflexiveCompositionAllowed` + * exempts from that check. + */ +const CfFolder = defineNode("CfFolder", { schema: z.object({}) }); +const CfActivePart = defineNode("CfActivePart", { schema: z.object({}) }); +const CfActiveWhole = defineNode("CfActiveWhole", { schema: z.object({}) }); +const CfOnePart = defineNode("CfOnePart", { schema: z.object({}) }); +const CfOneWhole = defineNode("CfOneWhole", { schema: z.object({}) }); +/** + * DualPart -> DualWhole, part `from`, declaring BOTH `cardinality: "one"` + * (composition's own population axis) AND an orthogonal `targetCardinality: + * "one"` — the shape that exposes a per-row re-derivation of "is this a + * composition row": it folds a composition edge kind's ORDINARY axis + * violations onto the reserved composition axis too, regardless of which + * declaration's query actually produced the row. + */ +const CfDualPart = defineNode("CfDualPart", { schema: z.object({}) }); +const CfDualWhole = defineNode("CfDualWhole", { schema: z.object({}) }); + +/** Chapter -> Book, the ordinary "from"-side-part orientation. */ +const cfChapterOf = defineEdge("cfChapterOf", { schema: z.object({}) }); +/** Chapter -> Anthology, a SECOND whole kind realized by a different edge kind. */ +const cfIncludedIn = defineEdge("cfIncludedIn", { schema: z.object({}) }); +/** Section -> Book, "from"-side-part. */ +const cfSectionOf = defineEdge("cfSectionOf", { schema: z.object({}) }); +/** Anthology -> Section, "to"-side-part (a `has_*`-shaped realizing edge). */ +const cfHasSection = defineEdge("cfHasSection", { schema: z.object({}) }); +/** Compendium -> Section, a SECOND `has_*`-shaped edge over the same part kind. */ +const cfHasSectionAlt = defineEdge("cfHasSectionAlt", { schema: z.object({}) }); +/** Folder -> Folder, "from"-side-part: the source folder is part of the target. */ +const cfContainsA = defineEdge("cfContainsA", { schema: z.object({}) }); +/** Folder -> Folder, "from"-side-part, a SECOND edge kind over the same pair. */ +const cfContainsB = defineEdge("cfContainsB", { schema: z.object({}) }); +/** Folder -> Folder, "to"-side-part: the TARGET folder is part of the source — the REVERSED member. */ +const cfContainsC = defineEdge("cfContainsC", { schema: z.object({}) }); +/** ActivePart -> ActiveWhole, population `oneActive`. */ +const cfActiveOf = defineEdge("cfActiveOf", { schema: z.object({}) }); +/** OnePart -> OneWhole, population `one`. */ +const cfOneOf = defineEdge("cfOneOf", { schema: z.object({}) }); +/** DualPart -> DualWhole, `cardinality: "one"` AND `targetCardinality: "one"`. */ +const cfDualOf = defineEdge("cfDualOf", { schema: z.object({}) }); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + CfChapter: { type: CfChapter }, + CfBook: { type: CfBook }, + CfAnthology: { type: CfAnthology }, + CfSection: { type: CfSection }, + CfCompendium: { type: CfCompendium }, + CfFolder: { type: CfFolder }, + CfActivePart: { type: CfActivePart }, + CfActiveWhole: { type: CfActiveWhole }, + CfOnePart: { type: CfOnePart }, + CfOneWhole: { type: CfOneWhole }, + CfDualPart: { type: CfDualPart }, + CfDualWhole: { type: CfDualWhole }, + }, + edges: { + cfChapterOf: { + type: cfChapterOf, + from: [CfChapter], + to: [CfBook], + cardinality: "one", + }, + cfIncludedIn: { + type: cfIncludedIn, + from: [CfChapter], + to: [CfAnthology], + cardinality: "one", + }, + cfSectionOf: { + type: cfSectionOf, + from: [CfSection], + to: [CfBook], + cardinality: "one", + }, + cfHasSection: { + type: cfHasSection, + from: [CfAnthology], + to: [CfSection], + targetCardinality: "one", + }, + cfHasSectionAlt: { + type: cfHasSectionAlt, + from: [CfCompendium], + to: [CfSection], + targetCardinality: "one", + }, + cfContainsA: { + type: cfContainsA, + from: [CfFolder], + to: [CfFolder], + cardinality: "one", + }, + cfContainsB: { + type: cfContainsB, + from: [CfFolder], + to: [CfFolder], + cardinality: "one", + }, + cfContainsC: { + type: cfContainsC, + from: [CfFolder], + to: [CfFolder], + targetCardinality: "one", + }, + cfActiveOf: { + type: cfActiveOf, + from: [CfActivePart], + to: [CfActiveWhole], + cardinality: "oneActive", + }, + cfOneOf: { + type: cfOneOf, + from: [CfOnePart], + to: [CfOneWhole], + cardinality: "one", + }, + cfDualOf: { + type: cfDualOf, + from: [CfDualPart], + to: [CfDualWhole], + cardinality: "one", + targetCardinality: "one", + }, + }, + ontology: [ + partOf(CfChapter, CfBook, { via: cfChapterOf }), + partOf(CfChapter, CfAnthology, { via: cfIncludedIn }), + partOf(CfSection, CfBook, { via: cfSectionOf }), + hasPart(CfAnthology, CfSection, { via: cfHasSection }), + hasPart(CfCompendium, CfSection, { via: cfHasSectionAlt }), + partOf(CfFolder, CfFolder, { via: cfContainsA, partSide: "from" }), + partOf(CfFolder, CfFolder, { via: cfContainsB, partSide: "from" }), + hasPart(CfFolder, CfFolder, { via: cfContainsC, partSide: "to" }), + partOf(CfActivePart, CfActiveWhole, { via: cfActiveOf }), + partOf(CfOnePart, CfOneWhole, { via: cfOneOf }), + partOf(CfDualPart, CfDualWhole, { via: cfDualOf }), + ], + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `composition_fence_${graphIdCounter}`; +} + +/** Asserts `error` is a `CompositionError` naming `partId` as already occupied. */ +function expectWholeOccupied(error: unknown, partId: string): void { + expect(error).toBeInstanceOf(CompositionError); + expect((error as CompositionError).details.partId).toBe(partId); +} + +export function registerCompositionFenceIntegrationTests( + context: IntegrationTestContext, +): void { + describe("composition claim (one whole per part, relation-wide)", () => { + it("refuses a second whole realized by a DIFFERENT edge kind (two-relation single-whole violation)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const chapter = await store.nodes.CfChapter.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + await store.edges.cfChapterOf.create(chapter, book, {}); + + const error = await store.edges.cfIncludedIn + .create(chapter, anthology, {}) + .catch((error_: unknown) => error_); + expectWholeOccupied(error, chapter.id); + expect((error as CompositionError).details).toMatchObject({ + situation: "occupied", + wholeId: anthology.id, + edgeKind: "cfIncludedIn", + }); + }); + // MUTATION CHECK: drop the composition claim from `edgeInsertClaims` + // (src/store/claims/composition-claims.ts) — both per-edge cardinality + // claims are satisfied independently (chapter has no OTHER cfChapterOf + // edge, no OTHER cfIncludedIn edge), so this write then succeeds and + // chapter ends up with two live wholes. + + it("refuses a second whole realized by a DIFFERENT `has_*`-shaped edge kind (both to-side)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const section = await store.nodes.CfSection.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + const compendium = await store.nodes.CfCompendium.create({}); + + await store.edges.cfHasSection.create(anthology, section, {}); + + const error = await store.edges.cfHasSectionAlt + .create(compendium, section, {}) + .catch((error_: unknown) => error_); + expectWholeOccupied(error, section.id); + }); + + it("refuses a second whole across ONE relation in each orientation for the same part kind (one row, one refusal)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const section = await store.nodes.CfSection.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + // `cfSectionOf`: the part is the edge's `from` (direction: "source"). + await store.edges.cfSectionOf.create(section, book, {}); + + // `cfHasSection`: the part is the SAME section, but as the edge's `to` + // (direction: "target"). Both claims must fold onto ONE row, or this + // write would wrongly succeed. + const error = await store.edges.cfHasSection + .create(anthology, section, {}) + .catch((error_: unknown) => error_); + expectWholeOccupied(error, section.id); + }); + // MUTATION CHECK: force `direction: "source"` unconditionally in + // `compositionClaim` (src/store/claims/composition-claims.ts). The + // mixed-orientation case above then writes two DIFFERENT keys (both + // computed as if `cfHasSection`'s part were its `from` endpoint, i.e. + // the anthology) and admits two wholes for one section. + + it("refuses a cross-kind cycle over the composition union even though neither edge kind is acyclic alone", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const folderA = await store.nodes.CfFolder.create({}); + const folderB = await store.nodes.CfFolder.create({}); + + // folderA partOf folderB via cfContainsA. + await store.edges.cfContainsA.create(folderA, folderB, {}); + // folderB partOf folderA via cfContainsB (a DIFFERENT realizing edge + // kind, so this is not merely one relation contending with itself) + // closes folderA -> folderB -> folderA in the union relation. The two + // parts (folderA, folderB) are distinct, so the one-whole-per-part claim is untouched — + // this is purely the union's acyclicity. + const error = await store.edges.cfContainsB + .create(folderB, folderA, {}) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EdgeAcyclicityError); + // The relation name reaching the public error is the printable + // "composition", never the reserved U+001E-prefixed claim axis it is + // stored as internally. + expect((error as EdgeAcyclicityError).details.relation).toBe( + "composition", + ); + }); + // MUTATION CHECK: narrow `compositionAcyclicRelation` + // (src/store/acyclicity.ts) to return `undefined` (or restrict + // `registry.compositionEdgeKinds()` to the single edge kind being + // written). The second create then passes: cfContainsB alone has no + // cycle. + + it("refuses a cycle formed with a REVERSED (has_*-shaped) member, proving the union's orientation flag", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const folderA = await store.nodes.CfFolder.create({}); + const folderB = await store.nodes.CfFolder.create({}); + + // folderA partOf folderB via cfContainsA (stored A -> B, walked + // forward: reversed: false). + await store.edges.cfContainsA.create(folderA, folderB, {}); + // hasPart(folderA, folderB) via cfContainsC means "folderB partOf + // folderA", but the edge itself is ALSO stored A -> B (cfContainsC's + // own endpoints, `partSide: "to"`): only walking it REVERSED (B -> A) + // closes the cycle A -> B -> A. Walked forward (the defect this test + // exists to catch), the union would see A -> B twice and report no + // cycle. + const error = await store.edges.cfContainsC + .create(folderA, folderB, {}) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(EdgeAcyclicityError); + expect((error as EdgeAcyclicityError).details.relation).toBe( + "composition", + ); + }); + // MUTATION CHECK: hard-code `reversed: false` in `compositionAcyclicRelation` + // (src/store/acyclicity.ts) instead of reading + // `registry.compositionPartSide(edgeKind) === "to"`. This case then + // passes (no cycle found) while the previous one still fails, proving the + // flag — not just the union's existence — is load-bearing. + + it("refuses a second whole with the same CompositionError, naming the incumbent edge, on every create shape", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const chapter = await store.nodes.CfChapter.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + const incumbent = await store.edges.cfChapterOf.create(chapter, book, {}); + const second = { from: chapter, to: anthology, props: {} }; + + const shapes: Record Promise> = { + create: () => store.edges.cfIncludedIn.create(chapter, anthology, {}), + bulkCreate: () => store.edges.cfIncludedIn.bulkCreate([second]), + bulkInsert: () => store.edges.cfIncludedIn.bulkInsert([second]), + getOrCreateByEndpoints: () => + store.edges.cfIncludedIn.getOrCreateByEndpoints( + chapter, + anthology, + {}, + ), + bulkGetOrCreateByEndpoints: () => + store.edges.cfIncludedIn.bulkGetOrCreateByEndpoints([second]), + "create caught inside a transaction": async () => { + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await tx.edges.cfIncludedIn + .create(chapter, anthology, {}) + .catch((error: unknown) => refusals.push(error)); + }); + throw refusals[0]; + }, + }; + const refusals: Record = {}; + for (const [shape, run] of Object.entries(shapes)) { + refusals[shape] = await run().then( + () => "accepted", + (error: unknown) => + error instanceof CompositionError ? error.details : error, + ); + } + + const refusal = expect.objectContaining({ + partId: chapter.id, + wholeId: anthology.id, + edgeKind: "cfIncludedIn", + incumbentEdgeId: incumbent.id, + }) as unknown; + expect(refusals).toEqual( + Object.fromEntries( + Object.keys(shapes).map((shape) => [shape, refusal]), + ), + ); + + expect(await store.edges.cfIncludedIn.find({})).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("reports the composition refusal ahead of the ordinary axes on a kind that declares both", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const part = await store.nodes.CfDualPart.create({}); + const wholeA = await store.nodes.CfDualWhole.create({}); + const wholeB = await store.nodes.CfDualWhole.create({}); + const incumbent = await store.edges.cfDualOf.create(part, wholeA, {}); + + const error = await store.edges.cfDualOf + .create(part, wholeB, {}) + .catch((error_: unknown) => error_); + + expectWholeOccupied(error, part.id); + expect((error as CompositionError).details).toMatchObject({ + situation: "occupied", + incumbentEdgeId: incumbent.id, + }); + }); + + it("`oneActive` reparents once the incumbent's window ends, with no delete required", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const part = await store.nodes.CfActivePart.create({}); + const wholeA = await store.nodes.CfActiveWhole.create({}); + const wholeB = await store.nodes.CfActiveWhole.create({}); + + const first = await store.edges.cfActiveOf.create( + part, + wholeA, + {}, + { validFrom: "2019-01-01T00:00:00.000Z" }, + ); + await expect( + store.edges.cfActiveOf.create(part, wholeB, {}), + ).rejects.toBeInstanceOf(CompositionError); + + // Ending the window (no delete) frees an ACTIVE-ONLY axis in place. + await store.edges.cfActiveOf.update( + first.id, + {}, + { validTo: "2020-01-01T00:00:00.000Z" }, + ); + await expect( + store.edges.cfActiveOf.create(part, wholeB, {}), + ).resolves.toBeDefined(); + }); + + it("`one` keeps refusing a reattach until the incumbent edge is actually deleted", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const part = await store.nodes.CfOnePart.create({}); + const wholeA = await store.nodes.CfOneWhole.create({}); + const wholeB = await store.nodes.CfOneWhole.create({}); + + const first = await store.edges.cfOneOf.create( + part, + wholeA, + {}, + { validFrom: "2019-01-01T00:00:00.000Z" }, + ); + // Ending the window does NOT free a `claimsWhenBornEnded: true` axis — + // `one`'s population counts every live edge, active or not. + await store.edges.cfOneOf.update( + first.id, + {}, + { validTo: "2020-01-01T00:00:00.000Z" }, + ); + await expect( + store.edges.cfOneOf.create(part, wholeB, {}), + ).rejects.toBeInstanceOf(CompositionError); + + await store.edges.cfOneOf.delete(first.id); + await expect( + store.edges.cfOneOf.create(part, wholeB, {}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK: force `holderLiveness: "live"` to read as + // `"liveAndActive"` for `source:one` in `EDGE_CARDINALITY_SPECS` + // (src/store/claims/edge-claims.ts). The "one" case above then reparents + // right after `validTo` is set, without the delete, and its first + // `rejects` assertion fails. + + it("a bulk create of composition edges reserves both parts' axes — no custom-port fallback leaves a partial claim", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const chapterA = await store.nodes.CfChapter.create({}); + const chapterB = await store.nodes.CfChapter.create({}); + const bookA = await store.nodes.CfBook.create({}); + const bookB = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + await store.edges.cfChapterOf.bulkCreate([ + { from: chapterA, to: bookA }, + { from: chapterB, to: bookB }, + ]); + + // Both rows landed... + expect(await store.edges.cfChapterOf.findFrom(chapterA)).toHaveLength(1); + expect(await store.edges.cfChapterOf.findFrom(chapterB)).toHaveLength(1); + // ...and each one's composition claim was genuinely taken: attaching + // EITHER chapter to a second whole through a different edge kind is + // still refused. A silently unwritten composition claim (the fused + // path applying only the ordinary axis) would let this succeed. + await expect( + store.edges.cfIncludedIn.create(chapterA, anthology, {}), + ).rejects.toBeInstanceOf(CompositionError); + }); + // MUTATION CHECK (verified, reverted, via + // tests/atomic-generated-edge-batch.test.ts's dedicated eligibility + // case — this suite's default backend never marks atomic-batch + // support, so the fused path is not reachable through THIS test): + // narrowing `compositionAcyclicRelation` to return `undefined` makes + // `resolveAtomicEdgeBatchExecutor` resolve the fused executor for a + // composition kind wherever a backend's bundled root declares atomic + // batch support, and the fused program never applies the composition + // claim. + + it("a bulk create's stale-claim takeover cannot steal a live incumbent held by a DIFFERENT composition edge kind", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const chapter = await store.nodes.CfChapter.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + await store.edges.cfChapterOf.create(chapter, book, {}); + + // The batch claim path's initial lock reports an existing holder of a + // DIFFERENT edge id (`cfChapterOf`'s) than this write's own + // (`cfIncludedIn`'s), so it falls to the stale-claim takeover + // statement. That statement's liveness check must read every + // composition-scope holder kind, not only the WRITING edge's own kind + // — otherwise the live `cfChapterOf` incumbent is invisible to it and + // the axis is stolen out from under a still-live edge. + await expect( + store.edges.cfIncludedIn.bulkCreate([{ from: chapter, to: anthology }]), + ).rejects.toBeInstanceOf(CompositionError); + + // The part still holds exactly its original whole. + expect(await store.edges.cfChapterOf.findFrom(chapter)).toHaveLength(1); + expect(await store.edges.cfIncludedIn.findFrom(chapter)).toHaveLength(0); + }); + // MUTATION CHECK: revert the `claimHolderTerms` call inside + // `recordedClaimHolderIsLivePredicate`, which `buildTakeOverEdgeClaim` + // reads (src/backend/drizzle/operations/edge-claims.ts), back to the + // inline `edges.kind = values.edgeKind` + + // `endpointTerms(...)` spelling. The takeover's liveness sub-select then + // filters on the WRITING edge's own kind (`cfIncludedIn`) instead of + // every composition holder kind, finds no live `cfIncludedIn` row at the + // part, and the stale-claim takeover wrongly succeeds: `bulkCreate` + // resolves instead of rejecting, and the part ends up with two live + // wholes (verified, reverted). + + it("self-heals across a DIFFERENT edge kind once the incumbent is hard-deleted behind the store's back", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const backend = store.backend; + const chapter = await store.nodes.CfChapter.create({}); + const bookA = await store.nodes.CfBook.create({}); + const bookB = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + const incumbent = await store.edges.cfChapterOf.create( + chapter, + bookA, + {}, + ); + + await backend.hardDeleteEdge({ + graphId: store.graphId, + id: incumbent.id, + kind: "cfChapterOf", + }); + + // The axis self-heals across a DIFFERENT realizing edge kind — the + // liveness predicate reads the entity relation directly, never a + // claim-row release path. + const replacement = await store.edges.cfIncludedIn.create( + chapter, + anthology, + {}, + ); + expect(replacement.toId).toBe(anthology.id); + + // And the axis is genuinely held by the replacement: a third whole + // through the FIRST edge kind is still refused. + await expect( + store.edges.cfChapterOf.create(chapter, bookB, {}), + ).rejects.toBeInstanceOf(CompositionError); + }); + + it("verifyConstraintFences reports a family: composition violation for a part with two live wholes planted directly", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const backend = store.backend; + const chapter = await store.nodes.CfChapter.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + const first = await store.edges.cfChapterOf.create(chapter, book, {}); + // Bypasses the store entirely — no claim is written for this second + // row, exactly the shape a trusted import or a pre-upgrade database + // leaves behind. + await backend.insertEdge({ + graphId: store.graphId, + id: "cf-planted-second-whole", + kind: "cfIncludedIn", + fromKind: "CfChapter", + fromId: chapter.id, + toKind: "CfAnthology", + toId: anthology.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const compositionViolation = violations.find( + (violation) => violation.family === "composition", + ); + expect(compositionViolation).toBeDefined(); + if (compositionViolation?.family !== "composition") { + throw new Error("expected a composition violation"); + } + expect([...compositionViolation.edgeIds].toSorted()).toEqual( + [first.id, "cf-planted-second-whole"].toSorted(), + ); + }); + // REVERT CHECK: remove the `family: "composition"` arm — i.e. let + // `edgeCardinalityViolations` (src/store/claims/verify.ts) fold every + // row through the ordinary per-edge-kind axis unconditionally. The two + // rows above then fold onto two DIFFERENT axes (`one:cfChapterOf` and + // `one:cfIncludedIn`), each with exactly one holder, and this test finds + // a clean graph instead of the violation. + + it("verifyConstraintFences reports an ended and a future-dated whole of one part across both orientations", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const section = await store.nodes.CfSection.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + // The composition population here is `one`, which counts a row for its + // whole life — so an ended attachment (part `from`, via cfSectionOf) and + // a future-dated one (part `to`, via cfHasSection) contend the part's + // axis even though neither is current. + await store.backend.insertEdge({ + graphId: store.graphId, + id: "cf-ended-whole", + kind: "cfSectionOf", + fromKind: "CfSection", + fromId: section.id, + toKind: "CfBook", + toId: book.id, + props: {}, + validFrom: "2000-01-01T00:00:00.000Z", + validTo: "2001-01-01T00:00:00.000Z", + }); + await store.backend.insertEdge({ + graphId: store.graphId, + id: "cf-future-whole", + kind: "cfHasSection", + fromKind: "CfAnthology", + fromId: anthology.id, + toKind: "CfSection", + toId: section.id, + props: {}, + validFrom: "2999-01-01T00:00:00.000Z", + }); + + const violations = await store.verifyConstraintFences(); + const compositionViolations = violations.filter( + (violation) => violation.family === "composition", + ); + expect(compositionViolations).toHaveLength(1); + expect(requireDefined(compositionViolations[0]).edgeIds).toEqual([ + "cf-ended-whole", + "cf-future-whole", + ]); + }); + + it("verifyConstraintFences still reports a composition edge kind's OWN orthogonal axis violation", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const backend = store.backend; + const partA = await store.nodes.CfDualPart.create({}); + const partB = await store.nodes.CfDualPart.create({}); + const whole = await store.nodes.CfDualWhole.create({}); + + // Two DIFFERENT parts attached to the SAME whole: a genuine + // `targetCardinality: "one"` violation on `cfDualOf`'s own ordinary + // target axis. Neither part holds more than one whole, so this is NOT + // a composition violation — planted directly, bypassing the + // store's claims entirely, the shape a trusted import or a + // pre-upgrade database leaves behind. + await backend.insertEdge({ + graphId: store.graphId, + id: "cf-dual-row-a", + kind: "cfDualOf", + fromKind: "CfDualPart", + fromId: partA.id, + toKind: "CfDualWhole", + toId: whole.id, + props: {}, + }); + await backend.insertEdge({ + graphId: store.graphId, + id: "cf-dual-row-b", + kind: "cfDualOf", + fromKind: "CfDualPart", + fromId: partB.id, + toKind: "CfDualWhole", + toId: whole.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const cardinalityViolation = violations.find( + (violation) => violation.family === "edgeCardinality", + ); + expect(cardinalityViolation).toBeDefined(); + if (cardinalityViolation?.family !== "edgeCardinality") { + throw new Error("expected an edgeCardinality violation"); + } + expect([...cardinalityViolation.edgeIds].toSorted()).toEqual( + ["cf-dual-row-a", "cf-dual-row-b"].toSorted(), + ); + // No composition violation: each part still holds exactly one + // whole, and no `edgeIds` entry repeats (a double-count of one violation). + expect( + violations.some((violation) => violation.family === "composition"), + ).toBe(false); + }); + // MUTATION CHECK: restore the per-row `compositionClaim(registry, row)` + // re-derivation in `edgeCardinalityViolations` (src/store/claims/ + // verify.ts) instead of reading the row's own `scope`. Both planted rows + // are of a composition-realizing edge kind (`cfDualOf`), so the + // re-derivation folds them onto the reserved composition axis + // regardless of which query produced them; grouped there they share one + // axis with 2 edge ids, so `family: "composition"` becomes truthy above + // and the genuine `edgeCardinality` violation disappears (verified, + // reverted). + + it("verifyConstraintFences resolves (no raw TypeError) over a composition row whose part-side endpoint kind is undeclared (E4)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const backend = store.backend; + // `CfBook` is never a declared composition PART kind anywhere in this + // graph — it is only ever a WHOLE (`cfChapterOf`, `cfSectionOf`) — so + // `registry.compositionPopulation("CfBook")` is `undefined`. Using it + // as `cfChapterOf`'s `from` endpoint (declared `from: [CfChapter]`) is + // exactly the input that used to throw a raw TypeError when + // `edgeCardinalityViolations` re-derived the composition claim per row + // instead of reading the row's own `scope` (E4). Two rows sharing this + // same (bogus) part identity is what makes the composition audit's + // peer query select both, so the throw — if it regressed — would fire + // during THIS test's `verifyConstraintFences()` call, not silently. + const fakePart = await store.nodes.CfBook.create({}); + const wholeA = await store.nodes.CfBook.create({}); + const wholeB = await store.nodes.CfBook.create({}); + await backend.insertEdge({ + graphId: store.graphId, + id: "cf-bad-part-row-a", + kind: "cfChapterOf", + fromKind: "CfBook", + fromId: fakePart.id, + toKind: "CfBook", + toId: wholeA.id, + props: {}, + }); + await backend.insertEdge({ + graphId: store.graphId, + id: "cf-bad-part-row-b", + kind: "cfChapterOf", + fromKind: "CfBook", + fromId: fakePart.id, + toKind: "CfBook", + toId: wholeB.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const endpointViolation = violations.find( + (violation) => + violation.family === "edgeEndpointAssignability" && + violation.edgeKind === "cfChapterOf", + ); + expect(endpointViolation).toBeDefined(); + if (endpointViolation?.family !== "edgeEndpointAssignability") { + throw new Error("expected an edgeEndpointAssignability violation"); + } + expect( + endpointViolation.edges.map((edge) => edge.edgeId).toSorted(), + ).toEqual(["cf-bad-part-row-a", "cf-bad-part-row-b"].toSorted()); + }); + // REVERT CHECK: reintroduce a per-row `registry.compositionPopulation()` + // lookup on the fence-audit read path (for example, re-deriving the + // composition claim in `edgeCardinalityViolations` instead of reading + // `row.scope`, or resolving the part's population while enriching a + // composition violation's payload). `store.verifyConstraintFences()` + // above then throws `"cfChapterOf" is a composition edge kind but + // "CfBook" has no recorded composition population` instead of resolving. + + it("two concurrent attaches of one part to two different wholes: exactly one commits", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const chapter = await store.nodes.CfChapter.create({}); + const book = await store.nodes.CfBook.create({}); + const anthology = await store.nodes.CfAnthology.create({}); + + const results = await Promise.allSettled([ + store.edges.cfChapterOf.create(chapter, book, {}), + store.edges.cfIncludedIn.create(chapter, anthology, {}), + ]); + + const fulfilled = results.filter( + (result) => result.status === "fulfilled", + ); + const rejected = results.filter((result) => result.status === "rejected"); + expect(fulfilled).toHaveLength(1); + expect(rejected).toHaveLength(1); + expect(requireDefined(rejected[0]).reason).toBeInstanceOf( + CompositionError, + ); + + // Whichever won, the part is left with exactly one live whole. + const wholesHeld = [ + ...(await store.edges.cfChapterOf.findFrom(chapter)), + ...(await store.edges.cfIncludedIn.findFrom(chapter)), + ]; + expect(wholesHeld).toHaveLength(1); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-membership-reads.ts b/packages/typegraph/tests/backends/integration/composition-membership-reads.ts new file mode 100644 index 000000000..9a018454c --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-membership-reads.ts @@ -0,0 +1,591 @@ +/** + * Composition reads report OWNERSHIP for their edges, on every backend: + * `subgraph({ composition })`, `parts()` and `wholes()` follow the composition + * rows that bind a part to its whole — the same membership the cascade + * deletes by — never those edges' validity windows. NODES follow the read's + * temporal filter at every hop, so a part node the read cannot see is not + * returned, and neither is anything reachable only through it. + * + * Fixture shape, one graph per pair of whole-side populations (TOP, SUB): + * + * MrWhole <-(mrPartOf, partOf, population TOP)-- MrPart + * MrPart --(mrHasSub, hasPart, population SUB)--> MrSub + * + * The two levels mix orientation, so the recursive walks compile their + * `inverseEdgeKinds` union, and a graph with TOP != SUB holds an "allRows" + * and an "openRows" kind in one relation. Each matrix cell changes the state + * of the top edge (open / ended / soft-deleted) and reads at one coordinate, + * with every part node visible there; the expected answer is ALWAYS the + * cascade's own plan (`planDelete`) taken when the store stood at the + * coordinate's instant: + * + * - a valid-time read sees the plan captured at its instant — except that a + * soft-delete removes the row from every valid-time read retroactively; + * - current, includeEnded and includeTombstones see the plan now: those modes + * widen what a read may see, not what a whole owns; + * - a recorded read sees the plan captured at its pinned revision. + * + * MUTATION CHECK: reverting the composition readers to the edges' validity + * window (`TemporalFilterPass.forTraversalEdges` returning `forAlias(...)`, + * `prepareReachableFilters` ignoring `edgeMembership`, and + * `subgraphEdgeRowFilter` returning its window arm) fails the ended-"one" + * cells and the ended-"oneActive" includeEnded / includeTombstones cells. + * In `compileEdgeMembershipFilters` (src/query/compiler/temporal.ts): + * deleting the `valid_from <= asOf` term fails the "asOf before the binding + * edge existed" cells and the reparent case; turning `valid_to > asOf` into + * `>=` fails the "asOf at the end instant" cells and the reparent case; and + * dropping the per-kind `NOT (kind IN openRows)` guard fails the TOP "one" / + * SUB "oneActive" ended cells — each verified and reverted. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + count, + defineEdge, + defineGraph, + defineNode, + hasPart, + type NodeDeletePlan, + partOf, +} from "../../../src"; +import { type RecordedInstant } from "../../../src/core/temporal"; +import { requireDefined } from "../../../src/utils/presence"; +import { + type InspectableHistoryStore, + type IntegrationTestContext, +} from "./test-context"; + +const MrWhole = defineNode("MrWhole", { + schema: z.object({ name: z.string() }), +}); +const MrPart = defineNode("MrPart", { schema: z.object({ name: z.string() }) }); +const MrSub = defineNode("MrSub", { schema: z.object({ name: z.string() }) }); +const mrPartOf = defineEdge("mrPartOf", { schema: z.object({}) }); +const mrHasSub = defineEdge("mrHasSub", { schema: z.object({}) }); + +type Population = "one" | "oneActive"; +type EdgeState = "open" | "ended" | "softDeleted"; + +const POPULATION_PAIRS: readonly Readonly<{ + top: Population; + sub: Population; +}>[] = [ + { top: "one", sub: "one" }, + { top: "oneActive", sub: "oneActive" }, + { top: "one", sub: "oneActive" }, + { top: "oneActive", sub: "one" }, +]; +const EDGE_STATES: readonly EdgeState[] = ["open", "ended", "softDeleted"]; + +let graphIdCounter = 0; + +function nextGraphId(): string { + graphIdCounter += 1; + return `composition_membership_reads_${graphIdCounter}`; +} + +function buildGraph(top: Population, sub: Population) { + return defineGraph({ + id: nextGraphId(), + nodes: { + MrWhole: { type: MrWhole }, + MrPart: { type: MrPart }, + MrSub: { type: MrSub }, + }, + edges: { + mrPartOf: { + type: mrPartOf, + from: [MrPart], + to: [MrWhole], + cardinality: top, + }, + mrHasSub: { + type: mrHasSub, + from: [MrPart], + to: [MrSub], + targetCardinality: sub, + }, + }, + ontology: [ + partOf(MrPart, MrWhole, { via: mrPartOf }), + hasPart(MrPart, MrSub, { via: mrHasSub }), + ], + }); +} + +type MembershipGraph = ReturnType; +type MembershipStore = InspectableHistoryStore; +type MembershipView = + | ReturnType + | ReturnType; + +type NodeKey = Readonly<{ kind: string; id: string }>; + +function keyOf(node: NodeKey): string { + return `${node.kind}:${node.id}`; +} + +function keys(nodes: readonly NodeKey[]): readonly string[] { + return nodes.map((node) => keyOf(node)).toSorted(); +} + +/** What the cascade would remove with the whole: the oracle every reader is held to. */ +function plannedParts(plan: NodeDeletePlan): readonly string[] { + return keys(plan.cascadedParts); +} + +function plannedDirectParts( + plan: NodeDeletePlan, + whole: NodeKey, +): readonly string[] { + return keys( + plan.cascadedParts.filter((part) => keyOf(part.whole) === keyOf(whole)), + ); +} + +/** + * Separates two coordinates by moving the faked `Date` (installed for every + * test in this file), so each instant and recorded pin is distinct without a + * wall-clock sleep. + */ +function pause(): void { + vi.setSystemTime(Date.now() + 15); +} + +function nowIso(): string { + return new Date().toISOString(); +} + +type Created Promise> = Awaited< + ReturnType +>; +type Fixture = Readonly<{ + whole: Created; + part: Created; + sub: Created; + topEdgeId: Created["id"]; +}>; + +/** What every reader at one coordinate must answer. */ +type Expected = Readonly<{ + /** The closure under the whole, root excluded. */ + parts: readonly string[]; + /** The whole's direct parts. */ + directParts: readonly string[]; + /** `wholes()` of the part. */ + partWholes: readonly string[]; + /** `wholes()` of the sub-part. */ + subWholes: readonly string[]; + /** The top edge as `subgraph` returns it: absent, or whether it carries `validTo`. */ + topEdge: "absent" | Readonly<{ ended: boolean }>; +}>; + +async function expectReaders( + view: MembershipView, + fixture: Fixture, + expected: Expected, + cell: string, +): Promise { + const { whole, part, sub } = fixture; + + const unit = await view.subgraph(whole, { edges: [], composition: true }); + expect( + keys(unit.nodes.filter((node) => keyOf(node) !== keyOf(whole))), + `${cell}: subgraph closure`, + ).toEqual(expected.parts); + const returnedTopEdge = unit.edges.find( + (edge) => edge.id === fixture.topEdgeId, + ); + expect( + returnedTopEdge === undefined ? "absent" : ( + { ended: returnedTopEdge.meta.validTo !== undefined } + ), + `${cell}: subgraph's binding edge`, + ).toEqual(expected.topEdge); + + const parts = await view + .query() + .from("MrWhole", "w") + .whereNode("w", (node) => node.id.eq(whole.id)) + .parts("p") + .select((ctx) => ({ kind: ctx.p.kind, id: ctx.p.id })) + .execute(); + expect(keys(parts), `${cell}: parts()`).toEqual(expected.parts); + + const directParts = await view + .query() + .from("MrWhole", "w") + .whereNode("w", (node) => node.id.eq(whole.id)) + .parts("p", { maxHops: 1 }) + .select((ctx) => ({ kind: ctx.p.kind, id: ctx.p.id })) + .execute(); + expect(keys(directParts), `${cell}: parts({ maxHops: 1 })`).toEqual( + expected.directParts, + ); + + const counted = await view + .query() + .from("MrWhole", "w") + .whereNode("w", (node) => node.id.eq(whole.id)) + .parts("p", { maxHops: 1, via: mrPartOf }) + .groupByNode("w") + .aggregate({ partCount: count("p") }) + .execute(); + expect( + counted[0]?.partCount ?? 0, + `${cell}: count over parts({ maxHops: 1 })`, + ).toBe(expected.directParts.length); + + const partWholes = await view + .query() + .from("MrPart", "p") + .whereNode("p", (node) => node.id.eq(part.id)) + .wholes("w") + .select((ctx) => ({ kind: ctx.w.kind, id: ctx.w.id })) + .execute(); + expect(keys(partWholes), `${cell}: wholes() of the part`).toEqual( + expected.partWholes, + ); + + const subWholes = await view + .query() + .from("MrSub", "s") + .whereNode("s", (node) => node.id.eq(sub.id)) + .wholes("w") + .select((ctx) => ({ kind: ctx.w.kind, id: ctx.w.id })) + .execute(); + expect(keys(subWholes), `${cell}: wholes() of the sub-part`).toEqual( + expected.subWholes, + ); +} + +/** The cascade's plans for the whole and for the part, at one instant. */ +type Plans = Readonly<{ whole: NodeDeletePlan; part: NodeDeletePlan }>; + +async function capturePlans( + store: MembershipStore, + fixture: Pick, +): Promise { + return { + whole: requireDefined( + await store.nodes.MrWhole.planDelete(fixture.whole.id), + ), + part: requireDefined(await store.nodes.MrPart.planDelete(fixture.part.id)), + }; +} + +function wholeOwnsPart(plans: Plans, fixture: Fixture): boolean { + return plannedParts(plans.whole).includes(keyOf(fixture.part)); +} + +/** The answer every reader owes when each part node is visible: the plans'. */ +function expectedFromPlans( + plans: Plans, + fixture: Fixture, + topEdge: Expected["topEdge"], +): Expected { + const partIsMember = wholeOwnsPart(plans, fixture); + const subIsMember = plannedParts(plans.part).includes(keyOf(fixture.sub)); + return { + parts: plannedParts(plans.whole), + directParts: plannedDirectParts(plans.whole, fixture.whole), + partWholes: partIsMember ? [keyOf(fixture.whole)] : [], + subWholes: keys([ + ...(subIsMember ? [fixture.part] : []), + ...(subIsMember && partIsMember ? [fixture.whole] : []), + ]), + topEdge, + }; +} + +async function seed(store: MembershipStore): Promise< + Fixture & + Readonly<{ + beforeEdges: string; + pinBeforeEdges: RecordedInstant; + plansBeforeEdges: Plans; + }> +> { + const whole = await store.nodes.MrWhole.create({ name: "w" }); + const part = await store.nodes.MrPart.create({ name: "p" }); + const sub = await store.nodes.MrSub.create({ name: "s" }); + pause(); + const beforeEdges = nowIso(); + const pinBeforeEdges = requireDefined(await store.recordedNow()); + const plansBeforeEdges = await capturePlans(store, { whole, part }); + pause(); + const topEdge = await store.edges.mrPartOf.create(part, whole, {}); + await store.edges.mrHasSub.create(part, sub, {}); + return { + whole, + part, + sub, + topEdgeId: topEdge.id, + beforeEdges, + pinBeforeEdges, + plansBeforeEdges, + }; +} + +export function registerCompositionMembershipReadIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Composition reads report membership (subgraph / parts / wholes)", () => { + beforeEach(() => { + vi.useFakeTimers({ toFake: ["Date"] }); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + for (const { top, sub } of POPULATION_PAIRS) { + for (const state of EDGE_STATES) { + it(`top "${top}" / sub "${sub}", top edge ${state}: with every part node visible, each reader matches the cascade plan at its coordinate`, async () => { + const store = await context.createHistoryStore(buildGraph(top, sub)); + const seeded = await seed(store); + const { topEdgeId } = seeded; + pause(); + + const before = nowIso(); + const pinBefore = requireDefined(await store.recordedNow()); + const planBefore = await capturePlans(store, seeded); + pause(); + + let endInstant: string | undefined; + if (state === "ended") { + endInstant = nowIso(); + await store.edges.mrPartOf.update( + topEdgeId, + {}, + { validTo: endInstant }, + ); + } else if (state === "softDeleted") { + await store.edges.mrPartOf.delete(topEdgeId); + } + pause(); + + const after = nowIso(); + const pinAfter = requireDefined(await store.recordedNow()); + const planAfter = await capturePlans(store, seeded); + + // A valid-time read reads the one live row, already changed, so an + // ended binding edge comes back with its `validTo`; a recorded read + // sees the version its revision pins. + const changedRow = { ended: state === "ended" }; + const unchangedRow = { ended: false }; + const deletedRetroactively = state === "softDeleted"; + const coordinates: readonly Readonly<{ + label: string; + view: MembershipView; + plan: Plans; + topEdgeRow: Readonly<{ ended: boolean }>; + }>[] = [ + { + label: "current", + view: store.view({ mode: "current" }), + plan: planAfter, + topEdgeRow: changedRow, + }, + { + label: "includeEnded", + view: store.view({ mode: "includeEnded" }), + plan: planAfter, + topEdgeRow: changedRow, + }, + { + label: "includeTombstones", + view: store.view({ mode: "includeTombstones" }), + plan: planAfter, + topEdgeRow: changedRow, + }, + { + label: "asOf before the binding edge existed", + view: store.asOf(seeded.beforeEdges), + plan: seeded.plansBeforeEdges, + topEdgeRow: changedRow, + }, + { + label: "asOf before the change", + view: store.asOf(before), + plan: deletedRetroactively ? planAfter : planBefore, + topEdgeRow: changedRow, + }, + ...(endInstant === undefined ? + [] + : [ + { + label: "asOf at the end instant", + view: store.asOf(endInstant), + plan: planAfter, + topEdgeRow: changedRow, + }, + ]), + { + label: "asOf after the change", + view: store.asOf(after), + plan: planAfter, + topEdgeRow: changedRow, + }, + { + label: "asOfRecorded before the binding edge existed", + view: store.asOfRecorded(seeded.pinBeforeEdges), + plan: seeded.plansBeforeEdges, + topEdgeRow: unchangedRow, + }, + { + label: "asOfRecorded before the change", + view: store.asOfRecorded(pinBefore), + plan: planBefore, + topEdgeRow: unchangedRow, + }, + { + label: "asOfRecorded after the change", + view: store.asOfRecorded(pinAfter), + plan: planAfter, + topEdgeRow: changedRow, + }, + ]; + + for (const { label, view, plan, topEdgeRow } of coordinates) { + await expectReaders( + view, + seeded, + expectedFromPlans( + plan, + seeded, + wholeOwnsPart(plan, seeded) ? topEdgeRow : "absent", + ), + `${top}/${sub} / ${state} / ${label}`, + ); + } + }); + } + } + + it("hides a part whose own window has ended, and everything reachable only through it, while planDelete still lists them", async () => { + const store = await context.createHistoryStore(buildGraph("one", "one")); + const seeded = await seed(store); + const { part, sub } = seeded; + pause(); + const beforeEnd = nowIso(); + pause(); + await store.nodes.MrPart.update(part.id, {}, { validTo: nowIso() }); + pause(); + + // The delete still owns both: membership is a property of the edges. + const plans = await capturePlans(store, seeded); + expect(plannedParts(plans.whole)).toEqual(keys([part, sub])); + + const owned = expectedFromPlans(plans, seeded, { ended: false }); + const hidden: Expected = { + parts: [], + directParts: [], + partWholes: [], + subWholes: [], + topEdge: "absent", + }; + // MUTATION CHECK: drop the node temporal filter from the recursive + // step of `prepareExhaustiveReachableCte` (src/store/recursive-cte.ts) + // and the current subgraph walks through the hidden part to its + // sub-part — verified and reverted. + await expectReaders( + store.view({ mode: "current" }), + seeded, + hidden, + "current", + ); + await expectReaders( + store.asOf(nowIso()), + seeded, + hidden, + "asOf after the part ended", + ); + // A read that can see the part node sees everything its edges own. + await expectReaders( + store.view({ mode: "includeEnded" }), + seeded, + owned, + "includeEnded", + ); + await expectReaders( + store.asOf(beforeEnd), + seeded, + owned, + "asOf before the part ended", + ); + }); + + it("hands a reparented part from one whole to the next at the move instant", async () => { + const store = await context.createHistoryStore( + buildGraph("oneActive", "oneActive"), + ); + const seeded = await seed(store); + const { whole, part } = seeded; + const nextWhole = await store.nodes.MrWhole.create({ name: "next" }); + pause(); + const before = nowIso(); + const plansBefore = [ + requireDefined(await store.nodes.MrWhole.planDelete(whole.id)), + requireDefined(await store.nodes.MrWhole.planDelete(nextWhole.id)), + ] as const; + pause(); + await store.nodes.MrPart.reparent(part.id, { whole: nextWhole }); + const [retired] = await store.edges.mrPartOf.find( + { to: whole }, + { temporalMode: "includeEnded" }, + ); + const moveInstant = requireDefined(requireDefined(retired).meta.validTo); + const plansAfter = [ + requireDefined(await store.nodes.MrWhole.planDelete(whole.id)), + requireDefined(await store.nodes.MrWhole.planDelete(nextWhole.id)), + ] as const; + + for (const [label, view, [planFirst, planNext]] of [ + ["asOf before the move", store.asOf(before), plansBefore], + ["asOf at the move instant", store.asOf(moveInstant), plansAfter], + ["current", store.view({ mode: "current" }), plansAfter], + ["includeEnded", store.view({ mode: "includeEnded" }), plansAfter], + ] as const) { + const ownedByFirst = plannedParts(planFirst).includes(keyOf(part)); + const ownedByNext = plannedParts(planNext).includes(keyOf(part)); + expect(ownedByFirst !== ownedByNext, `${label}: one owner`).toBe(true); + + for (const [owner, plan] of [ + [whole, planFirst], + [nextWhole, planNext], + ] as const) { + const unit = await view.subgraph(owner, { + edges: [], + composition: true, + }); + expect( + keys(unit.nodes.filter((node) => keyOf(node) !== keyOf(owner))), + `${label}: subgraph of ${owner.id === whole.id ? "first" : "next"} whole`, + ).toEqual(plannedParts(plan)); + const parts = await view + .query() + .from("MrWhole", "w") + .whereNode("w", (node) => node.id.eq(owner.id)) + .parts("p") + .select((ctx) => ({ kind: ctx.p.kind, id: ctx.p.id })) + .execute(); + expect( + keys(parts), + `${label}: parts() of ${owner.id === whole.id ? "first" : "next"} whole`, + ).toEqual(plannedParts(plan)); + } + const partWholes = await view + .query() + .from("MrPart", "p") + .whereNode("p", (node) => node.id.eq(part.id)) + .wholes("w") + .select((ctx) => ({ kind: ctx.w.kind, id: ctx.w.id })) + .execute(); + expect(keys(partWholes), `${label}: wholes() of the part`).toEqual([ + keyOf(ownedByFirst ? whole : nextWhole), + ]); + } + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-navigation.ts b/packages/typegraph/tests/backends/integration/composition-navigation.ts new file mode 100644 index 000000000..bd9c9a885 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-navigation.ts @@ -0,0 +1,1753 @@ +/** + * Composition navigation: `parts()` / `wholes()` and + * `subgraph({ composition: true })`, on every backend. + * + * Fixture shape: + * + * CnPodcast --(cnEpisodeOf, partOf, part->whole)-- CnEpisode + * CnEpisode --(cnSegmentOf, partOf, part->whole)-- CnSegment + * + * CnBook --(cnBookHasChapter, hasPart, whole->part)-- CnChapter + * CnChapter --(cnParagraphOf, partOf, part->whole)-- CnParagraph + * + * CnSection --(cnParentSection, partOf, part->whole, reflexive)-- CnSection + * + * The Podcast chain crosses two edge KINDS under one, uniform orientation + * (partOf both times). The Book chain crosses two edge kinds under MIXED + * orientation — `hasPart` (whole names the part) at the first level, + * `partOf` (part names the whole) at the second — exercising the + * `inverseEdgeKinds` union `parts()`/`wholes()` are built on. + * + * Every case states the mutation/revert check that must make it fail. + */ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineNode, + hasPart, + indexSubgraph, + partOf, + type QualifiedRecursivePath, + subClassOf, +} from "../../../src"; +import { MAX_EXPLICIT_RECURSIVE_DEPTH } from "../../../src/query/compiler/recursive"; +import { requireDefined } from "../../../src/utils/presence"; +import { matchingObject } from "../../test-utils"; +import { + type InspectableStore, + type IntegrationTestContext, +} from "./test-context"; + +const CnPodcast = defineNode("CnPodcast", { + schema: z.object({ title: z.string() }), +}); +const CnEpisode = defineNode("CnEpisode", { + schema: z.object({ title: z.string() }), +}); +const CnSegment = defineNode("CnSegment", { + schema: z.object({ title: z.string() }), +}); +const CnBook = defineNode("CnBook", { + schema: z.object({ title: z.string() }), +}); +const CnChapter = defineNode("CnChapter", { + schema: z.object({ title: z.string() }), +}); +const CnParagraph = defineNode("CnParagraph", { + schema: z.object({ title: z.string() }), +}); +const CnSection = defineNode("CnSection", { + schema: z.object({ title: z.string() }), +}); +/** An undeclared subclass of `CnEpisode` for the composition relation — no `partOf`/`hasPart` names it directly. */ +const CnBonusEpisode = defineNode("CnBonusEpisode", { + schema: z.object({ title: z.string() }), +}); + +const cnEpisodeOf = defineEdge("cnEpisodeOf", { + schema: z.object({ + position: z.number().optional(), + note: z.string().optional(), + }), +}); +const cnSegmentOf = defineEdge("cnSegmentOf", { schema: z.object({}) }); +const cnBookHasChapter = defineEdge("cnBookHasChapter", { + schema: z.object({}), +}); +const cnParagraphOf = defineEdge("cnParagraphOf", { schema: z.object({}) }); +const cnParentSection = defineEdge("cnParentSection", { + schema: z.object({}), +}); + +const compositionNavigationGraph = defineGraph({ + id: "composition_navigation", + nodes: { + CnPodcast: { type: CnPodcast }, + CnEpisode: { type: CnEpisode }, + CnSegment: { type: CnSegment }, + CnBook: { type: CnBook }, + CnChapter: { type: CnChapter }, + CnParagraph: { type: CnParagraph }, + CnSection: { type: CnSection }, + CnBonusEpisode: { type: CnBonusEpisode }, + }, + edges: { + cnEpisodeOf: { + type: cnEpisodeOf, + from: [CnEpisode], + to: [CnPodcast], + cardinality: "one", + }, + cnSegmentOf: { + type: cnSegmentOf, + from: [CnSegment], + to: [CnEpisode], + cardinality: "one", + }, + cnBookHasChapter: { + type: cnBookHasChapter, + from: [CnBook], + to: [CnChapter], + targetCardinality: "one", + }, + cnParagraphOf: { + type: cnParagraphOf, + from: [CnParagraph], + to: [CnChapter], + cardinality: "one", + }, + cnParentSection: { + type: cnParentSection, + from: [CnSection], + to: [CnSection], + cardinality: "one", + }, + }, + ontology: [ + partOf(CnEpisode, CnPodcast, { via: cnEpisodeOf }), + partOf(CnSegment, CnEpisode, { via: cnSegmentOf }), + hasPart(CnBook, CnChapter, { via: cnBookHasChapter }), + partOf(CnParagraph, CnChapter, { via: cnParagraphOf }), + partOf(CnSection, CnSection, { via: cnParentSection, partSide: "from" }), + subClassOf(CnBonusEpisode, CnEpisode), + ], +}); + +/** + * One realizing edge kind (`cnNoteOf`) attaching a note to a track OR to a + * video, so a note on a video that shares a track's id is one hop away from + * that track's id — but no part of the track's album. + */ +const CnAlbum = defineNode("CnAlbum", { schema: z.object({}) }); +const CnTrack = defineNode("CnTrack", { schema: z.object({}) }); +const CnVideo = defineNode("CnVideo", { schema: z.object({}) }); +const CnNote = defineNode("CnNote", { schema: z.object({}) }); +const cnAlbumHasTrack = defineEdge("cnAlbumHasTrack", { schema: z.object({}) }); +const cnNoteOf = defineEdge("cnNoteOf", { schema: z.object({}) }); +const sharedIdCompositionGraph = defineGraph({ + id: "composition_navigation_shared_id", + nodes: { + CnAlbum: { type: CnAlbum }, + CnTrack: { type: CnTrack }, + CnVideo: { type: CnVideo }, + CnNote: { type: CnNote }, + }, + edges: { + cnAlbumHasTrack: { + type: cnAlbumHasTrack, + from: [CnAlbum], + to: [CnTrack], + targetCardinality: "one", + }, + cnNoteOf: { + type: cnNoteOf, + from: [CnNote], + to: [CnTrack, CnVideo], + cardinality: "one", + }, + }, + ontology: [ + hasPart(CnAlbum, CnTrack, { via: cnAlbumHasTrack }), + partOf(CnNote, CnTrack, { via: cnNoteOf }), + partOf(CnNote, CnVideo, { via: cnNoteOf }), + ], +}); + +/** + * One whole whose DIRECT level mixes both orientations across two edge kinds — + * `cnFolderHasFile` (`hasPart`, whole names the part) and `cnLinkIn` + * (`partOf`, part names the whole) — with a grandchild level under the files + * that a one-hop read must leave out. + */ +const CnFolder = defineNode("CnFolder", { + schema: z.object({ name: z.string() }), +}); +const CnFile = defineNode("CnFile", { schema: z.object({ name: z.string() }) }); +const CnLink = defineNode("CnLink", { schema: z.object({ name: z.string() }) }); +const CnBlock = defineNode("CnBlock", { + schema: z.object({ name: z.string() }), +}); +const cnFolderHasFile = defineEdge("cnFolderHasFile", { schema: z.object({}) }); +const cnLinkIn = defineEdge("cnLinkIn", { schema: z.object({}) }); +const cnBlockOf = defineEdge("cnBlockOf", { schema: z.object({}) }); +const directPartsGraph = defineGraph({ + id: "composition_navigation_direct_parts", + nodes: { + CnFolder: { type: CnFolder }, + CnFile: { type: CnFile }, + CnLink: { type: CnLink }, + CnBlock: { type: CnBlock }, + }, + edges: { + cnFolderHasFile: { + type: cnFolderHasFile, + from: [CnFolder], + to: [CnFile], + targetCardinality: "one", + }, + cnLinkIn: { + type: cnLinkIn, + from: [CnLink], + to: [CnFolder], + cardinality: "one", + }, + cnBlockOf: { + type: cnBlockOf, + from: [CnBlock], + to: [CnFile], + cardinality: "one", + }, + }, + ontology: [ + hasPart(CnFolder, CnFile, { via: cnFolderHasFile }), + partOf(CnLink, CnFolder, { via: cnLinkIn }), + partOf(CnBlock, CnFile, { via: cnBlockOf }), + ], +}); + +/** No `partOf`/`hasPart` declared anywhere — the set-level subgraph refusal. */ +const noCompositionGraph = defineGraph({ + id: "composition_navigation_none", + nodes: { CnPodcast: { type: CnPodcast } }, + edges: {}, +}); + +const CsReport = defineNode("CsReport", { schema: z.object({}) }); +const CsSection = defineNode("CsSection", { schema: z.object({}) }); +const CsRichSection = defineNode("CsRichSection", { schema: z.object({}) }); +const CsParagraph = defineNode("CsParagraph", { schema: z.object({}) }); +const csSectionOf = defineEdge("csSectionOf", { schema: z.object({}) }); +const csParagraphOf = defineEdge("csParagraphOf", { schema: z.object({}) }); + +const SUBSUMPTION_CLOSURE_NODES = { + CsReport: { type: CsReport }, + CsSection: { type: CsSection }, + CsRichSection: { type: CsRichSection }, + CsParagraph: { type: CsParagraph }, +} as const; + +/** + * The second-level pair is declared on a SUBCLASS of the kind the first + * level reaches: `Report <- Section`, `RichSection <- Paragraph`. + */ +const closureThroughSubclassWholeGraph = defineGraph({ + id: "composition_closure_subclass_whole", + nodes: SUBSUMPTION_CLOSURE_NODES, + edges: { + csSectionOf: { + type: csSectionOf, + from: [CsSection], + to: [CsReport], + cardinality: "one", + }, + csParagraphOf: { + type: csParagraphOf, + from: [CsParagraph], + to: [CsRichSection], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(CsRichSection, CsSection), + partOf(CsSection, CsReport, { via: csSectionOf }), + partOf(CsParagraph, CsRichSection, { + via: csParagraphOf, + existence: "required", + }), + ], +}); + +/** + * The second-level pair is declared on a SUPERCLASS of the kind the first + * level reaches: `Report <- RichSection`, `Section <- Paragraph`. + */ +const closureThroughSuperclassWholeGraph = defineGraph({ + id: "composition_closure_superclass_whole", + nodes: SUBSUMPTION_CLOSURE_NODES, + edges: { + csSectionOf: { + type: csSectionOf, + from: [CsRichSection], + to: [CsReport], + cardinality: "one", + }, + csParagraphOf: { + type: csParagraphOf, + from: [CsParagraph], + to: [CsSection], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(CsRichSection, CsSection), + partOf(CsRichSection, CsReport, { via: csSectionOf }), + partOf(CsParagraph, CsSection, { + via: csParagraphOf, + existence: "required", + }), + ], +}); + +const SUBSUMPTION_CLOSURE_GRAPHS = [ + [ + "a pair declared on a subclass of a reached kind", + closureThroughSubclassWholeGraph, + ], + [ + "a pair declared on a superclass of a reached kind", + closureThroughSuperclassWholeGraph, + ], +] as const; + +type CompositionStore = InspectableStore; + +function edgeIds( + edges: readonly Readonly<{ id: string }>[], +): ReadonlySet { + return new Set(edges.map((edge) => edge.id)); +} + +async function seedCompositionFixtures(store: CompositionStore) { + const podcast = await store.nodes.CnPodcast.create({ title: "The Pod" }); + const episode1 = await store.nodes.CnEpisode.create({ title: "Episode 1" }); + const episode2 = await store.nodes.CnEpisode.create({ title: "Episode 2" }); + await store.edges.cnEpisodeOf.create(episode1, podcast, { + position: 1, + note: "pilot", + }); + await store.edges.cnEpisodeOf.create(episode2, podcast, {}); + const segment1 = await store.nodes.CnSegment.create({ title: "Segment 1" }); + await store.edges.cnSegmentOf.create(segment1, episode1, {}); + + const book = await store.nodes.CnBook.create({ title: "The Book" }); + const chapter1 = await store.nodes.CnChapter.create({ title: "Chapter 1" }); + await store.edges.cnBookHasChapter.create(book, chapter1, {}); + const paragraph1 = await store.nodes.CnParagraph.create({ + title: "Paragraph 1", + }); + await store.edges.cnParagraphOf.create(paragraph1, chapter1, {}); + + const sectionRoot = await store.nodes.CnSection.create({ + title: "Root Section", + }); + const sectionChild = await store.nodes.CnSection.create({ + title: "Child Section", + }); + const sectionGrandchild = await store.nodes.CnSection.create({ + title: "Grandchild Section", + }); + await store.edges.cnParentSection.create(sectionChild, sectionRoot, {}); + await store.edges.cnParentSection.create(sectionGrandchild, sectionChild, {}); + + return { + podcast, + episode1, + episode2, + segment1, + book, + chapter1, + paragraph1, + sectionRoot, + sectionChild, + sectionGrandchild, + }; +} + +// A whole with two realizing edges (`vnEpisodeOf`, `vnTrailerOf`) over a +// second level (`vnSegmentOf`): the shape `via` exists to narrow. +const VnPodcast = defineNode("VnPodcast", { + schema: z.object({ title: z.string() }), +}); +const VnEpisode = defineNode("VnEpisode", { + schema: z.object({ title: z.string() }), +}); +const VnSegment = defineNode("VnSegment", { + schema: z.object({ title: z.string() }), +}); +const VnTrailer = defineNode("VnTrailer", { + schema: z.object({ title: z.string() }), +}); +const vnEpisodeOf = defineEdge("vnEpisodeOf", { schema: z.object({}) }); +const vnSegmentOf = defineEdge("vnSegmentOf", { schema: z.object({}) }); +const vnTrailerOf = defineEdge("vnTrailerOf", { schema: z.object({}) }); + +const compositionViaGraph = defineGraph({ + id: "composition_navigation_via", + nodes: { + VnPodcast: { type: VnPodcast }, + VnEpisode: { type: VnEpisode }, + VnSegment: { type: VnSegment }, + VnTrailer: { type: VnTrailer }, + }, + edges: { + vnEpisodeOf: { + type: vnEpisodeOf, + from: [VnEpisode], + to: [VnPodcast], + cardinality: "one", + }, + vnSegmentOf: { + type: vnSegmentOf, + from: [VnSegment], + to: [VnEpisode], + cardinality: "one", + }, + vnTrailerOf: { + type: vnTrailerOf, + from: [VnTrailer], + to: [VnPodcast], + cardinality: "one", + }, + }, + ontology: [ + partOf(VnEpisode, VnPodcast, { via: vnEpisodeOf }), + partOf(VnSegment, VnEpisode, { via: vnSegmentOf }), + partOf(VnTrailer, VnPodcast, { via: vnTrailerOf }), + ], +}); + +async function seedCompositionViaFixtures( + store: InspectableStore, +) { + const podcast = await store.nodes.VnPodcast.create({ title: "The Pod" }); + const episode = await store.nodes.VnEpisode.create({ title: "Episode" }); + const segment = await store.nodes.VnSegment.create({ title: "Segment" }); + const trailer = await store.nodes.VnTrailer.create({ title: "Trailer" }); + await store.edges.vnEpisodeOf.create(episode, podcast, {}); + await store.edges.vnSegmentOf.create(segment, episode, {}); + await store.edges.vnTrailerOf.create(trailer, podcast, {}); + return { podcast, episode, segment, trailer }; +} + +/** Deeper than the implicit cap a bare `.recursive()` applies. */ +const DEEP_SECTION_CHAIN_LENGTH = 15; + +/** + * One node longer than the deepest chain a bounded recursion can walk + * (`MAX_EXPLICIT_RECURSIVE_DEPTH` hops reach `MAX_EXPLICIT_RECURSIVE_DEPTH + 1` + * nodes), so the chain's last node is reachable only by an exhaustive closure. + */ +const BEYOND_HOP_CEILING_CHAIN_LENGTH = MAX_EXPLICIT_RECURSIVE_DEPTH + 2; + +export function registerCompositionNavigationIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Composition navigation (parts/wholes/subgraph)", () => { + it("parts() crosses heterogeneous edge kinds (episodeOf + segmentOf)", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast } = await seedCompositionFixtures(store); + + // MUTATION CHECK: narrowing `compositionEdgeKindsUnder` to direct + // pairs only (dropping the transitive-part-of-a-part union) makes + // CnSegment disappear from this result — verified and reverted. + const rows = await store + .query() + .from("CnPodcast", "p") + .whereNode("p", (p) => p.id.eq(podcast.id)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); + + expect(rows).toHaveLength(3); + expect(new Set(rows.map((row) => row.kind))).toEqual( + new Set(["CnEpisode", "CnSegment"]), + ); + }); + + it("parts() includes an undeclared subclass of a declared part kind", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast } = await seedCompositionFixtures(store); + const bonusEpisode = await store.nodes.CnBonusEpisode.create({ + title: "Bonus Episode", + }); + // `cnEpisodeOf.create` admits the subclass endpoint: the ontology's + // `subClassOf(CnBonusEpisode, CnEpisode)` makes the resulting row a + // legitimate one, accepted by edge-endpoint validation via + // `isAssignableToAny`. + await store.edges.cnEpisodeOf.create(bonusEpisode, podcast, {}); + + // MUTATION CHECK: reverting the subclass-expansion fix (using the bare + // declared kind list from `compositionPartKindsUnder` instead of + // expanding each declared kind through `registry.expandSubClasses`) + // makes CnBonusEpisode disappear from this result even though the + // edge row exists and passed endpoint validation — verified and + // reverted. + const rows = await store + .query() + .from("CnPodcast", "p") + .whereNode("p", (p) => p.id.eq(podcast.id)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); + + expect(rows.map((row) => row.kind)).toContain("CnBonusEpisode"); + }); + + it("parts() unions mixed orientation across levels (hasPart then partOf)", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { book } = await seedCompositionFixtures(store); + + // MUTATION CHECK: forcing a single direction (dropping the + // `inverseEdgeKinds` branch so only the primary direction's edge + // kinds are followed) makes CnParagraph disappear — verified and + // reverted. + const rows = await store + .query() + .from("CnBook", "b") + .whereNode("b", (b) => b.id.eq(book.id)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); + + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.kind))).toEqual( + new Set(["CnChapter", "CnParagraph"]), + ); + }); + + it("parts() unions mixed orientation under a recorded-pinned read", async () => { + const history = await context.createHistoryStore( + compositionNavigationGraph, + ); + const book = await history.nodes.CnBook.create({ title: "The Book" }); + const chapter = await history.nodes.CnChapter.create({ + title: "Chapter 1", + }); + await history.edges.cnBookHasChapter.create(book, chapter, {}); + const paragraph = await history.nodes.CnParagraph.create({ + title: "Paragraph 1", + }); + await history.edges.cnParagraphOf.create(paragraph, chapter, {}); + const pin = await history.recordedNow(); + if (pin === undefined) throw new Error("recorded clock was not written"); + + // MUTATION CHECK: the mixed-orientation shape above forces + // `parts()` to compile the `inverseEdgeKinds` union branch, whose + // `_directed_edges` CTE narrows its edge projection. Reverting that + // narrowing to omit `recorded_from`/`recorded_to` (dropping + // `temporalFilterPass.recordedColumns` from the column list in + // `recursive.ts`) makes this throw `no such column: e.recorded_from` + // on SQLite (an undefined-column error on PostgreSQL/PGlite) as soon + // as this recorded-pinned read executes, because the edge temporal + // filter references those columns on every recorded read regardless + // of projection width — verified and reverted. + const rows = await history + .asOfRecorded(pin) + .query() + .from("CnBook", "b") + .whereNode("b", (b) => b.id.eq(book.id)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); + + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.kind))).toEqual( + new Set(["CnChapter", "CnParagraph"]), + ); + }); + + it("refuses cursor pagination over parts() and wholes()", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast, segment1 } = await seedCompositionFixtures(store); + const parts = store + .query() + .from("CnPodcast", "p") + .whereNode("p", (p) => p.id.eq(podcast.id)) + .parts("x") + .orderBy("p", "title", "asc") + .select((ctx) => ctx.x.id); + const wholes = store + .query() + .from("CnSegment", "s") + .whereNode("s", (s) => s.id.eq(segment1.id)) + .wholes("x") + .orderBy("s", "title", "asc") + .select((ctx) => ctx.x.id); + + for (const query of [parts, wholes]) { + await expect(query.paginate({ first: 1 })).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ + code: "RECURSIVE_PAGINATION_UNSUPPORTED", + operation: "paginate", + }), + }), + ); + expect(() => query.page({ first: 1 })).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "RECURSIVE_PAGINATION_UNSUPPORTED", + operation: "page", + }), + }), + ); + expect(() => query.stream()).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "RECURSIVE_PAGINATION_UNSUPPORTED", + operation: "stream", + }), + }), + ); + } + }); + + it("pages and streams one whole's direct parts across mixed-orientation edge kinds with parts({ maxHops: 1 })", async () => { + const store = await context.createStore(directPartsGraph); + const folder = await store.nodes.CnFolder.create({ name: "root" }); + const otherFolder = await store.nodes.CnFolder.create({ name: "other" }); + const directParts: { kind: string; id: string }[] = []; + for (const name of ["a", "b", "c"]) { + const file = await store.nodes.CnFile.create( + { name }, + { partOf: { whole: folder } }, + ); + directParts.push({ kind: "CnFile", id: file.id }); + await store.nodes.CnBlock.create( + { name: `${name}-block` }, + { partOf: { whole: file } }, + ); + } + for (const name of ["d", "e"]) { + const link = await store.nodes.CnLink.create( + { name }, + { partOf: { whole: folder } }, + ); + directParts.push({ kind: "CnLink", id: link.id }); + } + await store.nodes.CnFile.create( + { name: "elsewhere" }, + { partOf: { whole: otherFolder } }, + ); + + const level = store + .query() + .from("CnFolder", "f") + .whereNode("f", (f) => f.id.eq(folder.id)) + .parts("x", { maxHops: 1 }) + .orderBy("f", "name", "asc") + .select((ctx) => ({ kind: ctx.x.kind, id: ctx.x.id })); + + // MUTATION CHECK: force `willRecurse` to `true` in `parts()`/`wholes()` + // (src/query/builder/query-builder.ts) and every call below refuses + // with RECURSIVE_PAGINATION_UNSUPPORTED, because the one-hop step is + // then compiled as a recursive traversal. + const pages: (readonly { kind: string; id: string }[])[] = []; + let after: string | undefined; + do { + const page = await level.paginate({ + first: 2, + ...(after === undefined ? {} : { after }), + }); + pages.push(page.data); + after = page.nextCursor; + } while (after !== undefined); + + const paged = pages.flat(); + expect(pages.map((page) => page.length)).toEqual([2, 2, 1]); + expect(paged).toHaveLength(directParts.length); + expect(new Set(paged.map((row) => `${row.kind}:${row.id}`))).toEqual( + new Set(directParts.map((part) => `${part.kind}:${part.id}`)), + ); + + const streamed: typeof directParts = []; + for await (const row of level.stream({ batchSize: 2 })) { + streamed.push(row); + } + expect(streamed).toEqual(paged); + + const fileWholes: string[] = []; + for await (const row of store + .query() + .from("CnFile", "file") + .wholes("w", { maxHops: 1 }) + .orderBy("file", "name", "asc") + .select((ctx) => ({ file: ctx.file.name, whole: ctx.w.id })) + .stream({ batchSize: 1 })) { + fileWholes.push(`${row.file}:${row.whole}`); + } + expect(fileWholes).toEqual([ + `a:${folder.id}`, + `b:${folder.id}`, + `c:${folder.id}`, + `elsewhere:${otherFolder.id}`, + ]); + }); + + it("refuses paging parts({ maxHops: 1 }) once depth or path makes the step recursive", async () => { + const store = await context.createStore(directPartsGraph); + const folder = await store.nodes.CnFolder.create({ name: "root" }); + const queries = [ + store + .query() + .from("CnFolder", "f") + .whereNode("f", (f) => f.id.eq(folder.id)) + .parts("x", { maxHops: 1, depth: "d" }) + .orderBy("f", "name", "asc") + .select((ctx) => ({ id: ctx.x.id, depth: ctx.d })), + store + .query() + .from("CnFolder", "f") + .whereNode("f", (f) => f.id.eq(folder.id)) + .parts("x", { maxHops: 1, path: "route" }) + .orderBy("f", "name", "asc") + .select((ctx) => ({ id: ctx.x.id, route: ctx.route })), + ]; + + for (const query of queries) { + await expect(query.paginate({ first: 1 })).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "RECURSIVE_PAGINATION_UNSUPPORTED", + operation: "paginate", + }), + }), + ); + expect(() => query.stream()).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "RECURSIVE_PAGINATION_UNSUPPORTED", + operation: "stream", + }), + }), + ); + } + }); + + it("wholes() returns the ancestor chain from a Segment", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { segment1 } = await seedCompositionFixtures(store); + + const rows = await store + .query() + .from("CnSegment", "s") + .whereNode("s", (s) => s.id.eq(segment1.id)) + .wholes("x") + .select((ctx) => ctx.x) + .execute(); + + expect(rows).toHaveLength(2); + expect(new Set(rows.map((row) => row.kind))).toEqual( + new Set(["CnEpisode", "CnPodcast"]), + ); + }); + + it("parts() recurses over a reflexive composition kind, honoring maxHops", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { sectionRoot, sectionChild, sectionGrandchild } = + await seedCompositionFixtures(store); + + const allRows = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(sectionRoot.id)) + .parts("x") + .select((ctx) => ctx.x) + .execute(); + expect(new Set(allRows.map((row) => row.id))).toEqual( + new Set([sectionChild.id, sectionGrandchild.id]), + ); + + // MUTATION CHECK: the `maxHops === 1` clamp and the `{ maxHops: + // options.maxHops }` forwarding onto `.recursive(...)` are two + // separate mechanisms that happen to agree here — dropping the clamp + // ALONE still passes, because `.recursive({ maxHops: 1 })` bounds + // depth to 1 on its own. Only mutating BOTH together makes this + // assertion fail — verified and reverted. The `maxHops: 2` case below + // is the one only the forwarding mechanism guards. + const directRows = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(sectionRoot.id)) + .parts("x", { maxHops: 1 }) + .select((ctx) => ctx.x) + .execute(); + expect(directRows.map((row) => row.id)).toEqual([sectionChild.id]); + + // A great-grandchild, added only here so the unbounded assertion + // above (which expects exactly {child, grandchild}) is unaffected. + const sectionGreatGrandchild = await store.nodes.CnSection.create({ + title: "Great-grandchild Section", + }); + await store.edges.cnParentSection.create( + sectionGreatGrandchild, + sectionGrandchild, + {}, + ); + + // MUTATION CHECK: dropping the `{ maxHops: options.maxHops }` + // forwarding (so the recursive traversal always runs unbounded + // regardless of the option) makes this include the great-grandchild — + // verified and reverted. Unlike the `maxHops: 1` case above, the + // `maxHops === 1` clamp cannot substitute for this: it does not fire + // for `maxHops: 2`. + const twoHopRows = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(sectionRoot.id)) + .parts("x", { maxHops: 2 }) + .select((ctx) => ctx.x) + .execute(); + expect(new Set(twoHopRows.map((row) => row.id))).toEqual( + new Set([sectionChild.id, sectionGrandchild.id]), + ); + }); + + it("parts() exposes depth and path when requested", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { sectionRoot, sectionChild, sectionGrandchild } = + await seedCompositionFixtures(store); + + const rows = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(sectionRoot.id)) + .parts("x", { depth: "d", path: "p" }) + .select((ctx) => ({ id: ctx.x.id, depth: ctx.d, path: ctx.p })) + .execute(); + + const byId = new Map(rows.map((row) => [row.id, row])); + expect(byId.get(sectionChild.id)?.depth).toBe(1); + expect(byId.get(sectionChild.id)?.path).toEqual([ + sectionRoot.id, + sectionChild.id, + ]); + expect(byId.get(sectionGrandchild.id)?.depth).toBe(2); + expect(byId.get(sectionGrandchild.id)?.path).toEqual([ + sectionRoot.id, + sectionChild.id, + sectionGrandchild.id, + ]); + }); + + it("parts() takes the recursive path and depth option forms, including a qualified path", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { book, chapter1, paragraph1 } = + await seedCompositionFixtures(store); + const [chapterEdges, paragraphEdges] = await Promise.all([ + store.edges.cnBookHasChapter.findFrom(book), + store.edges.cnParagraphOf.findFrom(paragraph1), + ]); + const chapterEdge = requireDefined(chapterEdges[0]); + const paragraphEdge = requireDefined(paragraphEdges[0]); + + const rows = await store + .query() + .from("CnBook", "b") + .whereNode("b", (candidate) => candidate.id.eq(book.id)) + .parts("x", { depth: true, path: { format: "qualified" } }) + .select((ctx) => ({ depth: ctx.x_depth, route: ctx.x_path })) + .execute(); + + expectTypeOf(rows).toEqualTypeOf< + readonly { depth: number; route: QualifiedRecursivePath }[] + >(); + const chapterRoute = [ + { type: "node", kind: "CnBook", id: book.id }, + { + type: "edge", + kind: "cnBookHasChapter", + id: chapterEdge.id, + direction: "out", + }, + { type: "node", kind: "CnChapter", id: chapter1.id }, + ]; + expect(rows.toSorted((left, right) => left.depth - right.depth)).toEqual([ + { depth: 1, route: chapterRoute }, + { + depth: 2, + route: [ + ...chapterRoute, + { + type: "edge", + kind: "cnParagraphOf", + id: paragraphEdge.id, + direction: "in", + }, + { type: "node", kind: "CnParagraph", id: paragraph1.id }, + ], + }, + ]); + + const aliasedRows = await store + .query() + .from("CnParagraph", "p") + .whereNode("p", (candidate) => candidate.id.eq(paragraph1.id)) + .wholes("w", { maxHops: 1, path: false, depth: "level" }) + .select((ctx) => ({ whole: ctx.w.id, level: ctx.level })) + .execute(); + expectTypeOf(aliasedRows).toEqualTypeOf< + readonly { whole: string; level: number }[] + >(); + expect(aliasedRows).toEqual([{ whole: chapter1.id, level: 1 }]); + }); + + it("a recursing mixed-orientation parts() compiles as a later traversal stage", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { book, chapter1, paragraph1 } = + await seedCompositionFixtures(store); + + // The mixed orientation (hasPart walked as stored, then partOf walked + // reversed) makes this stage compile its normalizing directed-edges + // CTE inside the seeded later stage. + const rows = await store + .query() + .from("CnChapter", "c") + .whereNode("c", (chapter) => chapter.id.eq(chapter1.id)) + .traverse("cnBookHasChapter", "c_book", { direction: "in" }) + .to("CnBook", "b") + .parts("x", { from: "b", path: "route" }) + .select((ctx) => ({ book: ctx.b.id, part: ctx.x.id, route: ctx.route })) + .execute(); + + expect( + rows.toSorted((left, right) => left.route.length - right.route.length), + ).toEqual([ + { book: book.id, part: chapter1.id, route: [book.id, chapter1.id] }, + { + book: book.id, + part: paragraph1.id, + route: [book.id, chapter1.id, paragraph1.id], + }, + ]); + }); + + it("chains two recursing composition steps in one query", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { sectionRoot, sectionChild, sectionGrandchild } = + await seedCompositionFixtures(store); + + const rows = await store + .query() + .from("CnSection", "s") + .whereNode("s", (section) => section.id.eq(sectionGrandchild.id)) + .wholes("w") + .parts("p", { from: "w" }) + .select((ctx) => ({ whole: ctx.w.id, part: ctx.p.id })) + .execute(); + + const pairs = rows.map((row) => `${row.whole}>${row.part}`).toSorted(); + expect(pairs).toEqual( + [ + `${sectionChild.id}>${sectionGrandchild.id}`, + `${sectionRoot.id}>${sectionChild.id}`, + `${sectionRoot.id}>${sectionGrandchild.id}`, + ].toSorted(), + ); + }); + + it("parts()/wholes() reach the whole closure of a chain deeper than the implicit recursive cap", async () => { + const store = await context.createStore(compositionNavigationGraph); + const root = await store.nodes.CnSection.create({ title: "Section 0" }); + const chain = [root]; + for (let level = 1; level < DEEP_SECTION_CHAIN_LENGTH; level += 1) { + const section = await store.nodes.CnSection.create({ + title: `Section ${level}`, + }); + await store.edges.cnParentSection.create( + section, + requireDefined(chain.at(-1)), + {}, + ); + chain.push(section); + } + const leaf = requireDefined(chain.at(-1)); + const descendantIds = chain.slice(1).map((section) => section.id); + const ancestorIds = chain.slice(0, -1).map((section) => section.id); + + const parts = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(root.id)) + .parts("x", { depth: "level" }) + .select((ctx) => ({ id: ctx.x.id, level: ctx.level })) + .execute(); + expect(parts.map((row) => row.id).toSorted()).toEqual( + descendantIds.toSorted(), + ); + expect(Math.max(...parts.map((row) => row.level))).toBe( + DEEP_SECTION_CHAIN_LENGTH - 1, + ); + + const wholes = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(leaf.id)) + .wholes("w") + .select((ctx) => ctx.w.id) + .execute(); + expect(wholes.toSorted()).toEqual(ancestorIds.toSorted()); + + // A reflexive pair reaches its own kind again, so `via` narrows nothing + // here and the closure stays whole. + const partsVia = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(root.id)) + .parts("x", { via: cnParentSection }) + .select((ctx) => ctx.x.id) + .execute(); + expect(partsVia.toSorted()).toEqual(descendantIds.toSorted()); + + const wholesVia = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(leaf.id)) + .wholes("w", { via: "cnParentSection" }) + .select((ctx) => ctx.w.id) + .execute(); + expect(wholesVia.toSorted()).toEqual(ancestorIds.toSorted()); + + const owned = await store.subgraph(root, { + edges: [], + composition: true, + }); + expect(owned.nodes.map((node) => node.id).toSorted()).toEqual( + chain.map((section) => section.id).toSorted(), + ); + }); + + it("parts()/wholes() return the complete closure of a chain past the hop ceiling, and a stated maxHops stays bounded", async () => { + const store = await context.createStore(compositionNavigationGraph); + const sectionIds = Array.from( + { length: BEYOND_HOP_CEILING_CHAIN_LENGTH }, + (_, index) => `cn-beyond-ceiling-${index}`, + ); + await store.nodes.CnSection.bulkInsert( + sectionIds.map((id, index) => ({ + id, + props: { title: `Section ${index}` }, + })), + ); + await store.edges.cnParentSection.bulkInsert( + sectionIds.slice(1).map((id, index) => ({ + from: { kind: "CnSection", id }, + to: { kind: "CnSection", id: requireDefined(sectionIds[index]) }, + })), + ); + const rootId = requireDefined(sectionIds[0]); + const leafId = requireDefined(sectionIds.at(-1)); + + const parts = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(rootId)) + .parts("x", { depth: "level" }) + .select((ctx) => ({ id: ctx.x.id, level: ctx.level })) + .execute(); + expect(parts).toHaveLength(BEYOND_HOP_CEILING_CHAIN_LENGTH - 1); + expect(parts.map((row) => row.id)).toContain(leafId); + expect(Math.max(...parts.map((row) => row.level))).toBe( + BEYOND_HOP_CEILING_CHAIN_LENGTH - 1, + ); + + const wholes = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(leafId)) + .wholes("w") + .select((ctx) => ctx.w.id) + .execute(); + expect(wholes).toHaveLength(BEYOND_HOP_CEILING_CHAIN_LENGTH - 1); + expect(wholes).toContain(rootId); + + const bounded = await store + .query() + .from("CnSection", "s") + .whereNode("s", (s) => s.id.eq(rootId)) + .parts("x", { maxHops: MAX_EXPLICIT_RECURSIVE_DEPTH }) + .select((ctx) => ctx.x.id) + .execute(); + expect(bounded).toHaveLength(MAX_EXPLICIT_RECURSIVE_DEPTH); + expect(bounded).not.toContain(leafId); + }); + // MUTATION CHECK: restore the implicit `MAX_EXPLICIT_RECURSIVE_DEPTH` + // ceiling for a `parts()`/`wholes()` call that states no `maxHops` (drop + // `exhaustive` from the traversal's variable-length state). The closure + // then stops one node short and the length and `leafId`/`rootId` + // assertions fail; the explicit `maxHops` assertion passes either way, + // pinning that a stated bound is honored exactly. + + it("parts() with via walks only that realizing edge of a multi-level, two-edge whole", async () => { + const store = await context.createStore(compositionViaGraph); + const { podcast, episode, trailer } = + await seedCompositionViaFixtures(store); + + const partsVia = ( + via: typeof vnEpisodeOf | typeof vnTrailerOf | string, + maxHops?: number, + ) => + store + .query() + .from("VnPodcast", "p") + .whereNode("p", (p) => p.id.eq(podcast.id)) + .parts("x", { via, ...(maxHops === undefined ? {} : { maxHops }) }) + .select((ctx) => ({ id: ctx.x.id, kind: ctx.x.kind })) + .execute(); + + const episodeRow = { id: episode.id, kind: "VnEpisode" }; + expect(await partsVia(vnEpisodeOf)).toEqual([episodeRow]); + expect(await partsVia("vnEpisodeOf")).toEqual([episodeRow]); + expect(await partsVia(vnEpisodeOf, 1)).toEqual([episodeRow]); + expect(await partsVia(vnTrailerOf)).toEqual([ + { id: trailer.id, kind: "VnTrailer" }, + ]); + }); + + it("wholes() with via walks only that realizing edge", async () => { + const store = await context.createStore(compositionViaGraph); + const { podcast, episode, segment } = + await seedCompositionViaFixtures(store); + + const segmentWholes = await store + .query() + .from("VnSegment", "s") + .whereNode("s", (s) => s.id.eq(segment.id)) + .wholes("w", { via: vnSegmentOf }) + .select((ctx) => ({ id: ctx.w.id, kind: ctx.w.kind })) + .execute(); + expect(segmentWholes).toEqual([{ id: episode.id, kind: "VnEpisode" }]); + + const episodeWholes = await store + .query() + .from("VnEpisode", "e") + .whereNode("e", (episodeNode) => episodeNode.id.eq(episode.id)) + .wholes("w", { via: "vnEpisodeOf" }) + .select((ctx) => ({ id: ctx.w.id, kind: ctx.w.kind })) + .execute(); + expect(episodeWholes).toEqual([{ id: podcast.id, kind: "VnPodcast" }]); + }); + + it("parts()/wholes() refuse a via that realizes no pair at the source alias itself", async () => { + const store = await context.createStore(compositionViaGraph); + + expect(() => + store.query().from("VnPodcast", "p").parts("x", { via: vnSegmentOf }), + ).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "COMPOSITION_VIA_NOT_DECLARED", + via: "vnSegmentOf", + declaredVia: ["vnEpisodeOf", "vnTrailerOf"], + }), + }), + ); + expect(() => + store.query().from("VnSegment", "s").wholes("w", { via: vnEpisodeOf }), + ).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "COMPOSITION_VIA_NOT_DECLARED", + via: "vnEpisodeOf", + declaredVia: ["vnSegmentOf"], + }), + }), + ); + }); + + it("subgraph({ composition: { via } }) refuses exactly the via parts() refuses", async () => { + const store = await context.createStore(compositionViaGraph); + const { podcast, episode, segment } = + await seedCompositionViaFixtures(store); + + // MUTATION CHECK: validate `via` against + // `registry.compositionEdgeKindsUnder(root.kind)` in + // `resolveSubgraphCompositionEdgeKinds` (src/store/subgraph.ts), as it + // once did, and this read silently returns the podcast alone instead of + // refusing — `vnSegmentOf` is under the podcast transitively but + // realizes no pair at it, so the one-edge walk from the root reads + // nothing — verified and reverted. + await expect( + store.subgraph(podcast, { + edges: [], + composition: { via: vnSegmentOf }, + }), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "COMPOSITION_VIA_NOT_DECLARED", + via: "vnSegmentOf", + rootKind: "VnPodcast", + declaredVia: ["vnEpisodeOf", "vnTrailerOf"], + }), + }), + ); + + const viaSegments = await store.subgraph(episode, { + edges: [], + composition: { via: vnSegmentOf }, + }); + expect(new Set(viaSegments.nodes.map((node) => node.id))).toEqual( + new Set([episode.id, segment.id]), + ); + }); + + it("parts() refuses when the derived edge alias collides with an existing traversal", async () => { + const store = await context.createStore(compositionNavigationGraph); + await seedCompositionFixtures(store); + + // MUTATION CHECK: removing the `#getEdgeKindNamesForAlias` collision + // guard lets this build; `x_edge` then silently merges into + // `dynamicEdgeAliases` for two different edge types instead of + // refusing — verified and reverted. + expect(() => + store + .query() + .from("CnPodcast", "p") + .traverse("cnEpisodeOf", "x_edge", { direction: "in" }) + .to("CnEpisode", "y") + .parts("x", { from: "p", maxHops: 1 }), + ).toThrow(expect.objectContaining({ code: "CONFIGURATION_ERROR" })); + }); + + it("parts() with an unknown `from` alias refuses naming the alias, not COMPOSITION_NO_PARTS_DECLARED", async () => { + const store = await context.createStore(compositionNavigationGraph); + await seedCompositionFixtures(store); + + // MUTATION CHECK: before this fix, + // `this.#getKindNamesForAlias(fromAlias) ?? []` collapsed "no such + // alias" into "this kind declares no composition parts" — the query + // above threw `COMPOSITION_NO_PARTS_DECLARED` naming kinds + // "(unknown)" instead of naming the actual typo'd alias, misdiagnosing + // a typo as a composition-declaration problem. Reverting the + // `#getKindNamesForAlias(fromAlias) === undefined` branch below (back + // to `?? []`) makes this assertion fail (`details.code` comes back + // `COMPOSITION_NO_PARTS_DECLARED` instead of + // `COMPOSITION_UNKNOWN_ALIAS`) — verified and reverted. + // `from` is typed `keyof Aliases & string`, so only a JS caller (or a + // cast, as here) can name an alias the query does not have — the + // typed surface would refuse this at compile time. + expect(() => + store + .query() + .from("CnPodcast", "p") + .parts("x", { from: "pod" as never }), + ).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ + code: "COMPOSITION_UNKNOWN_ALIAS", + alias: "pod", + }), + }), + ); + }); + + it("parts() on a kind declaring no composition parts refuses", async () => { + const store = await context.createStore(compositionNavigationGraph); + + // MUTATION CHECK: replacing the `edgeKinds.size === 0` guard with a + // silent empty-array return makes this refusal disappear (the query + // executes and returns zero rows instead of throwing) — verified and + // reverted. + expect(() => store.query().from("CnSegment", "s").parts("x")).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_NO_PARTS_DECLARED" }), + }), + ); + }); + + it("wholes() on a kind declaring no composition wholes refuses", async () => { + const store = await context.createStore(compositionNavigationGraph); + + expect(() => store.query().from("CnPodcast", "p").wholes("x")).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_NO_WHOLES_DECLARED" }), + }), + ); + }); + + // MUTATION CHECK (all three cases): making the registry closure resolve + // only the root kind through subsumption (dropping the re-resolution of + // each reached kind) fails every case for both graphs — verified and + // reverted. + describe.each(SUBSUMPTION_CLOSURE_GRAPHS)( + "closure through %s", + (_label, graph) => { + async function seedSubsumptionClosure() { + const store = await context.createStore(graph); + const report = await store.nodes.CsReport.create({}); + const rich = await store.nodes.CsRichSection.create( + {}, + { partOf: { whole: report } }, + ); + const paragraph = await store.nodes.CsParagraph.create( + {}, + { partOf: { whole: rich } }, + ); + return { store, report, rich, paragraph }; + } + + it("parts() and wholes() return the whole closure", async () => { + const { store, report, rich, paragraph } = + await seedSubsumptionClosure(); + + const parts = await store + .query() + .from("CsReport", "d") + .parts("p") + .select((ctx) => ctx.p.id) + .execute(); + expect(new Set(parts)).toEqual(new Set([rich.id, paragraph.id])); + + const wholes = await store + .query() + .from("CsParagraph", "p") + .wholes("w") + .select((ctx) => ctx.w.id) + .execute(); + expect(new Set(wholes)).toEqual(new Set([rich.id, report.id])); + }); + + it("subgraph({ composition: true }) returns the complete owned unit", async () => { + const { store, report, rich, paragraph } = + await seedSubsumptionClosure(); + + const result = await store.subgraph(report, { + edges: [], + composition: true, + }); + + expect(new Set(result.nodes.map((node) => node.id))).toEqual( + new Set([report.id, rich.id, paragraph.id]), + ); + }); + + it("deleting the whole cascades through the whole closure", async () => { + const { store, report, rich, paragraph } = + await seedSubsumptionClosure(); + + await store.nodes.CsReport.delete(report.id); + + expect( + await store.nodes.CsRichSection.getById(rich.id), + ).toBeUndefined(); + expect( + await store.nodes.CsParagraph.getById(paragraph.id), + ).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + }, + ); + + it("subgraph({ composition: true }) exports the whole plus its parts", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast, episode1, episode2, segment1 } = + await seedCompositionFixtures(store); + + const result = await store.subgraph(podcast, { + edges: [], + composition: true, + }); + + expect(result.root?.kind).toBe("CnPodcast"); + expect(new Set(result.nodes.map((node) => node.id))).toEqual( + new Set([podcast.id, episode1.id, episode2.id, segment1.id]), + ); + }); + + it("subgraph({ composition: true }) rooted mid-tree closes only toward parts, never ancestors or siblings", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { sectionRoot, sectionChild, sectionGrandchild } = + await seedCompositionFixtures(store); + const sectionSibling = await store.nodes.CnSection.create({ + title: "Sibling Section", + }); + await store.edges.cnParentSection.create(sectionSibling, sectionRoot, {}); + + // MUTATION CHECK: reverting the oriented-closure fix (walking + // `compositionEdgeKinds` as a flat `direction: "both"` instead of the + // per-edge-kind direction `compositionTraversalDirection` derives) + // makes this include `sectionRoot` and `sectionSibling` alongside the + // expected two nodes — verified and reverted. + const result = await store.subgraph(sectionChild, { + edges: [], + composition: true, + }); + + expect(new Set(result.nodes.map((node) => node.id))).toEqual( + new Set([sectionChild.id, sectionGrandchild.id]), + ); + }); + + it("subgraph({ composition: true }) returns the COMPLETE owned unit past maxDepth", async () => { + const store = await context.createStore(compositionNavigationGraph); + // 14 levels: deeper than DEFAULT_SUBGRAPH_MAX_DEPTH (10), and deeper + // than the explicit `maxDepth: 2` this call also states. + const chainLength = 14; + const chain = [ + await store.nodes.CnSection.create({ title: "Section 0" }), + ]; + for (let level = 1; level < chainLength; level += 1) { + const child = await store.nodes.CnSection.create({ + title: `Section ${level}`, + }); + await store.edges.cnParentSection.create( + child, + requireDefined(chain[level - 1]), + {}, + ); + chain.push(child); + } + + // MUTATION CHECK: restore `maxHops: ctx.maxDepth` in + // `buildSubgraphCompositionReachableCte` (src/store/subgraph.ts). The + // composition closure then stops at `maxDepth` hops and this returns + // 3 nodes instead of 14 — verified and reverted. + const result = await store.subgraph(requireDefined(chain[0]), { + edges: [], + composition: true, + maxDepth: 2, + }); + + expect(new Set(result.nodes.map((node) => node.id))).toEqual( + new Set(chain.map((section) => section.id)), + ); + }); + + it("subgraph({ composition: true }) projects composition edges it adds to the traversal", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast, episode1 } = await seedCompositionFixtures(store); + + const result = await store.subgraph(podcast, { + edges: [], + composition: true, + project: { edges: { cnEpisodeOf: ["position"] } }, + }); + + const pilotEdges = indexSubgraph(result).outgoing( + episode1, + "cnEpisodeOf", + ); + expect(pilotEdges).toHaveLength(1); + const pilotEdge = requireDefined(pilotEdges[0]); + expect(pilotEdge).toMatchObject({ + kind: "cnEpisodeOf", + fromId: episode1.id, + toId: podcast.id, + position: 1, + }); + expect(pilotEdge).not.toHaveProperty("note"); + expect(pilotEdge).not.toHaveProperty("meta"); + }); + + it("subgraph({ composition: true }) walks the root's own (kind, id), never another kind's node that shares an id", async () => { + const store = await context.createStore(sharedIdCompositionGraph); + const album = await store.nodes.CnAlbum.create({}); + const track = await store.nodes.CnTrack.create({}, { id: "shared" }); + await store.edges.cnAlbumHasTrack.create(album, track, {}); + const trackNote = await store.nodes.CnNote.create( + {}, + { partOf: { whole: track } }, + ); + // A video under the SAME id, owning a note of its own: no part of the + // album's unit, which `parts()` and the cascade both key on kind. + const video = await store.nodes.CnVideo.create({}, { id: track.id }); + const videoNote = await store.nodes.CnNote.create( + {}, + { partOf: { whole: video } }, + ); + + const parts = await store + .query() + .from("CnAlbum", "album") + .whereNode("album", (node) => node.id.eq(album.id)) + .parts("part") + .select((ctx) => ({ kind: ctx.part.kind, id: ctx.part.id })) + .execute(); + expect(new Set(parts.map((part) => `${part.kind}/${part.id}`))).toEqual( + new Set([`CnTrack/${track.id}`, `CnNote/${trackNote.id}`]), + ); + + // MUTATION CHECK: drop the `e.from_kind = r.kind` / `e.to_kind = + // r.kind` terms from `buildDirectedGroupsBranch` + // (src/store/recursive-cte.ts) — the walk then follows the video's + // note edge from the track's id and pulls that note into the album's + // unit. + const result = await store.subgraph(album, { + edges: [], + composition: true, + }); + expect(indexSubgraph(result).node(trackNote)).toBeDefined(); + expect(indexSubgraph(result).node(videoNote)).toBeUndefined(); + + // MUTATION CHECK: drop the `n.kind` term from `compileSeedFilter` + // (src/store/recursive-cte.ts) — the walk then seeds from every node + // holding the root's id, and each root's unit takes in the other's note. + const trackUnit = await store.subgraph(track, { + edges: [], + composition: true, + }); + expect(indexSubgraph(trackUnit).node(trackNote)).toBeDefined(); + expect(indexSubgraph(trackUnit).node(videoNote)).toBeUndefined(); + const videoUnit = await store.subgraph(video, { + edges: [], + composition: true, + }); + expect(indexSubgraph(videoUnit).node(videoNote)).toBeDefined(); + expect(indexSubgraph(videoUnit).node(trackNote)).toBeUndefined(); + }); + + it("parts() and wholes() return every member when members of different kinds share an id", async () => { + const store = await context.createStore(sharedIdCompositionGraph); + const album = await store.nodes.CnAlbum.create({}); + const track = await store.nodes.CnTrack.create({}, { id: "shared" }); + await store.edges.cnAlbumHasTrack.create(album, track, {}); + // The note reuses the track's id, one level below it in the same unit. + const note = await store.nodes.CnNote.create( + {}, + { id: track.id, partOf: { whole: track } }, + ); + + // MUTATION CHECK: key the recursive cycle token on the bare id + // (`recursivePathTokenShape` returning "id" for a multi-kind walk, + // src/query/compiler/recursive-path-token.ts) — the walk then reads the + // note as a revisit of the track it descends from and drops it, and + // the upward walk from the note drops the track and never reaches the + // album. + const parts = await store + .query() + .from("CnAlbum", "album") + .whereNode("album", (node) => node.id.eq(album.id)) + .parts("part") + .select((ctx) => ({ kind: ctx.part.kind, id: ctx.part.id })) + .execute(); + expect(new Set(parts.map((part) => `${part.kind}/${part.id}`))).toEqual( + new Set([`CnTrack/${track.id}`, `CnNote/${note.id}`]), + ); + + const wholes = await store + .query() + .from("CnNote", "note") + .whereNode("note", (node) => node.id.eq(note.id)) + .wholes("whole") + .select((ctx) => ({ kind: ctx.whole.kind, id: ctx.whole.id })) + .execute(); + expect( + new Set(wholes.map((whole) => `${whole.kind}/${whole.id}`)), + ).toEqual(new Set([`CnTrack/${track.id}`, `CnAlbum/${album.id}`])); + }); + + it("subgraph({ composition: true }) reads every statement at one current instant", async () => { + const readStart = new Date("2099-01-01T00:00:00.000Z"); + const afterEpisodeEnds = new Date("2099-01-01T00:02:00.000Z"); + let advanceClockOnNextStatement = false; + const store = await context.createStore(compositionNavigationGraph, { + hooks: { + onQueryStart: () => { + if (!advanceClockOnNextStatement) return; + advanceClockOnNextStatement = false; + vi.setSystemTime(afterEpisodeEnds); + }, + }, + }); + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(readStart); + const podcast = await store.nodes.CnPodcast.create({ title: "Pod" }); + // The PART's own window ends a minute after the read starts. (A + // composition edge cannot: its window never ends later than its + // write, so its membership does not move with the clock — the node's + // window is what a mid-read clock change can still disagree about.) + const episode = await store.nodes.CnEpisode.create( + { title: "Ep" }, + { validTo: "2099-01-01T00:01:00.000Z" }, + ); + await store.edges.cnEpisodeOf.create(episode, podcast, {}); + + // The clock moves past the episode's end while the read is in + // flight, after its first statement. Every statement of one read must + // still evaluate "current" at the instant the read started. The + // transaction-bound read runs the same executor through the hooked + // backend, which is what lets the hook move the clock mid-read. + const result = await store.transaction((tx) => { + advanceClockOnNextStatement = true; + return tx.subgraph(podcast, { edges: [], composition: true }); + }); + + expect(new Set(result.nodes.map((node) => node.id))).toEqual( + new Set([podcast.id, episode.id]), + ); + } finally { + vi.useRealTimers(); + } + }); + + it("subgraph({ composition: true }) closes the unit under a recorded-pinned read", async () => { + const history = await context.createHistoryStore( + compositionNavigationGraph, + ); + const book = await history.nodes.CnBook.create({ title: "The Book" }); + const chapter = await history.nodes.CnChapter.create({ + title: "Chapter 1", + }); + await history.edges.cnBookHasChapter.create(book, chapter, {}); + const paragraph = await history.nodes.CnParagraph.create({ + title: "Paragraph 1", + }); + await history.edges.cnParagraphOf.create(paragraph, chapter, {}); + const pin = await history.recordedNow(); + if (pin === undefined) throw new Error("recorded clock was not written"); + const lateChapter = await history.nodes.CnChapter.create({ + title: "Chapter 2", + }); + await history.edges.cnBookHasChapter.create(book, lateChapter, {}); + + const pinned = await history.asOfRecorded(pin).subgraph(book, { + edges: [], + composition: true, + }); + + expect(new Set(pinned.nodes.map((node) => node.id))).toEqual( + new Set([book.id, chapter.id, paragraph.id]), + ); + }); + + it("tx.subgraph({ composition }) returns the same unit as store.subgraph", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast, book } = await seedCompositionFixtures(store); + + const [storePodcast, storeBookViaChapters] = await Promise.all([ + store.subgraph(podcast, { edges: [], composition: true }), + store.subgraph(book, { + edges: [], + composition: { via: "cnBookHasChapter" }, + }), + ]); + const [txPodcast, txBookViaChapters] = await store.transaction( + async (tx) => [ + await tx.subgraph(podcast, { edges: [], composition: true }), + await tx.subgraph(book, { + edges: [], + composition: { via: "cnBookHasChapter" }, + }), + ], + ); + + expect(txPodcast.nodes).toHaveLength(4); + expect(new Set(txPodcast.nodes.map((node) => node.id))).toEqual( + new Set(storePodcast.nodes.map((node) => node.id)), + ); + expect(edgeIds(txPodcast.edges)).toEqual(edgeIds(storePodcast.edges)); + expect(txBookViaChapters.nodes).toHaveLength(2); + expect(new Set(txBookViaChapters.nodes.map((node) => node.id))).toEqual( + new Set(storeBookViaChapters.nodes.map((node) => node.id)), + ); + await expect( + store.transaction((tx) => + tx.subgraph(book, { + edges: [], + composition: { via: "cnEpisodeOf" }, + }), + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ code: "COMPOSITION_VIA_NOT_DECLARED" }), + }), + ); + }); + + it("batchOnce refuses subgraph({ composition }) before running any statement", async () => { + const statements: string[] = []; + const store = await context.createStore(compositionNavigationGraph, { + hooks: { onQueryStart: (ctx) => statements.push(ctx.sql) }, + }); + const { podcast } = await seedCompositionFixtures(store); + // The batch builder's options type admits no `composition`; only a + // JavaScript caller can reach this refusal, so the options are + // untyped here. MUTATION CHECK: drop the `"batchOnce.subgraph"` + // composition refusal from `buildSubgraphPlan` (src/store/subgraph.ts) + // and both batches resolve, the one-statement read silently ignoring + // `composition` — verified and reverted. + const compositionSelections: readonly unknown[] = [ + true, + { via: "cnEpisodeOf" }, + { via: "notAnEdgeKind" }, + ]; + + for (const composition of compositionSelections) { + statements.length = 0; + const options = { edges: [], composition } as never; + await expect( + store.batchOnce((read) => [ + read.neighbors(podcast, { edges: ["cnEpisodeOf"] }), + read.subgraph(podcast, options), + ]), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ + code: "SUBGRAPH_COMPOSITION_ONE_STATEMENT_UNSUPPORTED", + }), + }), + ); + expect(statements).toEqual([]); + + await expect( + store.transaction((tx) => + tx.batchOnce((read) => [read.subgraph(podcast, options)]), + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "SUBGRAPH_COMPOSITION_ONE_STATEMENT_UNSUPPORTED", + }), + }), + ); + } + }); + + it("subgraph refuses a composition selection that is neither a boolean nor { via }", async () => { + const store = await context.createStore(compositionNavigationGraph); + const { podcast } = await seedCompositionFixtures(store); + const invalidSelections: readonly unknown[] = ["true", { via: 42 }, []]; + + for (const composition of invalidSelections) { + const options = { edges: [], composition } as never; + await expect(store.subgraph(podcast, options)).rejects.toThrow( + expect.objectContaining({ code: "VALIDATION_ERROR" }), + ); + } + }); + + it("subgraph({ composition: true }) refuses on a graph declaring no composition relation", async () => { + const store = await context.createStore(noCompositionGraph); + const node = await store.nodes.CnPodcast.create({ title: "Solo" }); + + await expect( + store.subgraph(node, { edges: [], composition: true }), + ).rejects.toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "COMPOSITION_NO_PARTS_DECLARED" }), + }), + ); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-reads-model.ts b/packages/typegraph/tests/backends/integration/composition-reads-model.ts new file mode 100644 index 000000000..ee16a4d49 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-reads-model.ts @@ -0,0 +1,732 @@ +/** + * Composition reads against a reference model, over random forests. + * + * A generated script builds a two-level forest on a fake clock — attaching, + * ending and soft-deleting composition edges and ending part nodes — on a + * graph whose two relations draw their populations independently, so one + * relation can hold an "allRows" kind and the other an "openRows" kind: + * + * PrRoot <-(prMidOf, partOf, population TOP)-- PrMid + * PrMid --(prHasLeaf, hasPart, population SUB)--> PrLeaf + * + * The model (plain TS, no store code) decides membership: an edge binds at + * instant t when it is not soft-deleted (a soft-delete is retroactive for + * every valid-time read), `validFrom <= t`, and — for a "oneActive" relation + * only — `t < validTo`. Reads follow membership for edges and the read's + * temporal filter for nodes: a part node the read cannot see is not returned, + * nor is anything reachable only through it. + * + * At every sampled coordinate (current, includeEnded, includeTombstones, and + * asOf instants on and just before the script's own write instants): + * - `parts()`, `parts({ maxHops: 1 })`, `wholes()` and + * `subgraph({ composition: true })` equal the model; + * - `p ∈ parts(w) ⇔ w ∈ wholes(p)`, judged on the reads alone; + * and after every write `planDelete` of each root equals the model's + * ownership closure now, ignoring node visibility (the cascade owns a hidden + * part too). An asOf read at a write instant no later soft-delete rewrote, + * with every node visible there, also equals the plan captured at that write. + */ +import fc from "fast-check"; +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineNode, + hasPart, + type NodeDeletePlan, + partOf, +} from "../../../src"; +import { propertyRuns } from "../../property/run-budget"; +import { + type InspectableStore, + type IntegrationTestContext, +} from "./test-context"; + +const PrRoot = defineNode("PrRoot", { schema: z.object({}) }); +const PrMid = defineNode("PrMid", { schema: z.object({}) }); +const PrLeaf = defineNode("PrLeaf", { schema: z.object({}) }); +const prMidOf = defineEdge("prMidOf", { schema: z.object({}) }); +const prHasLeaf = defineEdge("prHasLeaf", { schema: z.object({}) }); + +type Population = "one" | "oneActive"; +const POPULATIONS: readonly Population[] = ["one", "oneActive"]; + +const ROOT_COUNT = 2; +const MID_COUNT = 3; +const LEAF_COUNT = 3; +const CLOCK_START = Date.parse("2040-01-01T00:00:00.000Z"); +const CLOCK_STEP_MS = 1000; + +let graphIdCounter = 0; + +function buildGraph(top: Population, sub: Population) { + graphIdCounter += 1; + return defineGraph({ + id: `composition_reads_model_${graphIdCounter}`, + nodes: { + PrRoot: { type: PrRoot }, + PrMid: { type: PrMid }, + PrLeaf: { type: PrLeaf }, + }, + edges: { + prMidOf: { + type: prMidOf, + from: [PrMid], + to: [PrRoot], + cardinality: top, + }, + prHasLeaf: { + type: prHasLeaf, + from: [PrMid], + to: [PrLeaf], + targetCardinality: sub, + }, + }, + ontology: [ + partOf(PrMid, PrRoot, { via: prMidOf }), + hasPart(PrMid, PrLeaf, { via: prHasLeaf }), + ], + }); +} + +type ModelGraph = ReturnType; +type ModelStore = InspectableStore; + +type Op = + | Readonly<{ type: "attachMid"; mid: number; root: number }> + | Readonly<{ type: "attachLeaf"; leaf: number; mid: number }> + | Readonly<{ type: "endEdge"; pick: number }> + | Readonly<{ type: "deleteEdge"; pick: number }> + | Readonly<{ type: "endNode"; pick: number }>; + +const opArbitrary: fc.Arbitrary = fc.oneof( + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant("attachMid" as const), + mid: fc.nat({ max: MID_COUNT - 1 }), + root: fc.nat({ max: ROOT_COUNT - 1 }), + }), + }, + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant("attachLeaf" as const), + leaf: fc.nat({ max: LEAF_COUNT - 1 }), + mid: fc.nat({ max: MID_COUNT - 1 }), + }), + }, + { + weight: 2, + arbitrary: fc.record({ + type: fc.constant("endEdge" as const), + pick: fc.nat(), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + type: fc.constant("deleteEdge" as const), + pick: fc.nat(), + }), + }, + { + weight: 1, + arbitrary: fc.record({ + type: fc.constant("endNode" as const), + pick: fc.nat(), + }), + }, +); + +/** A starting forest: each mid under some root, each leaf under some mid, or unattached. */ +const seedForestArbitrary: fc.Arbitrary = fc + .tuple( + fc.array(fc.option(fc.nat({ max: ROOT_COUNT - 1 }), { freq: 4 }), { + minLength: MID_COUNT, + maxLength: MID_COUNT, + }), + fc.array(fc.option(fc.nat({ max: MID_COUNT - 1 }), { freq: 4 }), { + minLength: LEAF_COUNT, + maxLength: LEAF_COUNT, + }), + ) + .map(([midRoots, leafMids]) => [ + ...midRoots.flatMap((root, mid): Op[] => + root === null ? [] : [{ type: "attachMid", mid, root }], + ), + ...leafMids.flatMap((mid, leaf): Op[] => + mid === null ? [] : [{ type: "attachLeaf", leaf, mid }], + ), + ]); + +const scenarioArbitrary = fc.record({ + top: fc.constantFrom(...POPULATIONS), + sub: fc.constantFrom(...POPULATIONS), + ops: fc + .tuple(seedForestArbitrary, fc.array(opArbitrary, { maxLength: 10 })) + .map(([seedOps, ops]) => [...seedOps, ...ops]), + asOfPicks: fc.array(fc.nat(), { minLength: 2, maxLength: 4 }), +}); + +type Scenario = + typeof scenarioArbitrary extends fc.Arbitrary ? T : never; + +// ============================================================ +// Reference model +// ============================================================ + +interface ModelNode { + readonly kind: "PrRoot" | "PrMid" | "PrLeaf"; + readonly id: string; + validTo: number | undefined; +} + +type Relation = "prMidOf" | "prHasLeaf"; + +interface ModelEdge { + readonly id: string; + readonly relation: Relation; + /** Node keys, part and whole, independent of the edge's direction. */ + readonly part: string; + readonly whole: string; + readonly validFrom: number; + validTo: number | undefined; + deleted: boolean; +} + +type Model = Readonly<{ + populations: Readonly>; + nodes: Map; + edges: ModelEdge[]; +}>; + +/** A read's coordinate: which instant decides membership, and which nodes it sees. */ +type Coordinate = Readonly<{ + label: string; + /** The instant membership is judged at. */ + instant: number; + /** The instant node windows are judged at; `undefined` sees every node. */ + nodeInstant: number | undefined; +}>; + +function keyOf(node: Readonly<{ kind: string; id: string }>): string { + return `${node.kind}:${node.id}`; +} + +function edgeBinds(model: Model, edge: ModelEdge, instant: number): boolean { + if (edge.deleted || edge.validFrom > instant) return false; + if (model.populations[edge.relation] === "one") return true; + return edge.validTo === undefined || instant < edge.validTo; +} + +function nodeVisible( + node: ModelNode, + nodeInstant: number | undefined, +): boolean { + if (nodeInstant === undefined) return true; + // Every node is created at CLOCK_START, before any coordinate. + return node.validTo === undefined || nodeInstant < node.validTo; +} + +function requireNode(model: Model, key: string): ModelNode { + const node = model.nodes.get(key); + if (node === undefined) throw new Error(`model has no node ${key}`); + return node; +} + +/** Direct (part, whole, edge) bindings at a membership instant. */ +function bindings(model: Model, instant: number): readonly ModelEdge[] { + return model.edges.filter((edge) => edgeBinds(model, edge, instant)); +} + +/** + * The closure under `whole`, as `part, + maxHops = Number.POSITIVE_INFINITY, +): ReadonlyMap { + const bound = bindings(model, coordinate.instant); + const reached = new Map(); + if (!nodeVisible(requireNode(model, whole), coordinate.nodeInstant)) { + return reached; + } + let frontier = [whole]; + for (let hop = 0; hop < maxHops && frontier.length > 0; hop += 1) { + const next: string[] = []; + for (const current of frontier) { + for (const edge of bound) { + if (edge.whole !== current || reached.has(edge.part)) continue; + if (!nodeVisible(requireNode(model, edge.part), coordinate.nodeInstant)) + continue; + reached.set(edge.part, current); + next.push(edge.part); + } + } + frontier = next; + } + return reached; +} + +function wholesOf( + model: Model, + part: string, + coordinate: Coordinate, +): readonly string[] { + if (!nodeVisible(requireNode(model, part), coordinate.nodeInstant)) { + return []; + } + const bound = bindings(model, coordinate.instant); + const found: string[] = []; + let current = part; + for (;;) { + const up = bound.find((edge) => edge.part === current); + if (up === undefined) break; + if (!nodeVisible(requireNode(model, up.whole), coordinate.nodeInstant)) + break; + found.push(up.whole); + current = up.whole; + } + return found; +} + +function liveEdges(model: Model): readonly ModelEdge[] { + return model.edges.filter((edge) => !edge.deleted); +} + +/** Only writes the store surely accepts: the model never predicts a refusal. */ +function claimFree(model: Model, relation: Relation, part: string): boolean { + return !model.edges.some( + (edge) => + edge.relation === relation && + edge.part === part && + (model.populations[relation] === "one" || edge.validTo === undefined), + ); +} + +function rootKey(index: number): string { + return `PrRoot:r${index}`; +} + +function midKey(index: number): string { + return `PrMid:m${index}`; +} + +function leafKey(index: number): string { + return `PrLeaf:l${index}`; +} + +function idOf(key: string): string { + return key.slice(key.indexOf(":") + 1); +} + +// ============================================================ +// Store side +// ============================================================ + +function planPairs(plan: NodeDeletePlan): readonly string[] { + return plan.cascadedParts + .map((part) => `${keyOf(part)}<${keyOf(part.whole)}`) + .toSorted(); +} + +function modelPairs(reached: ReadonlyMap): readonly string[] { + return [...reached].map(([part, whole]) => `${part}<${whole}`).toSorted(); +} + +async function planOf(store: ModelStore, root: string) { + const plan = await store.nodes.PrRoot.planDelete(idOf(root) as never); + if (plan === undefined) throw new Error(`no plan for ${root}`); + return plan; +} + +/** Applies one op when the model accepts it; returns whether it ran. */ +async function applyOp( + store: ModelStore, + model: Model, + op: Op, + now: number, +): Promise { + const at = new Date(now).toISOString(); + switch (op.type) { + case "attachMid": + case "attachLeaf": { + const [relation, part, whole]: [Relation, string, string] = + op.type === "attachMid" ? + ["prMidOf", midKey(op.mid), rootKey(op.root)] + : ["prHasLeaf", leafKey(op.leaf), midKey(op.mid)]; + if (!claimFree(model, relation, part)) return false; + if (requireNode(model, part).validTo !== undefined) return false; + if (requireNode(model, whole).validTo !== undefined) return false; + const id = `e${model.edges.length}`; + await (relation === "prMidOf" ? + store.edges.prMidOf.create( + { kind: "PrMid", id: idOf(part) }, + { kind: "PrRoot", id: idOf(whole) }, + {}, + { id }, + ) + : store.edges.prHasLeaf.create( + { kind: "PrMid", id: idOf(whole) }, + { kind: "PrLeaf", id: idOf(part) }, + {}, + { id }, + )); + model.edges.push({ + id, + relation, + part, + whole, + validFrom: now, + validTo: undefined, + deleted: false, + }); + return true; + } + case "endEdge": { + const open = liveEdges(model).filter( + (edge) => edge.validTo === undefined, + ); + const edge = open[op.pick % Math.max(open.length, 1)]; + if (edge === undefined) return false; + await store.edges[edge.relation].update( + edge.id as never, + {}, + { validTo: at }, + ); + edge.validTo = now; + return true; + } + case "deleteEdge": { + const live = liveEdges(model); + const edge = live[op.pick % Math.max(live.length, 1)]; + if (edge === undefined) return false; + await store.edges[edge.relation].delete(edge.id as never); + edge.deleted = true; + return true; + } + case "endNode": { + const open = [...model.nodes.values()].filter( + (node) => node.kind !== "PrRoot" && node.validTo === undefined, + ); + const node = open[op.pick % Math.max(open.length, 1)]; + if (node === undefined) return false; + await (node.kind === "PrMid" ? + store.nodes.PrMid.update(node.id as never, {}, { validTo: at }) + : store.nodes.PrLeaf.update(node.id as never, {}, { validTo: at })); + node.validTo = now; + return true; + } + } +} + +type View = ReturnType; + +function pairKey(whole: string, part: string): string { + return `${part}<${whole}`; +} + +async function readPartPairs( + view: View, + kind: "PrRoot" | "PrMid", + maxHops?: number, +): Promise { + const rows = await view + .query() + .from(kind, "w") + .parts("p", maxHops === undefined ? undefined : { maxHops }) + .select((ctx) => ({ + whole: keyOf({ kind, id: ctx.w.id }), + part: `${ctx.p.kind}:${ctx.p.id}`, + })) + .execute(); + return rows.map((row) => pairKey(row.whole, row.part)); +} + +async function readWholePairs( + view: View, + kind: "PrMid" | "PrLeaf", +): Promise { + const rows = await view + .query() + .from(kind, "p") + .wholes("w") + .select((ctx) => ({ + part: keyOf({ kind, id: ctx.p.id }), + whole: `${ctx.w.kind}:${ctx.w.id}`, + })) + .execute(); + return rows.map((row) => pairKey(row.whole, row.part)); +} + +async function expectReadsMatchModel( + view: View, + model: Model, + coordinate: Coordinate, + context: string, +): Promise { + const label = `${context} @ ${coordinate.label}`; + const roots = [...model.nodes.keys()].filter((key) => + key.startsWith("PrRoot:"), + ); + const mids = [...model.nodes.keys()].filter((key) => + key.startsWith("PrMid:"), + ); + const leaves = [...model.nodes.keys()].filter((key) => + key.startsWith("PrLeaf:"), + ); + + const expectedParts = (wholes: readonly string[], maxHops?: number) => + wholes + .flatMap((whole) => + [...closure(model, whole, coordinate, maxHops).keys()].map((part) => + pairKey(whole, part), + ), + ) + .toSorted(); + const expectedWholes = (parts: readonly string[]) => + parts + .flatMap((part) => + wholesOf(model, part, coordinate).map((whole) => pairKey(whole, part)), + ) + .toSorted(); + + const partsFromRoots = await readPartPairs(view, "PrRoot"); + const partsFromMids = await readPartPairs(view, "PrMid"); + expect(partsFromRoots.toSorted(), `${label}: parts() of roots`).toEqual( + expectedParts(roots), + ); + expect(partsFromMids.toSorted(), `${label}: parts() of mids`).toEqual( + expectedParts(mids), + ); + const directPartsFromRoots = await readPartPairs(view, "PrRoot", 1); + expect( + directPartsFromRoots.toSorted(), + `${label}: parts({ maxHops: 1 }) of roots`, + ).toEqual(expectedParts(roots, 1)); + + const wholesFromMids = await readWholePairs(view, "PrMid"); + const wholesFromLeaves = await readWholePairs(view, "PrLeaf"); + expect(wholesFromMids.toSorted(), `${label}: wholes() of mids`).toEqual( + expectedWholes(mids), + ); + expect(wholesFromLeaves.toSorted(), `${label}: wholes() of leaves`).toEqual( + expectedWholes(leaves), + ); + + // Duality on the reads alone: every (whole, part) pair parts() reports is + // one wholes() reports, and the reverse. + expect( + [...partsFromRoots, ...partsFromMids].toSorted(), + `${label}: p ∈ parts(w) ⇔ w ∈ wholes(p)`, + ).toEqual([...wholesFromMids, ...wholesFromLeaves].toSorted()); + + const bound = bindings(model, coordinate.instant); + for (const root of roots) { + const reached = closure(model, root, coordinate); + const unit = await view.subgraph( + { kind: "PrRoot", id: idOf(root) } as never, + { edges: [], composition: true }, + ); + expect( + unit.nodes + .map((node) => keyOf(node)) + .filter((key) => key !== root) + .toSorted(), + `${label}: subgraph(${root}) nodes`, + ).toEqual([...reached.keys()].toSorted()); + expect( + unit.edges.map((edge) => edge.id).toSorted(), + `${label}: subgraph(${root}) edges`, + ).toEqual( + bound + .filter((edge) => reached.get(edge.part) === edge.whole) + .map((edge) => edge.id) + .toSorted(), + ); + } +} + +type Capture = Readonly<{ + instant: number; + /** Whether this write ended a window, so its instant is a `<` / `<=` boundary. */ + endedWindow: boolean; + /** How many soft-deletes the script had made when the plan was taken. */ + deletesSoFar: number; + plans: ReadonlyMap; +}>; + +async function runScenario( + context: IntegrationTestContext, + scenario: Scenario, +): Promise { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + let now = CLOCK_START; + vi.setSystemTime(now); + const store = await context.createStore( + buildGraph(scenario.top, scenario.sub), + ); + const model: Model = { + populations: { prMidOf: scenario.top, prHasLeaf: scenario.sub }, + nodes: new Map(), + edges: [], + }; + for (let index = 0; index < ROOT_COUNT; index += 1) { + const node = await store.nodes.PrRoot.create({}, { id: `r${index}` }); + model.nodes.set(keyOf(node), { + kind: "PrRoot", + id: node.id, + validTo: undefined, + }); + } + for (let index = 0; index < MID_COUNT; index += 1) { + const node = await store.nodes.PrMid.create({}, { id: `m${index}` }); + model.nodes.set(keyOf(node), { + kind: "PrMid", + id: node.id, + validTo: undefined, + }); + } + for (let index = 0; index < LEAF_COUNT; index += 1) { + const node = await store.nodes.PrLeaf.create({}, { id: `l${index}` }); + model.nodes.set(keyOf(node), { + kind: "PrLeaf", + id: node.id, + validTo: undefined, + }); + } + const roots = [...model.nodes.keys()].filter((key) => + key.startsWith("PrRoot:"), + ); + + const captures: Capture[] = []; + let deletes = 0; + for (const [step, op] of scenario.ops.entries()) { + now += CLOCK_STEP_MS; + vi.setSystemTime(now); + const ran = await applyOp(store, model, op, now); + if (!ran) continue; + if (op.type === "deleteEdge") deletes += 1; + const plans = new Map(); + for (const root of roots) { + const plan = planPairs(await planOf(store, root)); + // The cascade owns every bound part, hidden or not. + expect(plan, `step ${step} ${op.type}: planDelete(${root})`).toEqual( + modelPairs( + closure(model, root, { instant: now, nodeInstant: undefined }), + ), + ); + plans.set(root, plan); + } + captures.push({ + instant: now, + endedWindow: op.type === "endEdge" || op.type === "endNode", + deletesSoFar: deletes, + plans, + }); + } + + now += CLOCK_STEP_MS; + vi.setSystemTime(now); + const scriptLabel = `${scenario.top}/${scenario.sub} ${JSON.stringify(scenario.ops)}`; + const modeCoordinates: readonly (Coordinate & + Readonly<{ mode: "current" | "includeEnded" | "includeTombstones" }>)[] = + [ + { label: "current", mode: "current", instant: now, nodeInstant: now }, + { + label: "includeEnded", + mode: "includeEnded", + instant: now, + nodeInstant: undefined, + }, + { + label: "includeTombstones", + mode: "includeTombstones", + instant: now, + nodeInstant: undefined, + }, + ]; + for (const coordinate of modeCoordinates) { + await expectReadsMatchModel( + store.view({ mode: coordinate.mode }), + model, + coordinate, + scriptLabel, + ); + } + + // Every instant a window ended at, plus sampled write instants (the + // `validFrom` / `validTo` a write stamped) and the millisecond before + // each: the boundaries where `<` versus `<=` decides. + const instants = captures.flatMap((capture) => [ + capture.instant, + capture.instant - 1, + ]); + const sampled = new Set([ + ...captures + .filter((capture) => capture.endedWindow) + .map((capture) => capture.instant), + ...scenario.asOfPicks.flatMap((pick) => { + const instant = instants[pick % Math.max(instants.length, 1)]; + return instant === undefined ? [] : [instant]; + }), + ]); + for (const instant of sampled) { + const coordinate: Coordinate = { + label: `asOf ${new Date(instant).toISOString()}`, + instant, + nodeInstant: instant, + }; + const view = store.asOf(new Date(instant).toISOString()); + await expectReadsMatchModel(view, model, coordinate, scriptLabel); + + const capture = captures.find((entry) => entry.instant === instant); + const everyNodeVisible = [...model.nodes.values()].every((node) => + nodeVisible(node, instant), + ); + if (capture?.deletesSoFar !== deletes || !everyNodeVisible) continue; + for (const root of roots) { + const unit = await view.subgraph( + { kind: "PrRoot", id: idOf(root) } as never, + { edges: [], composition: true }, + ); + expect( + unit.nodes + .map((node) => keyOf(node)) + .filter((key) => key !== root) + .toSorted(), + `${scriptLabel} @ ${coordinate.label}: subgraph(${root}) vs the plan captured then`, + ).toEqual( + (capture.plans.get(root) ?? []) + .map((pair) => pair.slice(0, pair.indexOf("<"))) + .toSorted(), + ); + } + } + } finally { + vi.useRealTimers(); + } +} + +export function registerCompositionReadsModelIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Composition reads against a reference model (random forests)", () => { + it("parts(), wholes(), subgraph({ composition }) and planDelete agree with the model at every sampled coordinate", async () => { + await fc.assert( + fc.asyncProperty(scenarioArbitrary, async (scenario) => { + await runScenario(context, scenario); + }), + { numRuns: propertyRuns({ ci: 16, local: 24 }) }, + ); + }, 120_000); + }); +} diff --git a/packages/typegraph/tests/backends/integration/composition-tightening.ts b/packages/typegraph/tests/backends/integration/composition-tightening.ts new file mode 100644 index 000000000..38b6ff451 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/composition-tightening.ts @@ -0,0 +1,969 @@ +/** + * Data-validated tightening for a newly-declared composition pair, + * stacking on the schema-tightening preflight + * (`src/schema/tightening-preflight.ts`, `prepareSchemaTighteningPreflight`). + * + * Before composition constraints existed, `partOf`/`hasPart` classified as `safe` in both directions + * (shipped that way, pending composition's own constraints — see + * `src/schema/ontology-change.ts`'s module docblock). Adding the FIRST + * `partOf`/`hasPart` pair over two edge kinds that already exist as + * ordinary, independent cardinality-`one` edges is exactly the shape that + * can be dirty: nothing prevented one part from already holding a live edge + * under EACH kind before the pair existed to say that is now one whole too + * many. + * + * Each case states, in a comment, the mutation that must make it fail (the + * revert/mutation check load-bearing tests require). + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + MigrationError, + partOf, + subClassOf, +} from "../../../src"; +import { getActiveSchema, migrateSchema } from "../../../src/schema"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const CtChapter = defineNode("CtChapter", { schema: z.object({}) }); +const CtBook = defineNode("CtBook", { schema: z.object({}) }); +const CtAnthology = defineNode("CtAnthology", { schema: z.object({}) }); + +const ctChapterOf = defineEdge("ctChapterOf", { schema: z.object({}) }); +const ctIncludedIn = defineEdge("ctIncludedIn", { schema: z.object({}) }); + +// Fixture for flipping an already-declared pair's `existence`. +const CtSegment = defineNode("CtSegment", { schema: z.object({}) }); +const CtEpisode = defineNode("CtEpisode", { schema: z.object({}) }); +const ctSegmentOf = defineEdge("ctSegmentOf", { schema: z.object({}) }); + +function buildFlipGraph(id: string, required: boolean, declared = true) { + return defineGraph({ + id, + nodes: { CtSegment: { type: CtSegment }, CtEpisode: { type: CtEpisode } }, + edges: { + ctSegmentOf: { + type: ctSegmentOf, + from: [CtSegment], + to: [CtEpisode], + cardinality: "one", + }, + }, + ontology: + declared ? + [ + partOf(CtSegment, CtEpisode, { + via: ctSegmentOf, + ...(required ? { existence: "required" as const } : {}), + }), + ] + : [], + }); +} + +// Fixture for two DIFFERENT part kinds sharing one realizing edge kind, only +// one of which is tightened to `existence: "required"`. +const CtTag = defineNode("CtTag", { schema: z.object({}) }); +const CtClip = defineNode("CtClip", { schema: z.object({}) }); +const CtEpisode2 = defineNode("CtEpisode2", { schema: z.object({}) }); +const ctSharedOf = defineEdge("ctSharedOf", { schema: z.object({}) }); + +function buildSharedEdgeGraph(id: string, clipRequired: boolean) { + return defineGraph({ + id, + nodes: { + CtTag: { type: CtTag }, + CtClip: { type: CtClip }, + CtEpisode2: { type: CtEpisode2 }, + }, + edges: { + ctSharedOf: { + type: ctSharedOf, + from: [CtTag, CtClip], + to: [CtEpisode2], + cardinality: "one", + }, + }, + ontology: [ + // CtTag stays `existence: "optional"` across both versions — an + // unattached CtTag is legal forever and must never block a commit + // that only tightens its edge-kind sibling, CtClip. + partOf(CtTag, CtEpisode2, { via: ctSharedOf }), + partOf(CtClip, CtEpisode2, { + via: ctSharedOf, + ...(clipRequired ? { existence: "required" as const } : {}), + }), + ], + }); +} + +function buildGraph(id: string, composed: boolean) { + return defineGraph({ + id, + nodes: { + CtChapter: { type: CtChapter }, + CtBook: { type: CtBook }, + CtAnthology: { type: CtAnthology }, + }, + edges: { + ctChapterOf: { + type: ctChapterOf, + from: [CtChapter], + to: [CtBook], + cardinality: "one", + }, + ctIncludedIn: { + type: ctIncludedIn, + from: [CtChapter], + to: [CtAnthology], + cardinality: "one", + }, + }, + ...(composed ? + { + ontology: [ + partOf(CtChapter, CtBook, { via: ctChapterOf }), + partOf(CtChapter, CtAnthology, { via: ctIncludedIn }), + ], + } + : {}), + }); +} + +// Fixture for a kind that INHERITS a required-existence part obligation by +// becoming a subclass of the declared part kind. +const CtDraft = defineNode("CtDraft", { schema: z.object({}) }); + +function buildInheritedPartGraph(id: string, draftIsSegment: boolean) { + return defineGraph({ + id, + nodes: { + CtSegment: { type: CtSegment }, + CtEpisode: { type: CtEpisode }, + CtDraft: { type: CtDraft }, + }, + edges: { + ctSegmentOf: { + type: ctSegmentOf, + from: [CtSegment], + to: [CtEpisode], + cardinality: "one", + }, + }, + ontology: [ + partOf(CtSegment, CtEpisode, { + via: ctSegmentOf, + existence: "required", + }), + ...(draftIsSegment ? [subClassOf(CtDraft, CtSegment)] : []), + ], + }); +} + +// Fixture for a commit that widens the composition relation's population +// rather than adding a pair: two reflexive realizing edges over one kind. +const CtCell = defineNode("CtCell", { schema: z.object({}) }); +const ctCellUnder = defineEdge("ctCellUnder", { schema: z.object({}) }); +const ctCellIn = defineEdge("ctCellIn", { schema: z.object({}) }); + +function buildCellGraph( + id: string, + under: Readonly<{ cardinality: "one" | "oneActive"; acyclic: boolean }>, +) { + return defineGraph({ + id, + nodes: { CtCell: { type: CtCell } }, + edges: { + ctCellUnder: { + type: ctCellUnder, + from: [CtCell], + to: [CtCell], + cardinality: under.cardinality, + ...(under.acyclic ? { acyclic: true } : {}), + }, + // One part kind holds one composition population, so both realizing + // edges declare the same one. + ctCellIn: { + type: ctCellIn, + from: [CtCell], + to: [CtCell], + cardinality: under.cardinality, + }, + }, + ontology: [ + partOf(CtCell, CtCell, { via: ctCellUnder, partSide: "from" }), + partOf(CtCell, CtCell, { via: ctCellIn, partSide: "from" }), + ], + }); +} + +// Fixture for an edge kind dropped with its rows kept, then re-added as a +// composition pair's realizing edge. +const ctVolumeOf = defineEdge("ctVolumeOf", { schema: z.object({}) }); + +function buildReAddedVolumeGraph( + id: string, + state: "plain" | "dropped" | "composed", +) { + return defineGraph({ + id, + nodes: { CtChapter: { type: CtChapter }, CtBook: { type: CtBook } }, + edges: + state === "dropped" ? + {} + : { + ctVolumeOf: { + type: ctVolumeOf, + from: [CtChapter], + to: [CtBook], + cardinality: "one", + }, + }, + ...(state === "composed" ? + { ontology: [partOf(CtChapter, CtBook, { via: ctVolumeOf })] } + : {}), + }); +} + +/** A bound no test run reaches: later than any commit's clock. */ +const FUTURE_INSTANT = "2999-01-01T00:00:00.000Z"; + +function futureWindowViolations(error: unknown): readonly unknown[] { + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + return details.violations; +} + +/** + * `part` was under `whole` through `ctCellUnder` — that row is ended history, + * outside the composition relation — and `whole` now sits under `part` + * through `via`. Legal while `ctCellUnder` counts only its open rows. + */ +async function seedEndedCompositionCycle( + context: IntegrationTestContext, + id: string, + via: "ctCellUnder" | "ctCellIn", +): Promise { + const store = await context.createStore( + buildCellGraph(id, { cardinality: "oneActive", acyclic: false }), + ); + const part = await store.nodes.CtCell.create({}); + const whole = await store.nodes.CtCell.create({}); + await store.edges.ctCellUnder.create( + part, + whole, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + await requireDefined(store.getEdgeCollection(via)).create(whole, part, {}); + expect(await store.verifyConstraintFences()).toEqual([]); +} + +function expectCompositionCycleRefusal(error: unknown): void { + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toContainEqual( + expect.objectContaining({ + family: "edgeAcyclicity", + relation: "composition", + }), + ); +} + +async function activeVersion( + context: IntegrationTestContext, + id: string, +): Promise { + const active = await getActiveSchema(context.getBackend(), id); + return requireDefined(active, "active schema").version; +} + +export function registerCompositionTighteningIntegrationTests( + context: IntegrationTestContext, +): void { + describe("composition tightening (data-validated)", () => { + it("refuses declaring partOf/hasPart against a part that already has two live wholes", async () => { + const id = "composition_tightening_dirty"; + const store = await context.createStore(buildGraph(id, false)); + const chapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + const anthology = await store.nodes.CtAnthology.create({}); + // Both edges are independently valid under the UN-composed schema: + // each kind's own cardinality axis sees exactly one edge per chapter. + await store.edges.ctChapterOf.create(chapter, book, {}); + await store.edges.ctIncludedIn.create(chapter, anthology, {}); + + const error = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + const compositionViolation = details.violations.find( + (violation) => violation.family === "composition", + ); + expect(compositionViolation).toBeDefined(); + if (compositionViolation?.family !== "composition") { + throw new Error("expected a composition violation"); + } + expect(compositionViolation.edgeIds).toHaveLength(2); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: flip the `META_EDGE_PART_OF` / `META_EDGE_HAS_PART` + // arm in `classifyKnownRelationSeverity` + // (src/schema/ontology-change.ts) back to + // `{ severity: "safe", probeKinds: [] }` for `"added"`. The commit above + // then succeeds and `activeVersion` reads 2 instead of 1. + + it("migrates cleanly when the part has exactly one whole", async () => { + const id = "composition_tightening_clean"; + const store = await context.createStore(buildGraph(id, false)); + const chapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + await store.edges.ctChapterOf.create(chapter, book, {}); + + const version = await migrateSchema( + context.getBackend(), + buildGraph(id, true), + await activeVersion(context, id), + ); + expect(version).toBe(2); + + const [upgradedStore] = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ); + const anthology = await upgradedStore.nodes.CtAnthology.create({}); + await expect( + upgradedStore.edges.ctIncludedIn.create(chapter, anthology, {}), + ).rejects.toMatchObject({ code: "COMPOSITION_WHOLE_OCCUPIED" }); + }); + + it("refuses a bulk attach to a second whole when the incumbent predates the declaration", async () => { + const id = "composition_tightening_clean_bulk"; + const store = await context.createStore(buildGraph(id, false)); + const chapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + const incumbent = await store.edges.ctChapterOf.create(chapter, book, {}); + + await migrateSchema( + context.getBackend(), + buildGraph(id, true), + await activeVersion(context, id), + ); + const [upgradedStore] = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ); + const anthology = await upgradedStore.nodes.CtAnthology.create({}); + + // The incumbent was written before `partOf` existed, so it holds no + // composition claim row; only the batch claim's incumbent read sees it. + await expect( + upgradedStore.edges.ctIncludedIn.bulkCreate([ + { from: chapter, to: anthology }, + ]), + ).rejects.toMatchObject({ + code: "COMPOSITION_WHOLE_OCCUPIED", + details: { incumbentEdgeId: incumbent.id }, + }); + expect(await upgradedStore.edges.ctIncludedIn.findFrom(chapter)).toEqual( + [], + ); + }); + // MUTATION CHECK: make `readClaimlessIncumbents` + // (src/backend/drizzle/operation-backend-core.ts) return an empty map. + // The batch lock then inserts a fresh composition claim for the new edge, + // the bulk create commits, and the chapter holds two wholes. + + it("names the occupying edge when a single create attaches a second whole, claimed or not", async () => { + const id = "composition_tightening_incumbent_id"; + const store = await context.createStore(buildGraph(id, false)); + const unclaimedChapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + const unclaimed = await store.edges.ctChapterOf.create( + unclaimedChapter, + book, + {}, + ); + + await migrateSchema( + context.getBackend(), + buildGraph(id, true), + await activeVersion(context, id), + ); + const [upgradedStore] = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ); + const anthology = await upgradedStore.nodes.CtAnthology.create({}); + const claimedChapter = await upgradedStore.nodes.CtChapter.create({}); + const claimed = await upgradedStore.edges.ctChapterOf.create( + claimedChapter, + book, + {}, + ); + + // Written before `partOf`: the incumbent holds no composition claim + // row, so the guard's incumbent read is the only thing that names it. + await expect( + upgradedStore.edges.ctIncludedIn.create( + unclaimedChapter, + anthology, + {}, + ), + ).rejects.toMatchObject({ + code: "COMPOSITION_WHOLE_OCCUPIED", + details: { incumbentEdgeId: unclaimed.id }, + }); + await expect( + upgradedStore.edges.ctIncludedIn.create(claimedChapter, anthology, {}), + ).rejects.toMatchObject({ + code: "COMPOSITION_WHOLE_OCCUPIED", + details: { incumbentEdgeId: claimed.id }, + }); + }); + // MUTATION CHECK: in `claimEdgeCardinalityGuarded` + // (src/backend/drizzle/operation-backend-core.ts), report the lock's + // `holder_edge_id` instead of `incumbent_edge_id`. For the unclaimed + // incumbent the lock inserted the writing edge's own claim, so the + // refusal names the refused edge rather than `unclaimed.id`. + + it("names the same incumbent on every backend when several live edges occupy the axis", async () => { + const store = await context.createStore( + buildGraph("composition_tightening_incumbent_order", true), + ); + const chapter = await store.nodes.CtChapter.create({}); + const firstBook = await store.nodes.CtBook.create({}); + const secondBook = await store.nodes.CtBook.create({}); + const anthology = await store.nodes.CtAnthology.create({}); + // Beneath the store, so neither holds a claim row: only the incumbent + // reads see them. The ids differ only in case, the order a database + // collation and a code-point sort disagree on. + for (const [edgeId, book] of [ + ["a-ordered-incumbent", firstBook], + ["B-ordered-incumbent", secondBook], + ] as const) { + await store.backend.insertEdge({ + graphId: store.graphId, + id: edgeId, + kind: "ctChapterOf", + fromKind: "CtChapter", + fromId: chapter.id, + toKind: "CtBook", + toId: book.id, + props: {}, + }); + } + + await expect( + store.edges.ctIncludedIn.create(chapter, anthology, {}), + ).rejects.toMatchObject({ + code: "COMPOSITION_WHOLE_OCCUPIED", + details: { incumbentEdgeId: "B-ordered-incumbent" }, + }); + await expect( + store.edges.ctIncludedIn.bulkCreate([{ from: chapter, to: anthology }]), + ).rejects.toMatchObject({ + code: "COMPOSITION_WHOLE_OCCUPIED", + details: { incumbentEdgeId: "B-ordered-incumbent" }, + }); + }); + // MUTATION CHECK: drop `incumbentOrder` from the `ORDER BY` of + // `buildLockEdgeClaimGuarded` and `buildReadEdgeClaimIncumbents` + // (src/backend/drizzle/operations/edge-claims.ts). A PostgreSQL database + // whose collation sorts case-insensitively then names "a-ordered-incumbent". + + it('flipping an already-declared pair to existence: "required" refuses a DIRTY graph directly through ensureSchema, never reaching "breaking-change"', async () => { + const id = "composition_tightening_flip_required"; + const store = await context.createStore(buildFlipGraph(id, false)); + const orphan = await store.nodes.CtSegment.create({}); + + // An in-place `existence` flip classifies as a `modified` change + // (`classifyExistenceChange`, src/schema/ontology-change.ts) — never + // as remove + add — so it is backwards-compatible on its own and + // reaches `prepareSchemaTighteningPreflight` through the ordinary + // `ensureSchema`/`createAdapterStoreWithSchema` auto-migrate path, + // exactly like any other `warning`-severity tightening. No explicit + // `migrateSchema()` call is needed. + const error = await createAdapterStoreWithSchema( + buildFlipGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + const violation = details.violations.find( + (candidate) => candidate.family === "compositionExistence", + ); + expect(violation).toBeDefined(); + if (violation?.family !== "compositionExistence") { + throw new Error("expected a compositionExistence violation"); + } + expect(violation.partKind).toBe("CtSegment"); + expect(violation.parts).toEqual([{ kind: "CtSegment", id: orphan.id }]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: restore `relation.existence ?? ""` as a fifth element + // of `relationMapKey`'s tuple (src/schema/ontology-change.ts). The flip + // then diffs as remove (breaking) + add, `createAdapterStoreWithSchema` + // throws `MigrationError` reason `"breaking-change"` instead of + // `"ontology-tightening-violated"`, and the test above fails on the + // `details.reason` check before ever reaching the composition + // violation assertions. + + it('refuses declaring a brand-new existence: "required" pair over a part with no whole', async () => { + const id = "composition_tightening_new_required_pair"; + const store = await context.createStore(buildFlipGraph(id, false, false)); + const orphan = await store.nodes.CtSegment.create({}); + + const error = await createAdapterStoreWithSchema( + buildFlipGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toContainEqual( + expect.objectContaining({ + family: "compositionExistence", + partKind: "CtSegment", + parts: [{ kind: "CtSegment", id: orphan.id }], + }), + ); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): in `classifyKnownRelationSeverity` + // (src/schema/ontology-change.ts), probe an added `required` pair with + // `["composition"]` alone. The orphan then goes unchecked, the commit + // succeeds, and `activeVersion` reads 2. + + it('flipping an already-declared pair to existence: "required" auto-migrates a CLEAN graph directly through ensureSchema', async () => { + const id = "composition_tightening_flip_required_clean"; + const store = await context.createStore(buildFlipGraph(id, false)); + const segment = await store.nodes.CtSegment.create({}); + const episode = await store.nodes.CtEpisode.create({}); + await store.edges.ctSegmentOf.create(segment, episode, {}); + + const [upgradedStore] = await createAdapterStoreWithSchema( + buildFlipGraph(id, true), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await expect( + upgradedStore.nodes.CtSegment.create({}), + ).rejects.toMatchObject({ code: "COMPOSITION_WHOLE_REQUIRED" }); + }); + // MUTATION CHECK: change the `partOf`/`hasPart` `removed` arm in + // `classifyKnownRelationSeverity` from unconditional `breaking` to + // `safe`. `classifyExistenceChange` is a separate function so this + // mutation does not directly touch it, but it demonstrates the OLD + // fold-into-remove+add path would have reached this exact commit as a + // (previously refused) breaking change now silently accepted with no + // data probe at all; the test's `COMPOSITION_WHOLE_REQUIRED` + // assertion is the behavioral guarantee this test actually pins. + + it('loosening an already-declared pair from existence: "required" to optional is safe and auto-migrates through ensureSchema (a pure loosening is always safe)', async () => { + const id = "composition_tightening_flip_loosen"; + const store = await context.createStore(buildFlipGraph(id, true)); + const episode = await store.nodes.CtEpisode.create({}); + // `existence: "required"` refuses a bare create with no `partOf` — + // the episode must exist first, and the segment must name it. + await store.nodes.CtSegment.create( + {}, + { partOf: { whole: { kind: "CtEpisode", id: episode.id } } }, + ); + + // Loosening never needs a data probe — no `migrateSchema()` call, + // straight through `ensureSchema`/`createAdapterStoreWithSchema`. + const [upgradedStore] = await createAdapterStoreWithSchema( + buildFlipGraph(id, false), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + // What "loosened" means in practice: a part with no live whole is now + // legal, where it was refused a moment ago. + await expect( + upgradedStore.nodes.CtSegment.create({}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK: in `classifyExistenceChange` + // (src/schema/ontology-change.ts), flip the `afterExistence === + // "optional"` branch's severity from `"safe"` to `"breaking"`. + // `createAdapterStoreWithSchema` then throws `MigrationError` reason + // `"breaking-change"` instead of returning an upgraded store, and this + // test fails on the destructuring `const [upgradedStore] = await …`. + + it("tightening one part kind never blocks on an unrelated OPTIONAL sibling sharing its edge kind", async () => { + const id = "composition_tightening_shared_edge_kind"; + const store = await context.createStore(buildSharedEdgeGraph(id, false)); + // Legal forever: CtTag's existence stays "optional" in both versions. + await store.nodes.CtTag.create({}); + + const version = await migrateSchema( + context.getBackend(), + buildSharedEdgeGraph(id, true), + await activeVersion(context, id), + ); + expect(version).toBe(2); + + const [upgradedStore] = await createAdapterStoreWithSchema( + buildSharedEdgeGraph(id, true), + context.getBackend(), + ); + await expect(upgradedStore.nodes.CtClip.create({})).rejects.toThrow( + /required/i, + ); + }); + // MUTATION CHECK: in `readCompositionUnattachedPartsForEdgeKinds` + // (src/schema/tightening-preflight.ts), drop the + // `requiredPartKinds.has(concreteKind)` filter (scan every pair sharing + // the probe's edge kinds, not only the required ones). The unattached + // CtTag planted above then makes the migration refuse, and `migrateSchema` + // throws instead of returning `2`. + + it("refuses a subClassOf addition that makes live rows required parts with no whole", async () => { + const id = "composition_tightening_inherited_required"; + const store = await context.createStore( + buildInheritedPartGraph(id, false), + ); + const orphan = await store.nodes.CtDraft.create({}); + + const error = await createAdapterStoreWithSchema( + buildInheritedPartGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "compositionExistence", + partKind: "CtDraft", + parts: [{ kind: "CtDraft", id: orphan.id }], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: drop `"compositionExistence"` from the added + // `subClassOf`/`equivalentTo`/`sameAs` arm of + // `classifyKnownRelationSeverity` (src/schema/ontology-change.ts). The + // commit then migrates, and `createAdapterStoreWithSchema` resolves to a + // store instead of the `MigrationError` asserted above. + + it("refuses redeclaring a oneActive realizing edge `one` when its ended rows would close a composition cycle", async () => { + const id = "composition_tightening_population_widened"; + await seedEndedCompositionCycle(context, id, "ctCellUnder"); + + // MUTATION CHECK: probe the composition relation only when the commit + // adds a pair (drop the `compositionRelationWidened` arm of + // `acyclicRelationsToProbe`, src/schema/tightening-preflight.ts) — the + // commit then lands, its ended row now counts, and the audit reports the + // cycle it closes. + const error = await createAdapterStoreWithSchema( + buildCellGraph(id, { cardinality: "one", acyclic: false }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expectCompositionCycleRefusal(error); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses declaring `acyclic: true` on a composition realizing edge whose ended row closes a cycle through another realizing edge", async () => { + const id = "composition_tightening_acyclic_widened"; + // The cycle spans two realizing edges, so `ctCellUnder` alone holds + // none: only the composition relation it widens does. + await seedEndedCompositionCycle(context, id, "ctCellIn"); + + // MUTATION CHECK: probe a newly `acyclic: true` kind only as its own + // standalone relation (the composition relation left out of + // `acyclicRelationsToProbe`, src/schema/tightening-preflight.ts) — the + // commit then lands, and every later write probes the composition + // relation that already holds the cycle. + const error = await createAdapterStoreWithSchema( + buildCellGraph(id, { cardinality: "oneActive", acyclic: true }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expectCompositionCycleRefusal(error); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses redeclaring oneActive realizing edges `one` when a part's ended row under one edge kind and open row under another would make two wholes", async () => { + const id = "composition_tightening_population_widened_occupancy"; + const store = await context.createStore( + buildCellGraph(id, { cardinality: "oneActive", acyclic: false }), + ); + const part = await store.nodes.CtCell.create({}); + const formerWhole = await store.nodes.CtCell.create({}); + const currentWhole = await store.nodes.CtCell.create({}); + const endedEdge = await store.edges.ctCellUnder.create( + part, + formerWhole, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + const openEdge = await store.edges.ctCellIn.create( + part, + currentWhole, + {}, + ); + expect(await store.verifyConstraintFences()).toEqual([]); + + // MUTATION CHECK: audit composition occupancy only for the edge kinds + // of pairs the commit adds (drop the widened-membership arm of + // `compositionDeclarationsToAudit`, src/schema/tightening-preflight.ts). + // Each edge kind holds one row per part, so the per-kind cardinality + // probe stays clean, the commit lands, and the audit then reports the + // part holding two wholes. + const error = await createAdapterStoreWithSchema( + buildCellGraph(id, { cardinality: "one", acyclic: false }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + const compositionViolation = details.violations.find( + (violation) => violation.family === "composition", + ); + if (compositionViolation?.family !== "composition") { + throw new Error( + `expected a composition violation, got ${JSON.stringify(details.violations)}`, + ); + } + expect(compositionViolation.edgeIds.toSorted()).toEqual( + [endedEdge.id, openEdge.id].toSorted(), + ); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("auto-migrates the same redeclaration when the part's ended row is its only whole", async () => { + const id = "composition_tightening_population_widened_clean"; + const store = await context.createStore( + buildCellGraph(id, { cardinality: "oneActive", acyclic: false }), + ); + const part = await store.nodes.CtCell.create({}); + const formerWhole = await store.nodes.CtCell.create({}); + await store.edges.ctCellUnder.create( + part, + formerWhole, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + + const [upgradedStore, result] = await createAdapterStoreWithSchema( + buildCellGraph(id, { cardinality: "one", acyclic: false }), + context.getBackend(), + ); + + expect(result.status).toBe("migrated"); + expect(await upgradedStore.verifyConstraintFences()).toEqual([]); + }); + + it("refuses declaring partOf/hasPart over edge kinds whose stored rows start or end later than the commit", async () => { + const id = "composition_tightening_future_window"; + const store = await context.createStore(buildGraph(id, false)); + const endingChapter = await store.nodes.CtChapter.create({}); + const startingChapter = await store.nodes.CtChapter.create({}); + const endedChapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + const anthology = await store.nodes.CtAnthology.create({}); + // Legal while neither kind realizes a pair: only a composition edge is + // held to a window that is not in the future. + const futureEnd = await store.edges.ctChapterOf.create( + endingChapter, + book, + {}, + { validTo: FUTURE_INSTANT }, + ); + const futureStart = await store.edges.ctIncludedIn.create( + startingChapter, + anthology, + {}, + { validFrom: FUTURE_INSTANT }, + ); + // Ended in the past: settled history, which the pair may adopt. + await store.edges.ctChapterOf.create( + endedChapter, + book, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + + const error = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(futureWindowViolations(error)).toEqual([ + { + family: "compositionFutureWindow", + edgeKind: "ctChapterOf", + edgeIds: [futureEnd.id], + }, + { + family: "compositionFutureWindow", + edgeKind: "ctIncludedIn", + edgeIds: [futureStart.id], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + + // Resolving the rows is what lets the same commit through: a deleted + // row is no longer a composition row at all. + await store.edges.ctChapterOf.delete(futureEnd.id); + await store.edges.ctIncludedIn.delete(futureStart.id); + const [, result] = await createAdapterStoreWithSchema( + buildGraph(id, true), + context.getBackend(), + ); + expect(result.status).toBe("migrated"); + }); + // MUTATION CHECK: drop `futureWindowViolations` from the preflight's + // violation list in `prepareSchemaTighteningPreflight` + // (src/schema/tightening-preflight.ts). The first commit then lands with + // both rows as composition rows and `createAdapterStoreWithSchema` + // resolves instead of refusing. + + it("refuses re-adding an edge kind as a pair's realizing edge over rows a discarded drop left with a future window", async () => { + const id = "composition_tightening_future_window_readded"; + const store = await context.createStore( + buildReAddedVolumeGraph(id, "plain"), + ); + const chapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + const scheduled = await store.edges.ctVolumeOf.create( + chapter, + book, + {}, + { validTo: FUTURE_INSTANT }, + ); + await migrateSchema( + context.getBackend(), + buildReAddedVolumeGraph(id, "dropped"), + 1, + { discardDroppedKindRows: true }, + ); + + const error = await createAdapterStoreWithSchema( + buildReAddedVolumeGraph(id, "composed"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(futureWindowViolations(error)).toEqual([ + { + family: "compositionFutureWindow", + edgeKind: "ctVolumeOf", + edgeIds: [scheduled.id], + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: as above — the re-added kind arrives with its pair in + // the same commit, so the pair's `composition` probe is the only check + // that reads the left-behind row. + + it("verifyConstraintFences reports a composition row planted with a future window", async () => { + const id = "composition_tightening_future_window_audit"; + const store = await context.createStore(buildGraph(id, true)); + const chapter = await store.nodes.CtChapter.create({}); + const plantedChapter = await store.nodes.CtChapter.create({}); + const book = await store.nodes.CtBook.create({}); + await store.edges.ctChapterOf.create(chapter, book, {}); + // Beneath the store: no write path would accept this window. + await store.backend.insertEdge({ + graphId: store.graphId, + id: "ct-planted-future-end", + kind: "ctChapterOf", + fromKind: "CtChapter", + fromId: plantedChapter.id, + toKind: "CtBook", + toId: book.id, + props: {}, + validTo: FUTURE_INSTANT, + }); + + expect(await store.verifyConstraintFences()).toEqual([ + { + family: "compositionFutureWindow", + edgeKind: "ctChapterOf", + edgeIds: ["ct-planted-future-end"], + }, + ]); + }); + // MUTATION CHECK: drop the `compositionFutureWindow` read from + // `verifyConstraintFences` (src/store/claims/verify.ts). The audit then + // reports a clean graph. + + it("auto-migrates the same subClassOf addition when no row of the new part kind exists", async () => { + const id = "composition_tightening_inherited_required_clean"; + await context.createStore(buildInheritedPartGraph(id, false)); + + const [upgradedStore, result] = await createAdapterStoreWithSchema( + buildInheritedPartGraph(id, true), + context.getBackend(), + ); + + expect(result.status).toBe("migrated"); + await expect(upgradedStore.nodes.CtDraft.create({})).rejects.toThrow( + /required/i, + ); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/constraint-fence-errors.ts b/packages/typegraph/tests/backends/integration/constraint-fence-errors.ts index a561b9c88..c2f9027f6 100644 --- a/packages/typegraph/tests/backends/integration/constraint-fence-errors.ts +++ b/packages/typegraph/tests/backends/integration/constraint-fence-errors.ts @@ -177,13 +177,13 @@ function backendWithoutDisjointnessProbe( } /** - * The same backend with the CARDINALITY probe blinded: `countEdgesFrom` + * The same backend with the CARDINALITY probe blinded: `countEdgesAtEndpoint` * reports zero regardless of what already exists, so the create reaches its - * claim and the claim is what refuses. Only `countEdgesFrom` is hidden — the + * claim and the claim is what refuses. Only `countEdgesAtEndpoint` is hidden — the * `unique` cardinality's probe (`edgeExistsBetween`) is untouched, so this * fixture's `one` edge kind is the only one this blinds anything for. */ -const countEdgesFromZero: GraphBackend["countEdgesFrom"] = () => +const countEdgesAtEndpointZero: GraphBackend["countEdgesAtEndpoint"] = () => Promise.resolve(0); function backendWithoutCardinalityProbe(backend: GraphBackend): GraphBackend { @@ -191,13 +191,13 @@ function backendWithoutCardinalityProbe(backend: GraphBackend): GraphBackend { // frozen, and a decoration Proxy cannot shadow a non-configurable member. // `projectGraphBackend` is the audited way to get an unfrozen copy. return deriveBackend(projectGraphBackend(backend), { - countEdgesFrom: countEdgesFromZero, + countEdgesAtEndpoint: countEdgesAtEndpointZero, transaction: (run, options) => backend.transaction( (target) => run( deriveBackend(target, { - countEdgesFrom: countEdgesFromZero, + countEdgesAtEndpoint: countEdgesAtEndpointZero, }), ), options, @@ -365,11 +365,11 @@ export function registerConstraintFenceErrorIntegrationTests( }); it("reports the same cardinality refusal from the probe and from the claim", async () => { - // The edge-cardinality family's own refuser is `edgeClaimRefusal` - // (`edge-claims.ts`), and it shares its owners (`checkCardinality`, - // `checkUniqueEdge`) with the probe (`checkCardinalityConstraint`) — the - // same "one predicate, one owner" shape the uniqueness and disjointness - // cases above pin. This case pins it for cardinality too. + // The edge-cardinality family's own refuser is `edgeCardinalityViolation` + // (`edge-claims.ts`), and both the probe (`checkEdgeCardinalityConstraints`) + // and the fence's claim-takeover path share that one owner — the same + // "one predicate, one owner" shape the uniqueness and disjointness cases + // above pin. This case pins it for cardinality too. const store = await context.createStore(errorGraph); const source = await store.nodes.FenceGhost.create({ email: "reaper@identity.example", diff --git a/packages/typegraph/tests/backends/integration/constraint-fence-verification.ts b/packages/typegraph/tests/backends/integration/constraint-fence-verification.ts index 178cdf89e..78331ca99 100644 --- a/packages/typegraph/tests/backends/integration/constraint-fence-verification.ts +++ b/packages/typegraph/tests/backends/integration/constraint-fence-verification.ts @@ -18,11 +18,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { + asNodeId, defineEdge, defineGraph, defineGraphExtension, defineNode, disjointWith, + partOf, subClassOf, } from "../../../src"; import { computeUniqueKey } from "../../../src/constraints"; @@ -32,6 +34,8 @@ import { disjointnessClaimAxis, uniquenessClaimAxis, } from "../../../src/store/claims/axis"; +import { compositionEdgeCardinalityDeclarations } from "../../../src/store/claims/composition-claims"; +import { compareStrings } from "../../../src/utils/compare"; import { requireDefined } from "../../../src/utils/presence"; import { encodeTupleKey } from "../../../src/utils/tuple-key"; import { type IntegrationTestContext } from "./test-context"; @@ -65,6 +69,10 @@ const verifyManages = defineEdge("verifyManages", { schema: z.object({}), }); +const verifyAssignedTo = defineEdge("verifyAssignedTo", { + schema: z.object({}), +}); + /** * One graph carrying all three families: a scope spanning a hierarchy, a * declared disjoint pair, and a `cardinality: "one"` edge. @@ -85,6 +93,15 @@ const verifyGraph = defineGraph({ to: [VerifyProject], cardinality: "one", }, + // Both axes, so one graph exercises target-side reporting AND + // independent per-axis reporting on the same edge kind. + verifyAssignedTo: { + type: verifyAssignedTo, + from: [VerifyEmployee], + to: [VerifyProject], + cardinality: "one", + targetCardinality: "one", + }, }, ontology: [ subClassOf(VerifyEmployee, VerifyWorker), @@ -106,6 +123,55 @@ function emailKey(email: string): string { return computeUniqueKey({ email }, ["email"], "binary"); } +const ROW_CONTRACT_EMAIL_CONSTRAINT = "row_contract_email"; + +const RcWorker = defineNode("RcWorker", { + schema: z.object({ email: z.string() }), +}); +const RcWhole = defineNode("RcWhole", { schema: z.object({}) }); +const RcPart = defineNode("RcPart", { schema: z.object({}) }); +const rcAssigned = defineEdge("rcAssigned", { schema: z.object({}) }); +const rcPartOf = defineEdge("rcPartOf", { schema: z.object({}) }); + +/** + * One family per audit statement whose peer filter `verifyConstraintFences` + * cannot observe: a `kind`-scoped unique, an ordinary `oneActive` axis, and a + * composition axis. + */ +const rowContractGraph = defineGraph({ + id: "constraint_fence_row_contract", + nodes: { + RcWorker: { + type: RcWorker, + unique: [ + { + name: ROW_CONTRACT_EMAIL_CONSTRAINT, + fields: ["email"], + scope: "kind", + collation: "binary", + }, + ], + }, + RcWhole: { type: RcWhole }, + RcPart: { type: RcPart }, + }, + edges: { + rcAssigned: { + type: rcAssigned, + from: [RcWorker], + to: [RcWhole], + cardinality: "oneActive", + }, + rcPartOf: { + type: rcPartOf, + from: [RcPart], + to: [RcWhole], + cardinality: "one", + }, + }, + ontology: [partOf(RcPart, RcWhole, { via: rcPartOf })], +}); + /** Cross-backend fence diagnostics. */ export function registerConstraintFenceVerificationIntegrationTests( context: IntegrationTestContext, @@ -161,10 +227,10 @@ export function registerConstraintFenceVerificationIntegrationTests( owners: [ { concreteKind: "VerifyContractor", nodeId: contractor.id }, { concreteKind: "VerifyEmployee", nodeId: employee.id }, - ].toSorted((left, right) => - left.concreteKind === right.concreteKind ? - left.nodeId.localeCompare(right.nodeId) - : left.concreteKind.localeCompare(right.concreteKind), + ].toSorted( + (left, right) => + compareStrings(left.concreteKind, right.concreteKind) || + compareStrings(left.nodeId, right.nodeId), ), }, ]); @@ -258,21 +324,180 @@ export function registerConstraintFenceVerificationIntegrationTests( const violations = await store.verifyConstraintFences(); expect(violations).toHaveLength(1); const violation = requireDefined(violations[0], "cardinality violation"); + // Narrowed by assertion rather than by a ternary, so a report that named + // the wrong family fails here instead of comparing an empty list. + if (violation.family !== "edgeCardinality") + throw new Error(`expected an edge violation, got ${violation.family}`); expect(violation.target).toEqual({ relation: "edgeClaims", graphId: verifyGraph.id, axis: "one:verifyManages", key: encodeTupleKey(["VerifyEmployee", employee.id]), }); - // Narrowed by assertion rather than by a ternary, so a report that named - // the wrong family fails here instead of comparing an empty list. - if (violation.family !== "edgeCardinality") - throw new Error(`expected an edge violation, got ${violation.family}`); expect(violation.edgeIds).toEqual( [edge.id, "verify-unfenced-edge"].toSorted(), ); }); + it("reports an ended and a future-dated edge contending one cardinality:one axis", async () => { + const store = await context.createStore(verifyGraph); + const employee = await store.nodes.VerifyEmployee.create({ + email: "windows@example.com", + }); + const first = await store.nodes.VerifyProject.create({ title: "First" }); + const second = await store.nodes.VerifyProject.create({ + title: "Second", + }); + + // A `one` axis counts a row for its whole life: an ended edge is what an + // `asOf` read inside its window returns, and a future-dated one becomes + // current once the clock reaches it. Neither is current today, so an + // audit narrowed to the current window would report a clean axis. + const planted = [ + { + id: "verify-ended-edge", + toId: first.id, + validFrom: "2000-01-01T00:00:00.000Z", + validTo: "2001-01-01T00:00:00.000Z", + }, + { + id: "verify-future-edge", + toId: second.id, + validFrom: "2999-01-01T00:00:00.000Z", + }, + ]; + for (const edge of planted) { + await store.backend.insertEdge({ + graphId: verifyGraph.id, + kind: "verifyManages", + fromKind: "VerifyEmployee", + fromId: employee.id, + toKind: "VerifyProject", + props: {}, + ...edge, + }); + } + + expect(await store.verifyConstraintFences()).toEqual([ + { + family: "edgeCardinality", + target: { + relation: "edgeClaims", + graphId: verifyGraph.id, + axis: "one:verifyManages", + key: encodeTupleKey(["VerifyEmployee", employee.id]), + }, + edgeIds: ["verify-ended-edge", "verify-future-edge"], + }, + ]); + }); + + it("reports a target-side violation with the target-axis ClaimTarget (issue #610)", async () => { + const store = await context.createStore(verifyGraph); + const first = await store.nodes.VerifyEmployee.create({ + email: "target-fence-1@example.com", + }); + const second = await store.nodes.VerifyEmployee.create({ + email: "target-fence-2@example.com", + }); + const project = await store.nodes.VerifyProject.create({ + title: "Shared", + }); + const edge = await store.edges.verifyAssignedTo.create( + first, + project, + {}, + ); + + // Second employee assigned to the SAME project, written unfenced + // (pre-upgrade shape): the target axis is now contended. + await store.backend.insertEdge({ + graphId: verifyGraph.id, + id: "verify-target-unfenced-edge", + kind: "verifyAssignedTo", + fromKind: "VerifyEmployee", + fromId: second.id, + toKind: "VerifyProject", + toId: project.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const targetViolations = violations.filter( + (candidate) => + candidate.family === "edgeCardinality" && + candidate.target.axis !== "one:verifyAssignedTo", + ); + expect(targetViolations).toHaveLength(1); + const violation = requireDefined(targetViolations[0]); + if (violation.family !== "edgeCardinality") { + throw new Error(`expected an edge violation, got ${violation.family}`); + } + expect(violation.target).toEqual({ + relation: "edgeClaims", + graphId: verifyGraph.id, + axis: "\u001Eto\u001Eone:verifyAssignedTo", + key: encodeTupleKey(["VerifyProject", project.id]), + }); + expect(violation.edgeIds).toEqual( + [edge.id, "verify-target-unfenced-edge"].toSorted(), + ); + }); + // MUTATION CHECK (verified): have `fenceDeclarations` + // (`src/store/claims/verify.ts`) emit source refs only (drop the + // `edgeCardinalityAxisReferences` fold in favor of the source-only + // single-cardinality read). The target-side violation this test asserts + // goes unreported and `targetViolations` reads `[]`. + + it("reports both axes independently when an edge kind declares both", async () => { + const store = await context.createStore(verifyGraph); + const employee = await store.nodes.VerifyEmployee.create({ + email: "both-axes@example.com", + }); + const otherEmployee = await store.nodes.VerifyEmployee.create({ + email: "both-axes-2@example.com", + }); + const project = await store.nodes.VerifyProject.create({ + title: "BothAxes", + }); + const otherProject = await store.nodes.VerifyProject.create({ + title: "BothAxesOther", + }); + await store.edges.verifyAssignedTo.create(employee, project, {}); + + // Contend the SOURCE axis (employee already has one) and the TARGET + // axis (project already has one), written unfenced. + await store.backend.insertEdge({ + graphId: verifyGraph.id, + id: "verify-both-source-unfenced", + kind: "verifyAssignedTo", + fromKind: "VerifyEmployee", + fromId: employee.id, + toKind: "VerifyProject", + toId: otherProject.id, + props: {}, + }); + await store.backend.insertEdge({ + graphId: verifyGraph.id, + id: "verify-both-target-unfenced", + kind: "verifyAssignedTo", + fromKind: "VerifyEmployee", + fromId: otherEmployee.id, + toKind: "VerifyProject", + toId: project.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const assignedToViolations = violations.filter( + (candidate) => + candidate.family === "edgeCardinality" && + (candidate.target.axis === "one:verifyAssignedTo" || + candidate.target.axis === "\u001Eto\u001Eone:verifyAssignedTo"), + ); + expect(assignedToViolations).toHaveLength(2); + }); + it("does not report a soft-deleted second edge on the same axis", async () => { const store = await context.createStore(verifyGraph); const employee = await store.nodes.VerifyEmployee.create({ @@ -305,6 +530,289 @@ export function registerConstraintFenceVerificationIntegrationTests( expect(await store.verifyConstraintFences()).toEqual([]); }); + it("reports a live edge sitting outside every declared endpoint pair", async () => { + const store = await context.createStore(verifyGraph); + const contractor = await store.nodes.VerifyContractor.create({ + email: "grace@example.com", + }); + const project = await store.nodes.VerifyProject.create({ + title: "Fences", + }); + + // `verifyManages` only ever declares `from: [VerifyEmployee]`, so a + // `VerifyContractor` source is a pre-upgrade shape no live claim + // fences: the edges primary key is `(graph, id)`, so this insert is + // legal and holds no cardinality claim against it. + await store.backend.insertEdge({ + graphId: verifyGraph.id, + id: "verify-misassigned-edge", + kind: "verifyManages", + fromKind: "VerifyContractor", + fromId: contractor.id, + toKind: "VerifyProject", + toId: project.id, + props: {}, + }); + + expect(await store.verifyConstraintFences()).toEqual([ + { + family: "edgeEndpointAssignability", + edgeKind: "verifyManages", + allowedPairs: [["VerifyEmployee", "VerifyProject"]], + edges: [ + { + edgeKind: "verifyManages", + edgeId: "verify-misassigned-edge", + fromKind: "VerifyContractor", + fromId: contractor.id, + toKind: "VerifyProject", + toId: project.id, + }, + ], + }, + ]); + }); + + it("reports misassigned edges whose valid-time window already closed or has not opened", async () => { + const store = await context.createStore(verifyGraph); + const contractor = await store.nodes.VerifyContractor.create({ + email: "outside-window@example.com", + }); + const project = await store.nodes.VerifyProject.create({ + title: "Outside window", + }); + + // Neither row is current, but the write path admits and fences both: + // an `asOf` read inside the closed window returns the first, and the + // second becomes current once the clock reaches it. So both sit on a + // pair the declaration does not admit. + const misassigned = { + graphId: verifyGraph.id, + kind: "verifyManages", + fromKind: "VerifyContractor", + fromId: contractor.id, + toKind: "VerifyProject", + toId: project.id, + props: {}, + } as const; + await store.backend.insertEdge({ + ...misassigned, + id: "verify-misassigned-edge-closed-window", + validFrom: "2019-01-01T00:00:00.000Z", + validTo: "2020-01-01T00:00:00.000Z", + }); + await store.backend.insertEdge({ + ...misassigned, + id: "verify-misassigned-edge-pending-window", + validFrom: "2999-01-01T00:00:00.000Z", + }); + + // Two edges between the same endpoints also contend for + // `verifyManages`'s cardinality axis; this case asserts only the + // endpoint family. + const violations = await store.verifyConstraintFences(); + const report = violations.find( + (violation) => violation.family === "edgeEndpointAssignability", + ); + if (report?.family !== "edgeEndpointAssignability") { + throw new Error("expected an edgeEndpointAssignability violation"); + } + expect(report.edges.map((edge) => edge.edgeId).toSorted()).toEqual([ + "verify-misassigned-edge-closed-window", + "verify-misassigned-edge-pending-window", + ]); + }); + // MUTATION CHECK (verified): reading the endpoint family through the + // current-window predicate instead of `auditedRowTerm` in + // `buildMisassignedEdgeEndpointAudit` hides both rows and this test fails. + + it("reports a misassigned edge with a bounded FUTURE valid-time window", async () => { + const store = await context.createStore(verifyGraph); + const contractor = await store.nodes.VerifyContractor.create({ + email: "future-window@example.com", + }); + const project = await store.nodes.VerifyProject.create({ + title: "Future window", + }); + + // The window is CURRENTLY open (started in the past, ends far in the + // future) — exactly what an ordinary current-coordinate read returns + // today. A predicate that excludes anything but `valid_to IS NULL` + // would miss this row entirely, which is the bug this case guards. + await store.backend.insertEdge({ + graphId: verifyGraph.id, + id: "verify-misassigned-edge-future-window", + kind: "verifyManages", + fromKind: "VerifyContractor", + fromId: contractor.id, + toKind: "VerifyProject", + toId: project.id, + props: {}, + validFrom: "2019-01-01T00:00:00.000Z", + validTo: "2999-01-01T00:00:00.000Z", + }); + + expect(await store.verifyConstraintFences()).toEqual([ + { + family: "edgeEndpointAssignability", + edgeKind: "verifyManages", + allowedPairs: [["VerifyEmployee", "VerifyProject"]], + edges: [ + { + edgeKind: "verifyManages", + edgeId: "verify-misassigned-edge-future-window", + fromKind: "VerifyContractor", + fromId: contractor.id, + toKind: "VerifyProject", + toId: project.id, + }, + ], + }, + ]); + }); + // MUTATION CHECK (verified): a `valid_to IS NULL` liveness predicate makes + // this bounded-window row invisible to the audit and this test fails. + + it( + "audits a wide subclass hierarchy without exceeding SQLite's " + + "expression-tree depth or a connection's bind-parameter budget", + async () => { + // (141)^2 = 19,881 admitted pairs after subsumption expansion: past + // SQLite's `SQLITE_MAX_EXPR_DEPTH` break (an `OR`-chain rendering of + // the allowance breaks at ~1,000-1,700 pairs — a root with ~31-40 + // direct subclasses) AND past a single statement's bind-parameter + // budget (32,766 modern SQLite / 32,767 Postgres), so this width + // forces the real multi-statement, intersected-chunk path this audit + // now takes — not merely a bigger single query. + const SUBCLASS_COUNT = 140; + const WideRoot = defineNode("WideRoot", { schema: z.object({}) }); + const WideAlien = defineNode("WideAlien", { schema: z.object({}) }); + const subclasses = Array.from({ length: SUBCLASS_COUNT }, (_, index) => + defineNode(`WideSub${index}`, { schema: z.object({}) }), + ); + const wideRelationship = defineEdge("wideRelationship", { + schema: z.object({}), + }); + const wideGraph = defineGraph({ + id: "constraint_fence_wide_hierarchy", + nodes: { + WideRoot: { type: WideRoot }, + WideAlien: { type: WideAlien }, + ...Object.fromEntries( + subclasses.map((sub, index) => [ + `WideSub${index}`, + { type: sub }, + ]), + ), + }, + edges: { + wideRelationship: { + type: wideRelationship, + from: [WideRoot], + to: [WideRoot], + }, + }, + ontology: subclasses.map((sub) => subClassOf(sub, WideRoot)), + }); + + const store = await context.createStore(wideGraph); + + // Every node, including the two leaves and the misassigned edge's + // endpoints, is written through the backend directly: the dynamically + // built `nodes` map has no per-kind literal keys for the typed + // `store.nodes.` collections to resolve at compile time, and + // (for the leaves) the live `subClassOf` relation that admits them as + // `wideRelationship` endpoints is itself something only the backend's untyped + // insert can express — exactly like the endpoint-shrink cases + // elsewhere in this suite. + await store.backend.insertNode({ + graphId: wideGraph.id, + kind: "WideSub0", + id: "wide-leaf-a", + props: {}, + }); + await store.backend.insertNode({ + graphId: wideGraph.id, + kind: "WideSub1", + id: "wide-leaf-b", + props: {}, + }); + // A legitimately admitted edge between two leaves. It must NOT + // appear in the report. + await store.backend.insertEdge({ + graphId: wideGraph.id, + id: "wide-admitted-edge", + kind: "wideRelationship", + fromKind: "WideSub0", + fromId: "wide-leaf-a", + toKind: "WideSub1", + toId: "wide-leaf-b", + props: {}, + }); + + // A genuinely misassigned edge: `WideAlien` is outside the + // hierarchy entirely, so this row sits outside every one of the + // (141)^2 admitted pairs and must be the only violation reported. + await store.backend.insertNode({ + graphId: wideGraph.id, + kind: "WideAlien", + id: "wide-alien", + props: {}, + }); + await store.backend.insertNode({ + graphId: wideGraph.id, + kind: "WideRoot", + id: "wide-root", + props: {}, + }); + await store.backend.insertEdge({ + graphId: wideGraph.id, + id: "wide-misassigned-edge", + kind: "wideRelationship", + fromKind: "WideAlien", + fromId: "wide-alien", + toKind: "WideRoot", + toId: "wide-root", + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + expect(violations).toHaveLength(1); + const violation = requireDefined( + violations[0], + "wide-hierarchy violation", + ); + if (violation.family !== "edgeEndpointAssignability") { + throw new Error( + `expected edgeEndpointAssignability, got ${violation.family}`, + ); + } + expect(violation.edgeKind).toBe("wideRelationship"); + expect(violation.edges).toEqual([ + { + edgeKind: "wideRelationship", + edgeId: "wide-misassigned-edge", + fromKind: "WideAlien", + fromId: "wide-alien", + toKind: "WideRoot", + toId: "wide-root", + }, + ]); + // Root paired with every kind (itself plus its 140 subclasses), + // squared — proving the full allowance was expanded and read, not + // silently truncated by the chunking this width forces. + expect(violation.allowedPairs).toHaveLength((SUBCLASS_COUNT + 1) ** 2); + expect(violation.allowedPairs).toContainEqual(["WideRoot", "WideRoot"]); + expect(violation.allowedPairs).toContainEqual(["WideSub0", "WideSub1"]); + }, + 30_000, + ); + // MUTATION CHECK: reverting + // `buildMisassignedEdgeEndpointAudit` to render the admitted pairs as an + // `OR`-chain of bound equalities (rather than a `VALUES`-joined + // `NOT EXISTS`) throws `SqliteError: Expression tree is too large` on + // this case's SQLite lane. + it("audits declarations added after this Store became stale", async () => { const staleStore = await context.createStore(verifyGraph); const evolved = await staleStore.evolve( @@ -356,5 +864,157 @@ export function registerConstraintFenceVerificationIntegrationTests( }, ]); }); + + // The backend member returns contending ROWS and `verifyConstraintFences` + // keeps only axes with two or more holders, so a peer filter that admits + // a dead peer (tombstoned, ended on an active-only axis, or the row + // itself) changes the rows returned but never the report. This pins the + // member's own contract: a row whose only peer is dead is not contending. + it("readConstraintFenceViolations returns only rows with a live peer on their axis", async () => { + const store = await context.createStore(rowContractGraph); + const backend = store.backend; + const graphId = rowContractGraph.id; + const readViolations = requireDefined( + backend.readConstraintFenceViolations, + "backend.readConstraintFenceViolations", + ); + const wholeA = await store.nodes.RcWhole.create({}, { id: "whole-a" }); + const wholeB = await store.nodes.RcWhole.create({}, { id: "whole-b" }); + + // Uniqueness: "solo" shares its key only with a TOMBSTONED row; "dup" + // shares it with a live row at another axis. + await store.nodes.RcWorker.create( + { email: "solo@example.com" }, + { id: "solo" }, + ); + const dup = await store.nodes.RcWorker.create( + { email: "dup@example.com" }, + { id: "dup" }, + ); + const plantedUniques = [ + { email: "solo@example.com", nodeId: "ghost" }, + { email: "dup@example.com", nodeId: "intruder" }, + ]; + for (const planted of plantedUniques) { + await backend.insertUnique({ + graphId, + nodeKind: "RcWhole", + constraintName: ROW_CONTRACT_EMAIL_CONSTRAINT, + key: computeUniqueKey({ email: planted.email }, ["email"], "binary"), + nodeId: planted.nodeId, + concreteKind: "RcWhole", + }); + } + await backend.deleteUnique({ + graphId, + constraintName: ROW_CONTRACT_EMAIL_CONSTRAINT, + key: computeUniqueKey( + { email: "solo@example.com" }, + ["email"], + "binary", + ), + concreteKind: "RcWhole", + nodeId: "ghost", + }); + + // `oneActive`: "solo"'s only peer is ENDED; "dup" has two active edges. + await store.edges.rcAssigned.create( + { kind: "RcWorker", id: asNodeId("solo") }, + wholeA, + {}, + { id: "assigned-solo-active" }, + ); + await store.edges.rcAssigned.create( + dup, + wholeA, + {}, + { + id: "assigned-dup-first", + }, + ); + const plantedAssignments = [ + { + id: "assigned-solo-ended", + fromId: "solo", + validFrom: "2000-01-01T00:00:00.000Z", + validTo: "2001-01-01T00:00:00.000Z", + }, + { id: "assigned-dup-second", fromId: "dup" }, + ]; + for (const planted of plantedAssignments) { + await backend.insertEdge({ + graphId, + kind: "rcAssigned", + fromKind: "RcWorker", + toKind: "RcWhole", + toId: wholeB.id, + props: {}, + ...planted, + }); + } + + // Composition: "lone" has one whole (its only candidate peer is + // itself), "tombstoned" has a second whole that was soft-deleted, and + // "contended" has two live wholes. + for (const partId of ["lone", "tombstoned", "contended"]) { + const part = await store.nodes.RcPart.create({}, { id: partId }); + await store.edges.rcPartOf.create( + part, + wholeA, + {}, + { + id: `part-${partId}-first`, + }, + ); + } + for (const partId of ["tombstoned", "contended"]) { + await backend.insertEdge({ + graphId, + id: `part-${partId}-second`, + kind: "rcPartOf", + fromKind: "RcPart", + fromId: partId, + toKind: "RcWhole", + toId: wholeB.id, + props: {}, + }); + } + await backend.deleteEdge({ + graphId, + id: "part-tombstoned-second", + kind: "rcPartOf", + }); + + const rows = await readViolations({ + graphId, + uniqueConstraintNames: [ROW_CONTRACT_EMAIL_CONSTRAINT], + disjointKindPairs: [], + edgeCardinalities: [ + { + direction: "source", + cardinality: "oneActive", + edgeKind: "rcAssigned", + }, + ...compositionEdgeCardinalityDeclarations( + buildKindRegistry(rowContractGraph), + ), + ], + }); + + expect( + rows.contendedUniqueRows + .map((row) => `${row.concreteKind}/${row.nodeId}`) + .toSorted(), + ).toEqual(["RcWhole/intruder", "RcWorker/dup"]); + expect( + rows.contendedEdgeRows.map((row) => row.edgeId).toSorted(), + ).toEqual([ + "assigned-dup-first", + "assigned-dup-second", + "part-contended-first", + "part-contended-second", + ]); + expect(rows.disjointOverlaps).toEqual([]); + }); }); } diff --git a/packages/typegraph/tests/backends/integration/durable-edge-match-identity.ts b/packages/typegraph/tests/backends/integration/durable-edge-match-identity.ts index 312b30b58..f1b1d77c3 100644 --- a/packages/typegraph/tests/backends/integration/durable-edge-match-identity.ts +++ b/packages/typegraph/tests/backends/integration/durable-edge-match-identity.ts @@ -17,7 +17,10 @@ const knows = defineEdge("knows", { schema: z.object({ label: z.string(), note: z.string().optional() }), }); -function durableIdentityGraph(id: string) { +function durableIdentityGraph( + id: string, + options: Readonly<{ cardinality?: "one" }> = {}, +) { return defineGraph({ id, nodes: { Person: { type: Person } }, @@ -26,6 +29,7 @@ function durableIdentityGraph(id: string) { type: knows, from: [Person], to: [Person], + ...options, matchIdentity: { name: "knows-label", fields: ["label"] }, }, }, @@ -63,6 +67,34 @@ export function registerDurableEdgeMatchIdentityIntegrationTests( await expect(store.edges.knows.find()).resolves.toHaveLength(1); }); + it("refuses a constrained direct create before writing, so a caught refusal leaves no second edge", async () => { + const store = await context.createStore( + durableIdentityGraph("durable_identity_cardinality_refusal", { + cardinality: "one", + }), + ); + const source = await store.nodes.Person.create({ name: "Source" }); + const first = await store.nodes.Person.create({ name: "First" }); + const second = await store.nodes.Person.create({ name: "Second" }); + const incumbent = await store.edges.knows.create(source, first, { + label: "first", + }); + + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await tx.edges.knows + .create(source, second, { label: "second" }) + .catch((error: unknown) => refusals.push(error)); + }); + + expect(refusals).toEqual([ + expect.objectContaining({ name: "CardinalityError" }), + ]); + const stored = await store.edges.knows.find(); + expect(stored.map((edge) => edge.id)).toEqual([incumbent.id]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + it("returns the durable incumbent without creating a duplicate", async () => { const store = await context.createStore( durableIdentityGraph("durable_identity_convergence"), diff --git a/packages/typegraph/tests/backends/integration/edge-acyclicity.ts b/packages/typegraph/tests/backends/integration/edge-acyclicity.ts new file mode 100644 index 000000000..0a853a19f --- /dev/null +++ b/packages/typegraph/tests/backends/integration/edge-acyclicity.ts @@ -0,0 +1,528 @@ +/** + * `acyclic: true` on an edge registration. + * + * Query-and-constraint semantics for the acyclicity fence, run on every + * backend through the shared `integrationTestGraph` fixtures `dependsOn` + * (`cardinality: "many", acyclic: true`) and `blockedBy` (`acyclic: true`, + * default cardinality), both `Task -> Task`. + * + * Every case names the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; + +import { createStore, EdgeAcyclicityError } from "../../../src"; +import { MAX_EXPLICIT_RECURSIVE_DEPTH } from "../../../src/query/compiler/recursive"; +import { edgeWriteNeedsConstraintFence } from "../../../src/store/constraints"; +import { compareStrings } from "../../../src/utils/compare"; +import { requireDefined } from "../../../src/utils/presence"; +import { matchingObject } from "../../test-utils"; +import { withBindBudget } from "./bind-budget"; +import { integrationTestGraph } from "./fixtures"; +import { type IntegrationTestContext } from "./test-context"; + +type AcyclicIntegrationStore = ReturnType; + +/** `b → a` stored then soft-deleted: re-admitting it closes a cycle with `a → b`. */ +async function tombstonedBackEdge(store: AcyclicIntegrationStore) { + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const c = await store.nodes.Task.create({ name: "c" }); + const back = await store.edges.dependsOn.create(b, a); + await store.edges.dependsOn.delete(back.id); + return { store, a, b, c, back }; +} + +/** `a → b` live, `b → c` and `c → a` tombstoned: only both together close a cycle. */ +async function resurrectionsClosingACycleTogether( + store: AcyclicIntegrationStore, +) { + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const c = await store.nodes.Task.create({ name: "c" }); + const bToC = await store.edges.dependsOn.create(b, c); + const cToA = await store.edges.dependsOn.create(c, a); + await store.edges.dependsOn.delete(bToC.id); + await store.edges.dependsOn.delete(cToA.id); + await store.edges.dependsOn.create(a, b); + return { store, a, b, c, bToC, cToA }; +} + +async function refusalNamesWithin( + store: AcyclicIntegrationStore, + write: ( + edges: AcyclicIntegrationStore["edges"]["dependsOn"], + ) => Promise, +): Promise { + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await write(tx.edges.dependsOn as never).catch((error: unknown) => + refusals.push(error instanceof Error ? error.name : error), + ); + }); + return refusals; +} + +async function storedEndpoints( + store: AcyclicIntegrationStore, +): Promise { + const edges = await store.edges.dependsOn.find({}); + return edges + .map((edge) => `${edge.fromId}>${edge.toId}`) + .toSorted(compareStrings); +} + +export function registerEdgeAcyclicityIntegrationTests( + context: IntegrationTestContext, +): void { + describe("edge acyclicity", () => { + it("refuses a direct two-cycle", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + await store.edges.dependsOn.create(a, b); + + await expect(store.edges.dependsOn.create(b, a)).rejects.toThrow( + expect.objectContaining({ + name: "EdgeAcyclicityError", + details: matchingObject({ + edgeKind: "dependsOn", + fromId: b.id, + toId: a.id, + selfLoop: false, + }), + }), + ); + }); + + it("refuses a self-loop as a cycle of length one", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + + await expect(store.edges.dependsOn.create(a, a)).rejects.toThrow( + expect.objectContaining({ + name: "EdgeAcyclicityError", + details: matchingObject({ selfLoop: true }), + }), + ); + }); + + it("refuses a cycle longer than MAX_EXPLICIT_RECURSIVE_DEPTH, proving the check is not the bounded query path", async () => { + const store = context.getStore(); + const chainLength = MAX_EXPLICIT_RECURSIVE_DEPTH + 200; + const tasks = await store.nodes.Task.bulkCreate( + Array.from({ length: chainLength + 1 }, (_unused, index) => ({ + props: { name: `t${String(index)}` }, + })), + ); + + await store.edges.dependsOn.bulkCreate( + tasks.slice(0, -1).map((task, index) => ({ + from: task, + to: requireDefined(tasks[index + 1]), + })), + ); + + const first = requireDefined(tasks[0]); + const last = requireDefined(tasks.at(-1)); + await expect(store.edges.dependsOn.create(last, first)).rejects.toThrow( + EdgeAcyclicityError, + ); + }, 30_000); + + it("accepts a DAG with ~2^30 distinct paths and refuses the edge that closes it, in bounded time", async () => { + const store = context.getStore(); + // 30 stacked diamonds: each diamond doubles the path count from the + // previous merge node, so the final merge node is reachable from the + // root along 2^30 distinct paths through only ~120 edges. `UNION` + // (never `UNION ALL`) is what keeps this bounded. + const diamondCount = 30; + const root = await store.nodes.Task.create({ name: "root" }); + let current = root; + for (let diamond = 0; diamond < diamondCount; diamond += 1) { + const left = await store.nodes.Task.create({ + name: `d${String(diamond)}-left`, + }); + const right = await store.nodes.Task.create({ + name: `d${String(diamond)}-right`, + }); + const merge = await store.nodes.Task.create({ + name: `d${String(diamond)}-merge`, + }); + await store.edges.dependsOn.bulkCreate([ + { from: current, to: left }, + { from: current, to: right }, + { from: left, to: merge }, + { from: right, to: merge }, + ]); + current = merge; + } + + // An insert that does NOT close a cycle completes in bounded time. + const unrelated = await store.nodes.Task.create({ name: "unrelated" }); + await expect( + store.edges.dependsOn.create(current, unrelated), + ).resolves.toBeDefined(); + + // The closing edge from the final merge node back to the original + // root — reachable along 2^30 distinct paths — must be refused, and + // must not time out doing so. + await expect(store.edges.dependsOn.create(current, root)).rejects.toThrow( + EdgeAcyclicityError, + ); + }, 30_000); + + it("does not count a soft-deleted edge in the relation's population", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const forward = await store.edges.dependsOn.create(a, b); + await store.edges.dependsOn.delete(forward.id); + + await expect(store.edges.dependsOn.create(b, a)).resolves.toBeDefined(); + }); + + it("still counts an edge whose validity window has ended", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + // Born already ended, so no separate update-then-race with "now" is + // needed to get an ended-but-not-deleted row. + await store.edges.dependsOn.create( + a, + b, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2020-06-01T00:00:00.000Z", + }, + ); + + await expect(store.edges.dependsOn.create(b, a)).rejects.toThrow( + EdgeAcyclicityError, + ); + }); + + it("checks a resurrected edge against the current population", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const forward = await store.edges.dependsOn.create(a, b); + await store.edges.dependsOn.delete(forward.id); + // Accepted: the forward edge is soft-deleted, so it frees the relation. + await store.edges.dependsOn.create(b, a); + + // Resurrecting `a -> b` now closes the cycle `a -> b -> a`. + await expect( + store.edges.dependsOn.getOrCreateByEndpoints( + a, + b, + {}, + { ifExists: "update" }, + ), + ).rejects.toThrow(EdgeAcyclicityError); + }); + + it("clearValidTo alone runs no acyclicity probe (over-fencing guard)", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const forward = await store.edges.dependsOn.create( + a, + b, + {}, + { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2020-06-01T00:00:00.000Z", + }, + ); + + // Refused per the "ended window still counts" test above. + await expect(store.edges.dependsOn.create(b, a)).rejects.toThrow( + EdgeAcyclicityError, + ); + + // Clearing the end must not itself be checked, and must not change + // the outcome of the (still-refused) opposite create. + await store.edges.dependsOn.update( + forward.id, + {}, + { clearValidTo: true }, + ); + await expect(store.edges.dependsOn.create(b, a)).rejects.toThrow( + EdgeAcyclicityError, + ); + }); + + it("probes a batch larger than the bind budget in statements that fit, and still finds a cycle across them", async () => { + const store = context.getStore(); + const bindBudget = 100; + const rowCount = 30; + const budgeted = createStore( + integrationTestGraph, + withBindBudget(store.backend, bindBudget), + ); + const tasks = await store.nodes.Task.bulkCreate( + Array.from({ length: rowCount + 1 }, (_unused, index) => ({ + props: { name: `budget-${String(index)}` }, + })), + ); + const chain = tasks.slice(0, -1).map((task, index) => ({ + from: task, + to: requireDefined(tasks[index + 1]), + })); + + // MUTATION CHECK: sending every proposed row in one statement + // (`readUnwrittenEdgeReaches` never slicing) binds more than the budget + // for these 30 rows, and the budgeted backend refuses the statement — + // verified and reverted. + await budgeted.edges.dependsOn.bulkCreate(chain); + expect(await storedEndpoints(store)).toHaveLength(rowCount); + + // The same chain reversed closes a cycle with every stored row, and + // its rows are spread over several statements. + await expect( + budgeted.edges.dependsOn.bulkCreate( + chain.map(({ from, to }) => ({ from: to, to: from })), + ), + ).rejects.toBeInstanceOf(EdgeAcyclicityError); + expect(await storedEndpoints(store)).toHaveLength(rowCount); + }); + + it("refuses an in-batch cycle in bulkCreate with zero rows committed", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + + await expect( + store.edges.dependsOn.bulkCreate([ + { from: a, to: b }, + { from: b, to: a }, + ]), + ).rejects.toThrow(EdgeAcyclicityError); + + const remaining = await store.edges.dependsOn.find({}); + expect(remaining).toEqual([]); + }); + + it("leaves no cycle behind when a batch refusal is caught inside an enclosing transaction", async () => { + const batchWrites = { + bulkCreate: (edges, closing) => edges.bulkCreate([closing]), + bulkInsert: (edges, closing) => edges.bulkInsert([closing]), + bulkUpsertById: (edges, closing) => + edges.bulkUpsertById([ + { id: `closing-${closing.from.id}` as never, ...closing }, + ]), + bulkGetOrCreateByEndpoints: (edges, closing) => + edges.bulkGetOrCreateByEndpoints([closing]), + } satisfies Record< + string, + ( + edges: ReturnType["edges"]["dependsOn"], + closing: Readonly<{ + from: Readonly<{ kind: "Task"; id: string }>; + to: Readonly<{ kind: "Task"; id: string }>; + props: Record; + }>, + ) => Promise + >; + + const store = context.getStore(); + const outcomes: Record = {}; + for (const [variant, write] of Object.entries(batchWrites)) { + const a = await store.nodes.Task.create({ name: `${variant}-a` }); + const b = await store.nodes.Task.create({ name: `${variant}-b` }); + const c = await store.nodes.Task.create({ name: `${variant}-c` }); + await store.edges.dependsOn.create(a, b); + await store.edges.dependsOn.create(b, c); + + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await write(tx.edges.dependsOn as never, { + from: c, + to: a, + props: {}, + }).catch((error: unknown) => refusals.push(error)); + }); + + outcomes[variant] = { + refusals: refusals.map((error) => + error instanceof Error ? error.name : error, + ), + storedFromClosingSource: await store.edges.dependsOn.findFrom(c), + }; + } + expect(outcomes).toEqual( + Object.fromEntries( + Object.keys(batchWrites).map((variant) => [ + variant, + { refusals: ["EdgeAcyclicityError"], storedFromClosingSource: [] }, + ]), + ), + ); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + describe("a batch that resurrects tombstoned edges", () => { + it("bulkUpsertById stores no create when a resurrection in the same batch is refused", async () => { + const { store, a, b, back } = await tombstonedBackEdge( + context.getStore(), + ); + + const refusals = await refusalNamesWithin(store, (edges) => + edges.bulkUpsertById([ + { id: "fresh-a-b" as never, from: a, to: b, props: {} }, + { id: back.id, from: b, to: a, props: {} }, + ]), + ); + + expect(refusals).toEqual(["EdgeAcyclicityError"]); + expect(await storedEndpoints(store)).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("bulkUpsertById stores no resurrection when a later one is refused", async () => { + const { store, a, b, c, bToC, cToA } = + await resurrectionsClosingACycleTogether(context.getStore()); + + const refusals = await refusalNamesWithin(store, (edges) => + edges.bulkUpsertById([ + { id: bToC.id, from: b, to: c, props: {} }, + { id: cToA.id, from: c, to: a, props: {} }, + ]), + ); + + expect(refusals).toEqual(["EdgeAcyclicityError"]); + expect(await storedEndpoints(store)).toEqual([`${a.id}>${b.id}`]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("bulkGetOrCreateByEndpoints stores no create when a resurrection in the same batch is refused", async () => { + const { store, a, b, c } = await tombstonedBackEdge(context.getStore()); + + const refusals = await refusalNamesWithin(store, (edges) => + edges.bulkGetOrCreateByEndpoints([ + { from: a, to: b, props: {} }, + { from: c, to: a, props: {} }, + { from: b, to: a, props: {} }, + ]), + ); + + expect(refusals).toEqual(["EdgeAcyclicityError"]); + expect(await storedEndpoints(store)).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("bulkGetOrCreateByEndpoints stores no resurrection when a later one is refused", async () => { + const { store, a, b, c } = await resurrectionsClosingACycleTogether( + context.getStore(), + ); + + const refusals = await refusalNamesWithin(store, (edges) => + edges.bulkGetOrCreateByEndpoints([ + { from: b, to: c, props: {} }, + { from: c, to: a, props: {} }, + ]), + ); + + expect(refusals).toEqual(["EdgeAcyclicityError"]); + expect(await storedEndpoints(store)).toEqual([`${a.id}>${b.id}`]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + }); + + it("refuses a batch whose own rows close a cycle through a stored edge, before writing any of them", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const c = await store.nodes.Task.create({ name: "c" }); + await store.edges.dependsOn.create(b, c); + + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await tx.edges.dependsOn + .bulkCreate([ + { from: a, to: b }, + { from: c, to: a }, + ]) + .catch((error: unknown) => refusals.push(error)); + }); + + expect(refusals).toEqual([ + expect.objectContaining({ name: "EdgeAcyclicityError" }), + ]); + expect(await store.edges.dependsOn.find({})).toHaveLength(1); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("treats two acyclic edge kinds as two independent relations", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + + await store.edges.dependsOn.create(a, b); + await expect(store.edges.blockedBy.create(b, a)).resolves.toBeDefined(); + }); + + it("fences a many-cardinality acyclic edge under one transaction, both writes serialized", async () => { + // The concurrency proof with two REAL sessions is + // tests/backends/postgres/concurrent-edge-acyclicity.test.ts; + // this pins the classification the fence is taken FROM, and that + // opposing creates inside one transaction serialize correctly (the + // second sees the first's uncommitted insert). + expect( + edgeWriteNeedsConstraintFence({ cardinality: "many", acyclic: true }), + ).toBe("edgeAcyclicity"); + + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "lock-a" }); + const b = await store.nodes.Task.create({ name: "lock-b" }); + await expect( + store.transaction(async (tx) => { + await tx.edges.dependsOn.create(a, b); + await tx.edges.dependsOn.create(b, a); + }), + ).rejects.toThrow(EdgeAcyclicityError); + }); + + it("reports a pre-existing cycle through verifyConstraintFences", async () => { + const store = context.getStore(); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + // Seeded directly through the backend, bypassing the fence: the + // store's own writes are fenced and can never produce a cycle. + const backend = context.getBackend(); + const forward = await backend.insertEdge({ + graphId: "integration_test", + id: "seeded-forward", + kind: "dependsOn", + fromKind: "Task", + fromId: a.id, + toKind: "Task", + toId: b.id, + props: {}, + }); + const backward = await backend.insertEdge({ + graphId: "integration_test", + id: "seeded-backward", + kind: "dependsOn", + fromKind: "Task", + fromId: b.id, + toKind: "Task", + toId: a.id, + props: {}, + }); + + const violations = await store.verifyConstraintFences(); + const acyclicityViolations = violations.filter( + (violation) => violation.family === "edgeAcyclicity", + ); + expect(acyclicityViolations).toEqual([ + { + family: "edgeAcyclicity", + relation: "dependsOn", + edgeIds: [forward.id, backward.id].toSorted(compareStrings), + }, + ]); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/edge-cardinality-tightening.ts b/packages/typegraph/tests/backends/integration/edge-cardinality-tightening.ts new file mode 100644 index 000000000..a36ef5213 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/edge-cardinality-tightening.ts @@ -0,0 +1,337 @@ +/** + * Data-validated tightening for edge cardinality (issue #610), stacking on the schema-tightening preflight + * (`src/schema/tightening-preflight.ts`, `prepareSchemaTighteningPreflight`). + * + * Every case states, in its own comment, the mutation that must make it + * fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStoreWithSchema, + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + MigrationError, +} from "../../../src"; +import { projectBackendWithout } from "../../../src/backend/derive-backend"; +import { + getActiveSchema, + getSchemaChanges, + migrateSchema, +} from "../../../src/schema"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const Person = defineNode("TighteningPerson", { schema: z.object({}) }); +const Target = defineNode("TighteningTarget", { schema: z.object({}) }); +const assignedTo = defineEdge("tighteningAssignedTo", { schema: z.object({}) }); + +function buildGraph( + id: string, + options: Readonly<{ + cardinality?: "one" | "oneActive"; + targetCardinality?: "one" | "oneActive"; + }>, +) { + return defineGraph({ + id, + nodes: { + TighteningPerson: { type: Person }, + TighteningTarget: { type: Target }, + }, + edges: { + tighteningAssignedTo: { + type: assignedTo, + from: [Person], + to: [Target], + ...options, + }, + }, + }); +} + +async function activeVersion( + context: IntegrationTestContext, + id: string, +): Promise { + const active = await getActiveSchema(context.getBackend(), id); + return requireDefined(active, "active schema").version; +} + +export function registerEdgeCardinalityTighteningIntegrationTests( + context: IntegrationTestContext, +): void { + describe("edge cardinality tightening (data-validated)", () => { + it("refuses tightening targetCardinality against a target that already has two incoming edges", async () => { + const id = "edge_cardinality_tightening_target_dirty"; + const store = await context.createStore(buildGraph(id, {})); + const alice = await store.nodes.TighteningPerson.create({}); + const bob = await store.nodes.TighteningPerson.create({}); + const target = await store.nodes.TighteningTarget.create({}); + await store.edges.tighteningAssignedTo.create(alice, target, {}); + await store.edges.tighteningAssignedTo.create(bob, target, {}); + + const error = await createAdapterStoreWithSchema( + buildGraph(id, { targetCardinality: "one" }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "edge-cardinality-tightening-violated") { + throw new Error( + `expected edge-cardinality-tightening-violated, got ${details.reason}`, + ); + } + expect(details.axes).toEqual([ + { + direction: "target", + cardinality: "one", + edgeKind: "tighteningAssignedTo", + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): make `newlyConstrainedEdgeAxes` + // (`src/schema/edge-cardinality-change.ts`) return `[]` unconditionally. + // The commit then succeeds and `activeVersion` reads 2 instead of 1. + + it("establishes enforcement once tightening commits against clean data", async () => { + const id = "edge_cardinality_tightening_target_clean"; + const store = await context.createStore(buildGraph(id, {})); + const alice = await store.nodes.TighteningPerson.create({}); + const bob = await store.nodes.TighteningPerson.create({}); + const target = await store.nodes.TighteningTarget.create({}); + await store.edges.tighteningAssignedTo.create(alice, target, {}); + + const version = await migrateSchema( + context.getBackend(), + buildGraph(id, { targetCardinality: "one" }), + await activeVersion(context, id), + ); + expect(version).toBe(2); + + const [upgradedStore] = await createAdapterStoreWithSchema( + buildGraph(id, { targetCardinality: "one" }), + context.getBackend(), + ); + await expect( + upgradedStore.edges.tighteningAssignedTo.create(bob, target, {}), + ).rejects.toMatchObject({ details: { direction: "target" } }); + }); + + it("reports the change through getSchemaChanges before the upgrade is attempted", async () => { + const id = "edge_cardinality_tightening_get_changes"; + await context.createStore(buildGraph(id, {})); + + const diff = await getSchemaChanges( + context.getBackend(), + buildGraph(id, { targetCardinality: "one" }), + ); + expect(diff?.hasChanges).toBe(true); + const change = diff?.edges.find( + (candidate) => candidate.kind === "tighteningAssignedTo", + ); + expect(change?.details).toContain("Target cardinality"); + // Read-only: reporting the diff must not itself publish a version. + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses the same tightening through Store.evolve()", async () => { + const id = "edge_cardinality_tightening_evolve"; + const store = await context.createStore( + defineGraph({ + id, + nodes: { TighteningPerson: { type: Person } }, + edges: {}, + }), + ); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { + TighteningTag: { properties: { label: { type: "string" } } }, + }, + edges: { + tighteningTagged: { + from: ["TighteningTag"], + to: ["TighteningPerson"], + properties: {}, + }, + }, + }), + ); + const alice = await evolved.nodes.TighteningPerson.create({}); + const tagCol = requireDefined(evolved.getNodeCollection("TighteningTag")); + const tagA = (await tagCol.create({ label: "a" })) as unknown as { + kind: string; + id: string; + }; + const tagB = (await tagCol.create({ label: "b" })) as unknown as { + kind: string; + id: string; + }; + const taggedCol = requireDefined( + evolved.getEdgeCollection("tighteningTagged"), + ); + await taggedCol.create( + { kind: "TighteningTag", id: tagA.id }, + { kind: "TighteningPerson", id: alice.id }, + {}, + ); + await taggedCol.create( + { kind: "TighteningTag", id: tagB.id }, + { kind: "TighteningPerson", id: alice.id }, + {}, + ); + + const error = await evolved + .evolve( + defineGraphExtension({ + edges: { + tighteningTagged: { + from: ["TighteningTag"], + to: ["TighteningPerson"], + properties: {}, + targetCardinality: "one", + }, + }, + }), + ) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "edge-cardinality-tightening-violated") { + throw new Error( + `expected edge-cardinality-tightening-violated, got ${details.reason}`, + ); + } + }); + + it("applies the same data probe to a source-side tightening (many -> one)", async () => { + const id = "edge_cardinality_tightening_source_dirty"; + const store = await context.createStore(buildGraph(id, {})); + const alice = await store.nodes.TighteningPerson.create({}); + const target1 = await store.nodes.TighteningTarget.create({}); + const target2 = await store.nodes.TighteningTarget.create({}); + await store.edges.tighteningAssignedTo.create(alice, target1, {}); + await store.edges.tighteningAssignedTo.create(alice, target2, {}); + + const error = await createAdapterStoreWithSchema( + buildGraph(id, { cardinality: "one" }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "edge-cardinality-tightening-violated") { + throw new Error( + `expected edge-cardinality-tightening-violated, got ${details.reason}`, + ); + } + expect(details.axes).toEqual([ + { + direction: "source", + cardinality: "one", + edgeKind: "tighteningAssignedTo", + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): in `newlyConstrainedEdgeAxes` + // (`src/schema/edge-cardinality-change.ts`), skip the `direction === + // "source"` ref entirely (only ever push target refs). This case then + // commits and `activeVersion` reads 2 instead of 1 — pinning that the + // generalization covers axes the original tightening plan never touched. + + for (const direction of ["source", "target"] as const) { + const declare = (cardinality: "one" | "oneActive") => + direction === "source" ? + { cardinality } + : { targetCardinality: cardinality }; + + it(`commits ${direction} one -> oneActive over populated edges with no preflight, on a backend without the atomic primitive`, async () => { + const id = `edge_cardinality_loosening_${direction}`; + const store = await context.createStore(buildGraph(id, declare("one"))); + const alice = await store.nodes.TighteningPerson.create({}); + const target = await store.nodes.TighteningTarget.create({}); + const edge = await store.edges.tighteningAssignedTo.create( + alice, + target, + {}, + ); + await store.edges.tighteningAssignedTo.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + // A backend with no atomic preflight primitive refuses every commit + // that owes a data check, so committing through it proves none is owed. + const [upgraded] = await createAdapterStoreWithSchema( + buildGraph(id, declare("oneActive")), + projectBackendWithout(context.getBackend(), [ + "commitSchemaVersionWithPreflight", + ]), + ); + expect(await activeVersion(context, id)).toBe(2); + + // The ended row no longer occupies the axis. + const otherTarget = await upgraded.nodes.TighteningTarget.create({}); + const otherPerson = await upgraded.nodes.TighteningPerson.create({}); + await expect( + direction === "source" ? + upgraded.edges.tighteningAssignedTo.create(alice, otherTarget, {}) + : upgraded.edges.tighteningAssignedTo.create(otherPerson, target, {}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): make `cardinalityLoosens` + // (`src/schema/edge-cardinality-change.ts`) return `false`. The axis is + // then probed, the restricted backend refuses with + // EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND, and the version + // stays 1. + + it(`probes ${direction} oneActive -> one as a tightening and refuses an ended row beside an open one`, async () => { + const id = `edge_cardinality_tightening_active_${direction}`; + const store = await context.createStore( + buildGraph(id, declare("oneActive")), + ); + const alice = await store.nodes.TighteningPerson.create({}); + const bob = await store.nodes.TighteningPerson.create({}); + const target = await store.nodes.TighteningTarget.create({}); + const otherTarget = await store.nodes.TighteningTarget.create({}); + const ended = await store.edges.tighteningAssignedTo.create( + alice, + target, + {}, + ); + await store.edges.tighteningAssignedTo.update( + ended.id, + {}, + { validTo: new Date().toISOString() }, + ); + await (direction === "source" ? + store.edges.tighteningAssignedTo.create(alice, otherTarget, {}) + : store.edges.tighteningAssignedTo.create(bob, target, {})); + + const error = await createAdapterStoreWithSchema( + buildGraph(id, declare("one")), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "edge-cardinality-tightening-violated", + axes: [ + { direction, cardinality: "one", edgeKind: "tighteningAssignedTo" }, + ], + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): make `cardinalityLoosens` also accept + // `oneActive` -> `one`. The commit then succeeds and the version reads 2. + } + }); +} diff --git a/packages/typegraph/tests/backends/integration/edge-claim-self-heal.ts b/packages/typegraph/tests/backends/integration/edge-claim-self-heal.ts index 7552dc4d9..39e1c3019 100644 --- a/packages/typegraph/tests/backends/integration/edge-claim-self-heal.ts +++ b/packages/typegraph/tests/backends/integration/edge-claim-self-heal.ts @@ -18,7 +18,14 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineEdge, defineGraph, defineNode } from "../../../src"; +import { + defineEdge, + defineGraph, + defineNode, + type Node, + type Store, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; import { type IntegrationTestContext } from "./test-context"; const SelfHealPerson = defineNode("SelfHealPerson", { @@ -54,6 +61,9 @@ function buildSelfHealGraph(graphId: string) { }); } +type SelfHealStore = Store>; +type SelfHealNode = Node; + export function registerEdgeClaimSelfHealIntegrationTests( context: IntegrationTestContext, ): void { @@ -99,50 +109,97 @@ export function registerEdgeClaimSelfHealIntegrationTests( ).rejects.toThrow(/[Cc]ardinality/u); }); - it("takes over a claim whose holder id was reused by an unrelated edge", async () => { - graphIdCounter += 1; - const graph = buildSelfHealGraph(`edge_claim_id_reuse_${graphIdCounter}`); - const store = await context.createStore(graph); - const backend = store.backend; - - const alice = await store.nodes.SelfHealPerson.create({ name: "Alice" }); - const bob = await store.nodes.SelfHealPerson.create({ name: "Bob" }); - const carol = await store.nodes.SelfHealPerson.create({ name: "Carol" }); - - const incumbent = await store.edges.selfHealReportsTo.create( - alice, - bob, - {}, - { id: "reused-edge-id" }, - ); - expect(incumbent.id).toBe("reused-edge-id"); - - // Hard delete past the store, then re-use the id for a LIVE edge of a - // different kind from a different source. The claim row still names - // "reused-edge-id", and that id now belongs to something the axis does - // not describe. - await backend.hardDeleteEdge({ - graphId: graph.id, - id: "reused-edge-id", - kind: "selfHealReportsTo", - }); - await backend.insertEdge({ - graphId: graph.id, - id: "reused-edge-id", - kind: "selfHealKnows", - fromKind: "SelfHealPerson", - fromId: carol.id, - toKind: "SelfHealPerson", - toId: bob.id, - props: {}, - }); - - const replacement = await store.edges.selfHealReportsTo.create( - alice, - carol, - {}, - ); - expect(replacement.fromId).toBe(alice.id); - }); + // A single create claims through the guarded statement, which re-reads + // the whole axis; a batch claims through the lock-then-takeover pair, + // whose takeover reads only the RECORDED holder. Both must tell a reused + // id from the holder it names, so both write shapes run every case. + const writes = [ + { + name: "create", + run: (store: SelfHealStore, from: SelfHealNode, to: SelfHealNode) => + store.edges.selfHealReportsTo.create(from, to, {}), + }, + { + name: "bulkCreate", + run: async ( + store: SelfHealStore, + from: SelfHealNode, + to: SelfHealNode, + ) => { + const [edge] = await store.edges.selfHealReportsTo.bulkCreate([ + { from, to, props: {} }, + ]); + return requireDefined(edge, "bulkCreate returned no edge"); + }, + }, + ] as const; + + // Where the hard-deleted holder's id is reused: by an edge of an + // unconstrained kind, or by an edge of the SAME constrained kind holding a + // different key on the axis. Either way the id is live and the edge it + // names is not one the original claim describes. + const reuses = [ + { name: "an unrelated edge kind", kind: "selfHealKnows" }, + { name: "another key of the same axis", kind: "selfHealReportsTo" }, + ] as const; + + for (const write of writes) { + for (const reuse of reuses) { + it(`${write.name} takes over a claim whose holder id was reused by ${reuse.name}`, async () => { + graphIdCounter += 1; + const graph = buildSelfHealGraph( + `edge_claim_id_reuse_${graphIdCounter}`, + ); + const store = await context.createStore(graph); + const backend = store.backend; + + const alice = await store.nodes.SelfHealPerson.create({ + name: "Alice", + }); + const bob = await store.nodes.SelfHealPerson.create({ name: "Bob" }); + const carol = await store.nodes.SelfHealPerson.create({ + name: "Carol", + }); + + const incumbent = await store.edges.selfHealReportsTo.create( + alice, + bob, + {}, + { id: "reused-edge-id" }, + ); + expect(incumbent.id).toBe("reused-edge-id"); + + // Hard delete past the store, then re-use the id for a LIVE edge + // from a different source. The claim row still names + // "reused-edge-id", and that id now belongs to something the axis + // and key do not describe. + await backend.hardDeleteEdge({ + graphId: graph.id, + id: "reused-edge-id", + kind: "selfHealReportsTo", + }); + await backend.insertEdge({ + graphId: graph.id, + id: "reused-edge-id", + kind: reuse.kind, + fromKind: "SelfHealPerson", + fromId: carol.id, + toKind: "SelfHealPerson", + toId: bob.id, + props: {}, + }); + + const replacement = await write.run(store, alice, carol); + expect(replacement.fromId).toBe(alice.id); + expect(replacement.toId).toBe(carol.id); + + // The replacement now holds the axis: a second edge from Alice is + // refused on either write shape. + await expect( + store.edges.selfHealReportsTo.create(alice, bob, {}), + ).rejects.toThrow(/[Cc]ardinality/u); + }); + } + } }); } diff --git a/packages/typegraph/tests/backends/integration/edge-target-cardinality-temporal.ts b/packages/typegraph/tests/backends/integration/edge-target-cardinality-temporal.ts new file mode 100644 index 000000000..024b27c49 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/edge-target-cardinality-temporal.ts @@ -0,0 +1,540 @@ +/** + * Temporal semantics of the edge cardinality claim (issue #610), table-driven over `{one, oneActive} x {source, target, + * both}`. `edgeCardinalitySpec(ref).claimsWhenBornEnded` and + * `.holderLiveness` (`src/store/claims/edge-claims.ts`) are the two facts + * this file pins per axis: + * + * - `one` (source or target): `holderLiveness: "live"` — a claim survives an + * ended validity window; only a hard delete frees it. `claimsWhenBornEnded: + * true` — a row born already ended still occupies the slot. + * - `oneActive` (source or target): `holderLiveness: "liveAndActive"` — the + * claim is freed the moment the row is no longer the CURRENT revision with + * an open (`validTo` unset) window, whether by ending it or hard-deleting + * it. `claimsWhenBornEnded: false` — a row born already ended never + * occupied the slot to begin with. + * + * Each case's "competing attempt" is chosen per axis so it actually shares + * the axis's key with the holder under test: a source-axis competitor reuses + * the SAME source against a NEW target (the source axis never cares about + * the opposite endpoint), a target-axis competitor reuses the SAME target + * from a NEW source. Testing a source-axis claim with a fresh, unrelated + * source would trivially "succeed" no matter what the claim held — that is + * not a probe of the axis at all. + * + * Every case states, in its own comment, the mutation that must make it + * fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CardinalityError, + defineEdge, + defineGraph, + defineNode, +} from "../../../src"; +import { type IntegrationTestContext } from "./test-context"; + +const Person = defineNode("TgctPerson", { schema: z.object({}) }); +const Target = defineNode("TgctTarget", { schema: z.object({}) }); + +type Axis = "source" | "target" | "both"; +type Bound = "one" | "oneActive"; + +/** + * A minimal, untyped view of `EdgeCollection` sufficient for this file's + * generic loop over dynamically-named edge kinds. The concrete collection + * type is not expressible generically here — the graph and edge kind are + * built per table row — so this view is asserted through `unknown`. + */ +type TemporalEdgeCollection = Readonly<{ + create: ( + from: unknown, + to: unknown, + props: unknown, + options?: unknown, + ) => Promise<{ id: string }>; + update: (id: string, props: unknown, options: unknown) => Promise; + delete: (id: string) => Promise; + hardDelete: (id: string) => Promise; +}>; + +function edgeCollection( + store: Readonly<{ edges: Record }>, + edgeKind: string, +): TemporalEdgeCollection { + return store.edges[edgeKind] as TemporalEdgeCollection; +} + +function edgeOptions( + axis: Axis, + bound: Bound, +): Readonly<{ cardinality?: Bound; targetCardinality?: Bound }> { + switch (axis) { + case "source": { + return { cardinality: bound }; + } + case "target": { + return { targetCardinality: bound }; + } + case "both": { + return { cardinality: bound, targetCardinality: bound }; + } + } +} + +/** `oneActive` frees on end; `one` never frees short of a hard delete. */ +function freesOnEnd(bound: Bound): boolean { + return bound === "oneActive"; +} + +/** + * Asserts a create either resolves or refuses with `CardinalityError`, + * whichever `expectSuccess` states — expressed as one unconditional + * assertion on an already-settled outcome rather than an `expect` inside an + * `if`, so both branches are equally exercised by the linter's static check. + */ +async function expectCreateOutcome( + promise: Promise, + expectSuccess: boolean, +): Promise { + const outcome = await promise.then( + () => ({ succeeded: true as const, error: undefined as unknown }), + (error: unknown) => ({ succeeded: false as const, error }), + ); + expect(outcome.succeeded).toBe(expectSuccess); + expect(outcome.error).toEqual( + expectSuccess ? undefined : expect.any(CardinalityError), + ); +} + +/** + * The endpoint pair a competing create must use to actually contend for the + * SAME axis key the holder occupies: same source for `source` (a `both` kind + * additionally constrains the target, but sharing the source alone is + * already enough to contend the axis under test), same target for `target`. + */ +function competingEndpoints( + axis: Axis, + holderSource: unknown, + competingSource: unknown, + holderTarget: unknown, + freshTarget: unknown, +): readonly [from: unknown, to: unknown] { + return axis === "source" ? + [holderSource, freshTarget] + : [competingSource, holderTarget]; +} + +let counter = 0; +function nextId(): string { + counter += 1; + return `edge_target_cardinality_temporal_${counter}`; +} + +const AXES: readonly Axis[] = ["source", "target", "both"]; +const BOUNDS: readonly Bound[] = ["one", "oneActive"]; + +export function registerEdgeTargetCardinalityTemporalIntegrationTests( + context: IntegrationTestContext, +): void { + describe("edge cardinality temporal semantics (table-driven)", () => { + for (const axis of AXES) { + for (const bound of BOUNDS) { + const edgeKind = `tgctAssigned_${axis}_${bound}`; + const assigned = defineEdge(edgeKind, { schema: z.object({}) }); + + function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + TgctPerson: { type: Person }, + TgctTarget: { type: Target }, + }, + edges: { + [edgeKind]: { + type: assigned, + from: [Person], + to: [Target], + ...edgeOptions(axis, bound), + }, + }, + }); + } + + describe(`axis=${axis} bound=${bound}`, () => { + it( + "ending the holder's window " + + (freesOnEnd(bound) ? "frees" : "does not free") + + " the slot", + async () => { + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create(alice, target, {}); + // End alice's edge (a "soft delete" of its active window, not + // a hard delete of the row): sets validTo to now. + await collection.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expectCreateOutcome( + collection.create(from, to, {}), + freesOnEnd(bound), + ); + }, + ); + // MUTATION CHECK (verified): flip + // `EDGE_CARDINALITY_SPECS["${axis}:${bound}"].holderLiveness` to + // the opposite of its declared value in + // `src/store/claims/edge-claims.ts`. This case then asserts the + // wrong branch and fails for every axis/bound pair it covers. + + it("soft-deleting the holder frees the slot on EVERY bound, unlike merely ending it", async () => { + // `holderLiveness: "live"` reads `deletedAt IS NULL`, not + // `validTo IS NULL` — a genuine soft delete (`.delete()`, which + // sets `deletedAt`) frees a "one" slot the sibling "ending" + // case above proves an ended-but-not-deleted row does NOT free. + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create(alice, target, {}); + await collection.delete(edge.id); + + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expect( + collection.create(from, to, {}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): change the `deletedAt IS NULL` term + // in the "live" predicate (`competingLiveEdgePredicate` in + // `src/backend/drizzle/operations/edge-claims.ts`, and the + // equivalent in `buildCountEdgesAtEndpoint`, + // `src/backend/drizzle/operations/edges.ts`) to always pass + // (drop the `deletedAt` filter). The soft-deleted row then still + // reads as live and this case fails for every axis/bound pair. + + it( + "creating a row born already ended against an existing live incumbent " + + (bound === "oneActive" ? + "is exempt (never contends)" + : "is still checked"), + async () => { + // The incumbent must be LIVE (and, for `oneActive`, also + // active) so this pins `claimsWhenBornEnded` specifically: + // whether the NEW, already-ended row's own creation is + // checked against an incumbent that indisputably occupies the + // axis, not whether an ended row keeps blocking others (that + // is `holderLiveness`, pinned by the sibling cases above). + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const [incumbentFrom, incumbentTo] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await collection.create(incumbentFrom, incumbentTo, {}); + + // The SAME axis key, a DIFFERENT edge, born already ended. + const [contenderFrom, contenderTo] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expectCreateOutcome( + collection.create( + contenderFrom, + contenderTo, + {}, + { + validFrom: "2019-01-01T00:00:00.000Z", + validTo: "2019-06-01T00:00:00.000Z", + }, + ), + bound === "oneActive", + ); + }, + ); + // MUTATION CHECK (verified): flip + // `EDGE_CARDINALITY_SPECS["${axis}:${bound}"].claimsWhenBornEnded` + // to the opposite of its declared value. This case then asserts + // the wrong branch and fails. + + if (bound === "oneActive") { + it("a row born with a future validFrom and no validTo occupies the active slot", async () => { + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + await collection.create( + alice, + target, + {}, + { validFrom: "2999-01-01T00:00:00.000Z" }, + ); + + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expect( + collection.create(from, to, {}), + ).rejects.toBeInstanceOf(CardinalityError); + }); + + it("a row with ANY set validTo, future included, does not occupy the active slot", async () => { + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + await collection.create( + alice, + target, + {}, + { validTo: "2999-01-01T00:00:00.000Z" }, + ); + + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expect( + collection.create(from, to, {}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): change the active-window predicate + // this spec's `holderLiveness: "liveAndActive"` renders through + // (the `validTo IS NULL` / no-`validTo` check in + // `src/store/constraints.ts` and + // `src/backend/drizzle/operations/edge-claims.ts`) to a + // now-vs-`validTo` comparison instead. The future-`validTo` case + // above then refuses (wrongly reading the row as still active) + // and fails. + + it("reopening an ended window reacquires the reservation with a real re-probe", async () => { + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create( + alice, + target, + {}, + { validFrom: "2019-01-01T00:00:00.000Z" }, + ); + await collection.update( + edge.id, + {}, + { validTo: "2020-01-01T00:00:00.000Z" }, + ); + // The slot is free: a competitor sharing the axis key takes it. + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await collection.create(from, to, {}); + // Reopening alice's edge now must re-probe against the new + // holder's LIVE claim and refuse, not rubber-stamp the reopen + // because it is the axis's original holder. + await expect( + collection.update(edge.id, {}, { clearValidTo: true }), + ).rejects.toBeInstanceOf(CardinalityError); + }); + } + + it("historical revisions impose no lifetime uniqueness beyond the current row", async () => { + const store = await context.createStore(buildGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const target = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create(alice, target, {}); + // Revise the row several times: none of these revisions add a + // second claim, so freeing the axis (by whatever mechanism this + // bound uses) always leaves exactly one slot open, never zero. + await collection.update(edge.id, {}, {}); + await collection.update(edge.id, {}, {}); + await collection.update(edge.id, {}, {}); + + if (freesOnEnd(bound)) { + await collection.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + } else { + await collection.delete(edge.id); + await collection.hardDelete(edge.id); + } + const [from, to] = competingEndpoints( + axis, + alice, + bob, + target, + otherTarget, + ); + await expect( + collection.create(from, to, {}), + ).resolves.toBeDefined(); + }); + }); + } + } + }); + + /** + * Regression coverage for the reentry probe's self-count bug (#610 review + * finding): a `clearValidTo` reopen with NO delete + * transition used to re-probe EVERY declared axis, not just the + * active-only one. A non-active-only axis (`one`/`unique`) never lost its + * claim while the row stayed live and undeleted, so re-probing it counted + * this very row against itself and refused a reopen nothing else + * contends for. The two arms above (`AXES x BOUNDS`) never exercise this: + * `both` always pairs the SAME bound on both axes, so an `oneActive`+ + * `oneActive` kind's reentry probe naturally excludes the row from every + * axis it re-checks (both axes read `validTo IS NULL`, which this row + * fails while its window is still ended) and a single-axis kind never + * re-checks a second, non-active-only axis at all. + */ + const MIXED_BOUND_PAIRS: readonly Readonly<{ + source: Bound | "unique"; + target: Bound; + }>[] = [ + { source: "one", target: "oneActive" }, + { source: "unique", target: "oneActive" }, + { source: "oneActive", target: "one" }, + ]; + + describe("mixed-bound axes (one active-only, the other not)", () => { + for (const { source, target } of MIXED_BOUND_PAIRS) { + const edgeKind = `tgctMixed_${source}_${target}`; + const mixedEdge = defineEdge(edgeKind, { schema: z.object({}) }); + + function buildMixedGraph(id: string) { + return defineGraph({ + id, + nodes: { + TgctPerson: { type: Person }, + TgctTarget: { type: Target }, + }, + edges: { + [edgeKind]: { + type: mixedEdge, + from: [Person], + to: [Target], + cardinality: source, + targetCardinality: target, + }, + }, + }); + } + + describe(`cardinality=${source} targetCardinality=${target}`, () => { + it("reopening an ended window succeeds when nothing else contends", async () => { + const store = await context.createStore(buildMixedGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const targetNode = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create(alice, targetNode, {}); + await collection.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + await expect( + collection.update(edge.id, {}, { clearValidTo: true }), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): revert the reentry-probe fix + // (probe every declared axis on a `clearValidTo`-only reopen, + // instead of only the active-only ones) — this case then throws + // `CardinalityError` instead of resolving. + + it("the non-active-only axis still refuses a genuine competitor after the reopen", async () => { + const store = await context.createStore(buildMixedGraph(nextId())); + const collection = edgeCollection(store, edgeKind); + const alice = await store.nodes.TgctPerson.create({}); + const bob = await store.nodes.TgctPerson.create({}); + const targetNode = await store.nodes.TgctTarget.create({}); + const otherTarget = await store.nodes.TgctTarget.create({}); + + const edge = await collection.create(alice, targetNode, {}); + await collection.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + await collection.update(edge.id, {}, { clearValidTo: true }); + + // Whichever side is NOT active-only never released its claim, so a + // distinct edge contending that exact axis key must still refuse — + // proving the fix narrowed the reentry probe rather than dropping + // it. `unique`'s key is the whole (from, to) pair, so its + // competitor must reuse both endpoints; `one`'s key is the single + // constrained endpoint. + const [from, to] = + source === "unique" ? [alice, targetNode] + : source === "one" ? [alice, otherTarget] + : [bob, targetNode]; + await expect(collection.create(from, to, {})).rejects.toBeInstanceOf( + CardinalityError, + ); + }); + }); + } + }); +} diff --git a/packages/typegraph/tests/backends/integration/edge-target-cardinality.ts b/packages/typegraph/tests/backends/integration/edge-target-cardinality.ts new file mode 100644 index 000000000..20de3442f --- /dev/null +++ b/packages/typegraph/tests/backends/integration/edge-target-cardinality.ts @@ -0,0 +1,291 @@ +/** + * Target-side edge cardinality (issue #610), on every backend. + * + * `targetCardinality` bounds the edges that point AT one node, independent of + * `cardinality`, which bounds the edges leaving one source. These cases pin + * acceptance criteria 1, 2, 3 and 9: the reservation is scoped to + * `(graph, edge kind, target kind, target id)` and nothing else, it counts + * edges rather than distinct neighbours, and an idempotent reopen of an + * already-held axis does not refuse against itself. + * + * Each case states the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CardinalityError, + defineEdge, + defineGraph, + defineNode, +} from "../../../src"; +import { requireDefined } from "../../../src/utils/presence"; +import { expectOnlyClaimsOf, readEdgeClaimRows } from "../../edge-claim-rows"; +import { type IntegrationTestContext } from "./test-context"; + +const Person = defineNode("TgcPerson", { schema: z.object({}) }); +const Contractor = defineNode("TgcContractor", { schema: z.object({}) }); +const Target = defineNode("TgcTarget", { schema: z.object({}) }); +const OtherTarget = defineNode("TgcOtherTarget", { schema: z.object({}) }); + +const assignedTo = defineEdge("tgcAssignedTo", { schema: z.object({}) }); +const caresFor = defineEdge("tgcCaresFor", { schema: z.object({}) }); +const ownsAsset = defineEdge("tgcOwnsAsset", { schema: z.object({}) }); +const assignedToActive = defineEdge("tgcAssignedToActive", { + schema: z.object({}), +}); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + TgcPerson: { type: Person }, + TgcContractor: { type: Contractor }, + TgcTarget: { type: Target }, + TgcOtherTarget: { type: OtherTarget }, + }, + edges: { + // Target-only constrained: any source may point at a Target/OtherTarget + // once. Two source kinds share the SAME allowance, and the two target + // node kinds are independent axes. + tgcAssignedTo: { + type: assignedTo, + from: [Person, Contractor], + to: [Target, OtherTarget], + targetCardinality: "one", + }, + // A second target-`one` edge KIND over the same target kind, to prove + // the axis is scoped by edge kind too. + tgcCaresFor: { + type: caresFor, + from: [Person], + to: [Target], + targetCardinality: "one", + }, + // Both axes declared: strict 1:1 over the live population. + tgcOwnsAsset: { + type: ownsAsset, + from: [Person], + to: [Target], + cardinality: "one", + targetCardinality: "one", + }, + // Target-`oneActive`: the axis a reopened window can reclaim. + tgcAssignedToActive: { + type: assignedToActive, + from: [Person], + to: [Target], + targetCardinality: "oneActive", + }, + }, + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `edge_target_cardinality_${graphIdCounter}`; +} + +export function registerEdgeTargetCardinalityIntegrationTests( + context: IntegrationTestContext, +): void { + describe("target-side edge cardinality", () => { + it("refuses a second source assigned to an already-targeted node", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.TgcPerson.create({}); + const bob = await store.nodes.TgcPerson.create({}); + const target = await store.nodes.TgcTarget.create({}); + + await store.edges.tgcAssignedTo.create(alice, target, {}); + + const error = await store.edges.tgcAssignedTo + .create(bob, target, {}) + .catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(CardinalityError); + const details = (error as CardinalityError).details; + expect(details.direction).toBe("target"); + expect(details.toKind).toBe("TgcTarget"); + expect(details.toId).toBe(target.id); + + expect(await store.edges.tgcAssignedTo.findTo(target)).toHaveLength(1); + }); + // MUTATION CHECK (verified): set + // `EDGE_CARDINALITY_SPECS["target:one"].keyShape` to `"from"` in + // `src/store/claims/edge-claims.ts`. Bob's create then succeeds (the + // claim keys on the SOURCE instead of the target) and this test fails. + + it("refuses a second distinct edge from the SAME source to the same target (count, not distinct neighbours)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.TgcPerson.create({}); + const target = await store.nodes.TgcTarget.create({}); + + await store.edges.tgcAssignedTo.create(alice, target, {}); + await expect( + store.edges.tgcAssignedTo.create(alice, target, {}), + ).rejects.toBeInstanceOf(CardinalityError); + + expect(await store.edges.tgcAssignedTo.findTo(target)).toHaveLength(1); + }); + + it("lets two permitted source kinds share one target allowance, refusing the second", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.TgcPerson.create({}); + const carl = await store.nodes.TgcContractor.create({}); + const target = await store.nodes.TgcTarget.create({}); + + await store.edges.tgcAssignedTo.create(alice, target, {}); + await expect( + store.edges.tgcAssignedTo.create(carl, target, {}), + ).rejects.toBeInstanceOf(CardinalityError); + }); + + it("keeps the target axis scoped to (graph, edge kind, target kind, target id)", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const a1 = await store.nodes.TgcPerson.create({}); + const a2 = await store.nodes.TgcPerson.create({}); + const a3 = await store.nodes.TgcPerson.create({}); + + // A different TARGET KIND with the same literal id succeeds. + const target = await store.nodes.TgcTarget.create({}, { id: "dup-id" }); + const otherTarget = await store.nodes.TgcOtherTarget.create( + {}, + { id: "dup-id" }, + ); + await store.edges.tgcAssignedTo.create(a1, target, {}); + await expect( + store.edges.tgcAssignedTo.create(a2, otherTarget, {}), + ).resolves.toBeDefined(); + + // A different EDGE KIND over the SAME target kind and id succeeds. + await expect( + store.edges.tgcCaresFor.create(a3, target, {}), + ).resolves.toBeDefined(); + + // A different GRAPH with the same ids succeeds. + const otherStore = await context.createStore(buildGraph(nextGraphId())); + const b1 = await otherStore.nodes.TgcPerson.create({}); + const otherGraphTarget = await otherStore.nodes.TgcTarget.create( + {}, + { id: "dup-id" }, + ); + await expect( + otherStore.edges.tgcAssignedTo.create(b1, otherGraphTarget, {}), + ).resolves.toBeDefined(); + }); + + it("enforces both axes together and leaves no residue from a refused two-axis write", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.TgcPerson.create({}); + const dave = await store.nodes.TgcPerson.create({}); + const assetA = await store.nodes.TgcTarget.create({}); + const assetB = await store.nodes.TgcTarget.create({}); + const assetC = await store.nodes.TgcTarget.create({}); + + await store.edges.tgcOwnsAsset.create(alice, assetA, {}); + + // Source axis: alice already owns one asset. + await expect( + store.edges.tgcOwnsAsset.create(alice, assetB, {}), + ).rejects.toBeInstanceOf(CardinalityError); + // Target axis: assetA is already owned. + const targetRefusal = await store.edges.tgcOwnsAsset + .create(dave, assetA, {}) + .catch((error_: unknown) => error_); + expect(targetRefusal).toBeInstanceOf(CardinalityError); + + // The refused attempts left nothing behind: the claim relation holds + // exactly alice's edge's two axis rows — none for dave's source. + const [owned] = await store.edges.tgcOwnsAsset.findFrom(alice); + expectOnlyClaimsOf( + await readEdgeClaimRows(store.backend, store.graphId), + store.graphId, + { cardinality: "one", targetCardinality: "one" }, + [requireDefined(owned)], + ); + + // dave's source axis is free: a fresh, otherwise-valid write on it + // still succeeds. + await expect( + store.edges.tgcOwnsAsset.create(dave, assetC, {}), + ).resolves.toBeDefined(); + + expect(await store.edges.tgcOwnsAsset.findFrom(alice)).toHaveLength(1); + expect(await store.edges.tgcOwnsAsset.findTo(assetA)).toHaveLength(1); + }); + // MUTATION CHECK (verified): declaring `tgcOwnsAsset` with only + // `cardinality: "one"` (drop `targetCardinality`) makes `dave`'s create + // against the already-owned `assetA` succeed, and the fourth `expect` + // above (`findTo(assetA)` has length 1) fails. Separately, planting a + // claim for a never-inserted edge on dave's source axis (via + // `backend.claimEdgeCardinality`) before the `expectOnlyClaimsOf` call + // fails it with a third claim row, while the later `create(dave, assetC)` + // still passes (a stale claim is taken over by design). + + it("reopens a target-oneActive window on the SAME edge without refusing against itself, but re-probes for real", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.TgcPerson.create({}); + const bob = await store.nodes.TgcPerson.create({}); + const target = await store.nodes.TgcTarget.create({}); + + const edge = await store.edges.tgcAssignedToActive.create( + alice, + target, + {}, + { validFrom: "2019-01-01T00:00:00.000Z" }, + ); + // End it: the active-only slot is freed, and the claim row becomes + // takeable in place. + await store.edges.tgcAssignedToActive.update( + edge.id, + {}, + { + validTo: "2020-01-01T00:00:00.000Z", + }, + ); + + // Reopening the SAME edge immediately (nothing else has taken the + // freed slot) must not read its own prior row as a competing + // incumbent. + await expect( + store.edges.tgcAssignedToActive.update( + edge.id, + {}, + { + clearValidTo: true, + }, + ), + ).resolves.toBeDefined(); + const reopened = await store.edges.tgcAssignedToActive.getById(edge.id); + expect(reopened?.meta.validTo).toBeUndefined(); + // End it again, and let bob legitimately take the now-freed slot. + await store.edges.tgcAssignedToActive.update( + edge.id, + {}, + { + validTo: "2021-01-01T00:00:00.000Z", + }, + ); + await store.edges.tgcAssignedToActive.create(bob, target, {}); + + // Reopening alice's edge NOW must refuse: the reopen is a real + // re-probe against the CURRENT holder, not a rubber stamp for an + // edge id this write has seen before. + await expect( + store.edges.tgcAssignedToActive.update( + edge.id, + {}, + { + clearValidTo: true, + }, + ), + ).rejects.toBeInstanceOf(CardinalityError); + }); + // MUTATION CHECK (verified): in `performEdgeUpdate` + // (`src/store/operations/edge-operations.ts`), force + // `reentersActivePopulation` to `false` unconditionally. Reopening + // alice's edge after bob's takeover then skips the re-probe entirely and + // succeeds, and the final `rejects.toBeInstanceOf(CardinalityError)` + // assertion above fails. + }); +} diff --git a/packages/typegraph/tests/backends/integration/fixtures.ts b/packages/typegraph/tests/backends/integration/fixtures.ts index 6990d88d4..5249ba544 100644 --- a/packages/typegraph/tests/backends/integration/fixtures.ts +++ b/packages/typegraph/tests/backends/integration/fixtures.ts @@ -82,6 +82,17 @@ const Article = defineNode("Article", { }), }); +/** + * Task node for the edge-acyclicity suite. Kept separate from `Person` / + * `Company` so `dependsOn` / `blockedBy` are self-edges with no unrelated + * endpoint-validation surface to reason about. + */ +const Task = defineNode("Task", { + schema: z.object({ + name: z.string(), + }), +}); + const worksAt = defineEdge("worksAt", { schema: z.object({ role: z.string(), @@ -97,6 +108,20 @@ const knows = defineEdge("knows", { }), }); +/** Primary acyclicity fixture: `cardinality: "many"` with `acyclic: true`. */ +const dependsOn = defineEdge("dependsOn", { + schema: z.object({}), +}); + +/** + * A second, independent acyclic relation over the same node kind — pins + * The per-kind scope of acyclicity: `a --dependsOn--> b` and `b --blockedBy--> a` are + * both accepted because neither relation alone has a cycle. + */ +const blockedBy = defineEdge("blockedBy", { + schema: z.object({}), +}); + const authoredBy = defineEdge("authoredBy", { schema: z.object({}), }); @@ -154,6 +179,7 @@ export const integrationTestGraph = defineGraph({ Company: { type: Company }, Document: { type: Document }, Article: { type: Article }, + Task: { type: Task }, }, indexes: [ productCategoryIndex, @@ -183,6 +209,19 @@ export const integrationTestGraph = defineGraph({ to: [Person], cardinality: "many", }, + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + blockedBy: { + type: blockedBy, + from: [Task], + to: [Task], + acyclic: true, + }, }, identity: { sameIdAcrossKinds: "fold" }, }); diff --git a/packages/typegraph/tests/backends/integration/graph-merge-callbacks.ts b/packages/typegraph/tests/backends/integration/graph-merge-callbacks.ts index 533ea11a1..39f84a021 100644 --- a/packages/typegraph/tests/backends/integration/graph-merge-callbacks.ts +++ b/packages/typegraph/tests/backends/integration/graph-merge-callbacks.ts @@ -159,6 +159,7 @@ export function registerGraphMergeCallbackIntegrationTests( "findByConstraint", "getById", "getByIds", + "planDelete", ]); expect(Object.keys(reads.edges.accepts).toSorted()).toEqual([ "bulkFindFrom", diff --git a/packages/typegraph/tests/backends/integration/graph-merge-composition.ts b/packages/typegraph/tests/backends/integration/graph-merge-composition.ts new file mode 100644 index 000000000..09a98d965 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/graph-merge-composition.ts @@ -0,0 +1,309 @@ +/** + * Composition writes replayed by a graph merge. + * + * A branch realizes composition through the store's own composition-aware + * entries (`create` with `partOf`, `reparent`, a whole's cascading delete). A + * merge replays the resulting rows one write at a time, so every case here is + * a branch edit that is valid as a whole and whose individual rows are not: + * a required part exists before its edge does, a moved part is detached + * before it is attached again, a deleted whole still shows the part the same + * plan moves away. + * + * Each case runs over the full existence x cardinality matrix where the + * dimension matters, and checks the plan review beside the apply: the review + * must report no orphan for a plan the apply accepts. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + defineEdge, + defineGraph, + defineNode, + partOf, + type Store, +} from "../../../src"; +import { isErr, unwrap } from "../../../src/graph-merge"; +import { branch } from "../../../src/graph-merge/branch"; +import { + applyMergePlan, + applyMergePlanInTransaction, + merge, + mergeIncremental, + planMerge, + planMergeIncremental, +} from "../../../src/graph-merge/merge"; +import { asBranchId, type GraphBranch } from "../../../src/graph-merge/types"; +import { type IntegrationTestContext } from "./test-context"; + +const Whole = defineNode("GmcWhole", { + schema: z.object({ label: z.string() }), +}); +const Part = defineNode("GmcPart", { schema: z.object({ label: z.string() }) }); +const holds = defineEdge("gmcHolds", { schema: z.object({}) }); + +const EXISTENCES = ["required", "optional"] as const; +const CARDINALITIES = ["one", "oneActive"] as const; + +type Existence = (typeof EXISTENCES)[number]; +type Cardinality = (typeof CARDINALITIES)[number]; + +function compositionGraph(existence: Existence, cardinality: Cardinality) { + return defineGraph({ + id: `graph_merge_composition_${existence}_${cardinality}`, + nodes: { GmcWhole: { type: Whole }, GmcPart: { type: Part } }, + edges: { + gmcHolds: { type: holds, from: [Part], to: [Whole], cardinality }, + }, + ontology: [partOf(Part, Whole, { via: holds, existence })], + }); +} + +type CompositionGraph = ReturnType; +type TestStore = Store; + +const MATRIX = EXISTENCES.flatMap((existence) => + CARDINALITIES.map((cardinality) => ({ existence, cardinality })), +); + +function wholeRef(id: string) { + return { kind: "GmcWhole", id } as const; +} + +async function seededTarget( + context: IntegrationTestContext, + existence: Existence, + cardinality: Cardinality, +): Promise { + const target = await context.createStore( + compositionGraph(existence, cardinality), + { revisionTracking: true }, + ); + await target.nodes.GmcWhole.create({ label: "w1" }, { id: "w1" }); + await target.nodes.GmcWhole.create({ label: "w2" }, { id: "w2" }); + await target.nodes.GmcPart.create( + { label: "p1" }, + { id: "p1", partOf: { whole: wholeRef("w1") } }, + ); + return target; +} + +async function fork( + context: IntegrationTestContext, + target: TestStore, +): Promise> { + return unwrap( + await branch(target, () => context.createIsolatedBackend(), { + id: asBranchId("branch-a"), + }), + ); +} + +/** The whole each live part hangs from, by part id. */ +async function attachments( + store: TestStore, +): Promise>> { + const edges = await store.edges.gmcHolds.find(); + return Object.fromEntries(edges.map((edge) => [edge.fromId, edge.toId])); +} + +async function livePartIds(store: TestStore): Promise { + const parts = await store.nodes.GmcPart.find(); + return parts.map((part) => part.id).toSorted(); +} + +/** Plans the merge, asserts a clean review, applies it, and returns nothing. */ +async function planThenApply( + target: TestStore, + source: GraphBranch, +): Promise { + const plan = unwrap(await planMerge(target, [source])); + expect(plan.review.compositionOrphans).toEqual([]); + const applied = await applyMergePlan(target, plan); + if (isErr(applied)) throw applied.error; +} + +export function registerGraphMergeCompositionIntegrationTests( + context: IntegrationTestContext, +): void { + describe("composition writes replayed by graph merge", () => { + describe.each(MATRIX)( + "existence $existence, cardinality $cardinality", + ({ existence, cardinality }) => { + it("merges a part created under an existing whole", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcPart.create( + { label: "p2" }, + { id: "p2", partOf: { whole: wholeRef("w2") } }, + ); + + await planThenApply(target, source); + + expect(await livePartIds(target)).toEqual(["p1", "p2"]); + expect(await attachments(target)).toEqual({ p1: "w1", p2: "w2" }); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("merges a part created together with its whole", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcWhole.create( + { label: "w3" }, + { id: "w3" }, + ); + await source.store.nodes.GmcPart.create( + { label: "p3" }, + { id: "p3", partOf: { whole: wholeRef("w3") } }, + ); + + const merged = await merge(target, [source]); + if (isErr(merged)) throw merged.error; + + expect(await attachments(target)).toEqual({ p1: "w1", p3: "w3" }); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("merges a created part through every apply entry", async () => { + const forkPoint = await seededTarget(context, existence, cardinality); + const source = await fork(context, forkPoint); + await source.store.nodes.GmcPart.create( + { label: "p2" }, + { id: "p2", partOf: { whole: wholeRef("w1") } }, + ); + const expected = { p1: "w1", p2: "w1" }; + + // A second clone of the fork point stands in for a live target that + // is a different store from the immutable fork point. + const directFork = await fork(context, forkPoint); + const directTarget = directFork.store; + const direct = await mergeIncremental({ + forkPoint, + target: directTarget, + branches: [source], + }); + if (isErr(direct)) throw direct.error; + expect(await attachments(directTarget)).toEqual(expected); + expect(await directTarget.verifyConstraintFences()).toEqual([]); + + const plannedFork = await fork(context, forkPoint); + const plannedTarget = plannedFork.store; + const plan = unwrap( + await planMergeIncremental({ + forkPoint, + target: plannedTarget, + branches: [source], + }), + ); + expect(plan.review.compositionOrphans).toEqual([]); + await plannedTarget.transaction((tx) => + applyMergePlanInTransaction(plannedTarget, tx, plan), + ); + expect(await attachments(plannedTarget)).toEqual(expected); + expect(await plannedTarget.verifyConstraintFences()).toEqual([]); + }); + + it("merges a reparent as one move", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcPart.reparent( + asNodeId("p1"), + { + whole: wholeRef("w2"), + }, + ); + + await planThenApply(target, source); + + expect(await attachments(target)).toEqual({ p1: "w2" }); + expect(await livePartIds(target)).toEqual(["p1"]); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("merges a reparent followed by the old whole's delete", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcPart.reparent( + asNodeId("p1"), + { + whole: wholeRef("w2"), + }, + ); + await source.store.nodes.GmcWhole.delete( + asNodeId("w1"), + ); + + await planThenApply(target, source); + + expect(await attachments(target)).toEqual({ p1: "w2" }); + expect(await livePartIds(target)).toEqual(["p1"]); + expect( + await target.nodes.GmcWhole.getById(asNodeId("w1")), + ).toBeUndefined(); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("merges a create and a reparent into a history-capturing target", async () => { + // History capture records every replayed row; the plan's + // composition rows must still land as one unit there. + const target = await context.createHistoryStore( + compositionGraph(existence, cardinality), + ); + await target.nodes.GmcWhole.create({ label: "w1" }, { id: "w1" }); + await target.nodes.GmcWhole.create({ label: "w2" }, { id: "w2" }); + await target.nodes.GmcPart.create( + { label: "p1" }, + { id: "p1", partOf: { whole: wholeRef("w1") } }, + ); + const source = unwrap( + await branch(target, () => context.createIsolatedBackend(), { + id: asBranchId("branch-a"), + }), + ); + await source.store.nodes.GmcPart.reparent( + asNodeId("p1"), + { whole: wholeRef("w2") }, + ); + await source.store.nodes.GmcPart.create( + { label: "p2" }, + { id: "p2", partOf: { whole: wholeRef("w1") } }, + ); + + const merged = await merge(target, [source]); + if (isErr(merged)) throw merged.error; + + const edges = await target.edges.gmcHolds.find(); + expect( + Object.fromEntries(edges.map((edge) => [edge.fromId, edge.toId])), + ).toEqual({ p1: "w2", p2: "w1" }); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("reports no orphan for a part the plan itself deletes", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcPart.delete(asNodeId("p1")); + + await planThenApply(target, source); + + expect(await livePartIds(target)).toEqual([]); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("reports no orphan for a part a whole's delete cascades to", async () => { + const target = await seededTarget(context, existence, cardinality); + const source = await fork(context, target); + await source.store.nodes.GmcWhole.delete( + asNodeId("w1"), + ); + + await planThenApply(target, source); + + expect(await livePartIds(target)).toEqual([]); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + }, + ); + }); +} diff --git a/packages/typegraph/tests/backends/integration/graph-merge-plan.ts b/packages/typegraph/tests/backends/integration/graph-merge-plan.ts index 828c58005..2aee69628 100644 --- a/packages/typegraph/tests/backends/integration/graph-merge-plan.ts +++ b/packages/typegraph/tests/backends/integration/graph-merge-plan.ts @@ -1,9 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { asNodeId, createAdapterStoreWithSchema, + defineEdge, defineGraph, defineNode, type Store, @@ -21,6 +22,7 @@ import { import { branch } from "../../../src/graph-merge/branch"; import { BranchError, + CandidatePlanningScopeError, MergePlanCapabilityError, MergePlanningStaleError, MergePlanSchemaMismatchError, @@ -57,6 +59,37 @@ const graph = defineGraph({ type TestStore = Store; +const Binder = defineNode("PlanBinder", { schema: z.object({}) }); +const Sheet = defineNode("PlanSheet", { schema: z.object({}) }); +const filedIn = defineEdge("planFiledIn", { schema: z.object({}) }); +/** No composition yet: the evolution below is what declares the pair. */ +const compositionEvolutionGraph = defineGraph({ + id: "graph_merge_plan_composition_evolution", + nodes: { + PlanBinder: { type: Binder }, + PlanSheet: { type: Sheet }, + }, + edges: { + planFiledIn: { + type: filedIn, + from: [Sheet], + to: [Binder], + cardinality: "one", + }, + }, +}); +const requiredFilingExtension = defineGraphExtension({ + ontology: [ + { + metaEdge: "partOf", + from: "PlanSheet", + to: "PlanBinder", + via: "planFiledIn", + existence: "required", + }, + ], +}); + async function makeBranch( context: IntegrationTestContext, base: TestStore, @@ -284,7 +317,24 @@ export function registerGraphMergePlanIntegrationTests( ], edges: [], }; - const mergePlan = unwrap( + const scopedBackend = vi.fn(() => context.createIsolatedBackend()); + const scoped = await planCandidateWriteSetForEvolution({ + target, + evolutionPlan, + makeBackend: scopedBackend, + writeSet, + // @ts-expect-error the evolution planner declares no planningScope; an untyped caller is refused + planningScope: "candidate", + }); + if (!isErr(scoped)) + throw new Error("expected a candidate planning scope refusal"); + expect(scoped.error).toBeInstanceOf(CandidatePlanningScopeError); + expect(scoped.error.details).toEqual({ + planningScope: { scope: "whole-target", reason: "planned-evolution" }, + }); + expect(scopedBackend).not.toHaveBeenCalled(); + + const { plan: mergePlan, planningScope } = unwrap( await planCandidateWriteSetForEvolution({ target, evolutionPlan, @@ -292,6 +342,10 @@ export function registerGraphMergePlanIntegrationTests( writeSet, }), ); + expect(planningScope).toEqual({ + scope: "whole-target", + reason: "planned-evolution", + }); expect(mergePlan.target.schema).toEqual({ managed: true, version: evolutionPlan.result.version, @@ -390,7 +444,7 @@ export function registerGraphMergePlanIntegrationTests( }, }, }), - ); + ).plan; expect(mergePlan.review.conflicts).toEqual( expect.arrayContaining([ expect.objectContaining({ @@ -528,6 +582,38 @@ export function registerGraphMergePlanIntegrationTests( ).toBeUndefined(); }); + it("reports composition orphans under the resulting schema's registry", async () => { + const [target] = await createAdapterStoreWithSchema( + compositionEvolutionGraph, + context.getBackend(), + { revisionTracking: true }, + ); + const binder = await target.nodes.PlanBinder.create({}, { id: "binder" }); + const sheet = await target.nodes.PlanSheet.create({}, { id: "sheet" }); + const filing = await target.edges.planFiledIn.create(sheet, binder, {}); + const source = unwrap( + await branch(target, () => context.createIsolatedBackend(), { + id: asBranchId("drops-filing"), + }), + ); + await source.store.edges.planFiledIn.delete(filing.id); + const evolutionPlan = await target.planEvolution(requiredFilingExtension); + + const artifact = unwrap( + await planMergeForEvolution(target, evolutionPlan, [source]), + ); + + // Under the baseline registry the sheet owes no whole, so a merge that + // drops its only filing edge would report nothing here. + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "PlanSheet", id: "sheet" }, + viaEdgeKind: "planFiledIn", + cause: "unattached", + }, + ]); + }); + it("preserves branch failures when forking a resulting-schema branch", async () => { const store = await context.createStore(graph, { revisionTracking: true, diff --git a/packages/typegraph/tests/backends/integration/graph-merge-review-v2.ts b/packages/typegraph/tests/backends/integration/graph-merge-review-v2.ts index 87172a2dd..40006c2ea 100644 --- a/packages/typegraph/tests/backends/integration/graph-merge-review-v2.ts +++ b/packages/typegraph/tests/backends/integration/graph-merge-review-v2.ts @@ -4,6 +4,7 @@ import { z } from "zod"; import { defineGraph, defineNode } from "../../../src"; import { captureCandidateWriteSetTarget, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, planCandidateWriteSetReview, revalidateCandidateWriteSetReview, } from "../../../src/graph-merge"; @@ -21,7 +22,7 @@ const graph = defineGraph({ export function registerGraphMergeReviewV2IntegrationTests( context: IntegrationTestContext, ): void { - describe("candidate-scoped identity review V2", () => { + describe("candidate-scoped identity review", () => { it("requires renewed review after an identity assertion joins the retained closure", async () => { const target = await context.createHistoryStore(graph); const seed = await target.nodes.Person.create( @@ -68,7 +69,9 @@ export function registerGraphMergeReviewV2IntegrationTests( reviewScope: "candidate" as const, }; const review = unwrap(await planCandidateWriteSetReview(args)); - expect(review.formatVersion).toBe(2); + expect(review.formatVersion).toBe( + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ); expect(review.baseline.identityReferences).toContainEqual({ kind: "Person", id: seed.id, diff --git a/packages/typegraph/tests/backends/integration/graph-merge-review.ts b/packages/typegraph/tests/backends/integration/graph-merge-review.ts index 94e95aa0c..08fc04ace 100644 --- a/packages/typegraph/tests/backends/integration/graph-merge-review.ts +++ b/packages/typegraph/tests/backends/integration/graph-merge-review.ts @@ -10,16 +10,20 @@ import { defineNode, defineNodeIndex, disjointWith, + partOf, type Store, subClassOf, } from "../../../src"; import { defineGraphExtension } from "../../../src/graph-extension"; import { applyMergePlan, + CandidatePlanningScopeError, type CandidateWriteSet, CandidateWriteSetError, captureCandidateWriteSetTarget, ingestionBranch, + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, type MergeOptions, planCandidateWriteSet, planCandidateWriteSetReview, @@ -30,10 +34,11 @@ import { import { captureMergePlanTargetFence } from "../../../src/graph-merge/merge"; import { canonicalMergePlanJson } from "../../../src/graph-merge/plan-canonical"; import { isErr, unwrap } from "../../../src/graph-merge/result"; -import { canUseSparseCandidatePlanning } from "../../../src/graph-merge/sparse-candidate-branch"; +import { candidatePlanningScope } from "../../../src/graph-merge/sparse-candidate-branch"; import { asBranchId } from "../../../src/graph-merge/types"; import { importGraph } from "../../../src/interchange"; import type { CompiledRowsSql } from "../../../src/query/sql-intent"; +import { storeBackend } from "../../../src/store/runtime-port"; import { requireDefined } from "../../../src/utils/presence"; import type { IntegrationTestContext } from "./test-context"; @@ -168,6 +173,84 @@ const boundedOntologyIdentityGraph = defineGraph({ disjointWith(BaseKind, ExcludedKind), ], }); +const Peer = defineNode("Peer", { schema: z.object({ label: z.string() }) }); +const crossRow = defineEdge("crossRow", { schema: z.object({}) }); +const PEER_IDS = ["a", "b", "c", "d"] as const; + +/** + * A graph whose edge constraint reads rows outside a candidate's own scope. + * `seeded` and `conflicting` close the constraint over a row the sparse base + * never reads; `clean` does not. + */ +const crossRowCases = [ + { + name: "target-side cardinality", + planningScope: { scope: "whole-target", reason: "target-side-cardinality" }, + graph: defineGraph({ + id: "cross_row_target_cardinality", + nodes: { Peer: { type: Peer } }, + edges: { + crossRow: { + type: crossRow, + from: [Peer], + to: [Peer], + targetCardinality: "one", + }, + }, + }), + seeded: ["a", "c"], + conflicting: ["b", "c"], + clean: ["b", "d"], + }, + { + name: "acyclic edge", + planningScope: { scope: "whole-target", reason: "acyclic-relation" }, + graph: defineGraph({ + id: "cross_row_acyclic", + nodes: { Peer: { type: Peer } }, + edges: { + crossRow: { type: crossRow, from: [Peer], to: [Peer], acyclic: true }, + }, + }), + seeded: ["a", "b"], + conflicting: ["b", "a"], + clean: ["b", "c"], + }, + { + name: "composition", + planningScope: { scope: "whole-target", reason: "composition" }, + graph: defineGraph({ + id: "cross_row_composition", + nodes: { Peer: { type: Peer } }, + edges: { + crossRow: { + type: crossRow, + from: [Peer], + to: [Peer], + cardinality: "one", + }, + }, + ontology: [partOf(Peer, Peer, { via: crossRow, partSide: "from" })], + }), + seeded: ["a", "b"], + conflicting: ["b", "a"], + clean: ["b", "c"], + }, +] as const; +function crossRowEdge( + id: string, + [from, to]: readonly [string, string], +): CandidateWriteSet["edges"][number] { + return { + kind: "crossRow", + id, + from: { kind: "Peer", id: from }, + to: { kind: "Peer", id: to }, + properties: {}, + validFrom, + }; +} + const policy = { id: "review-policy-v1", context: { minimumApprovals: 1 }, @@ -295,8 +378,12 @@ export function registerGraphMergeReviewIntegrationTests( ) : undefined; const actualJson = - isErr(bounded) ? undefined : canonicalMergePlanJson(bounded.data); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + isErr(bounded) ? undefined : ( + canonicalMergePlanJson(bounded.data.plan) + ); + expect(candidatePlanningScope(target)).toEqual({ + scope: "candidate", + }); expect(imported.success).toBe(peerState !== "disjoint"); expect(actualJson).toBe(expectedJson); } finally { @@ -391,7 +478,7 @@ export function registerGraphMergeReviewIntegrationTests( isErr(bounded) && bounded.error instanceof CandidateWriteSetError ? bounded.error.details["errors"] : undefined; - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); expect(imported.success).toBe(false); expect(boundedImportErrors).toEqual(imported.errors); } finally { @@ -548,7 +635,9 @@ export function registerGraphMergeReviewIntegrationTests( ) : undefined; const actualJson = - isErr(bounded) ? undefined : canonicalMergePlanJson(bounded.data); + isErr(bounded) ? undefined : ( + canonicalMergePlanJson(bounded.data.plan) + ); expect(actualJson).toBe(expectedJson); } finally { await full.close(); @@ -633,7 +722,7 @@ export function registerGraphMergeReviewIntegrationTests( }, ], }; - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); const full = unwrap( await ingestionBranch(target, makeBackend, { id: asBranchId(writeSet.sourceId), @@ -682,7 +771,9 @@ export function registerGraphMergeReviewIntegrationTests( ) : undefined; const actualJson = - isErr(bounded) ? undefined : canonicalMergePlanJson(bounded.data); + isErr(bounded) ? undefined : ( + canonicalMergePlanJson(bounded.data.plan) + ); expect(actualJson).toBe(expectedJson); } finally { await full.close(); @@ -771,7 +862,9 @@ export function registerGraphMergeReviewIntegrationTests( ) : undefined; const actualJson = - isErr(bounded) ? undefined : canonicalMergePlanJson(bounded.data); + isErr(bounded) ? undefined : ( + canonicalMergePlanJson(bounded.data.plan) + ); expect(actualJson).toBe(expectedJson); } finally { await full.close(); @@ -862,7 +955,7 @@ export function registerGraphMergeReviewIntegrationTests( ); const actual = unwrap( await planCandidateWriteSet({ target, writeSet, makeBackend }), - ); + ).plan; expect(canonicalMergePlanJson(actual)).toBe( canonicalMergePlanJson(expected), ); @@ -902,7 +995,9 @@ export function registerGraphMergeReviewIntegrationTests( reviewScope: "candidate" as const, }; const review = unwrap(await planCandidateWriteSetReview(args)); - expect(review.formatVersion).toBe(2); + expect(review.formatVersion).toBe( + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ); expect(review.baseline.scope).toBe("referenced"); expect(await target.nodes.Item.getById(existing.id)).toMatchObject({ label: "Existing", @@ -921,7 +1016,89 @@ export function registerGraphMergeReviewIntegrationTests( .status, ).toBe("changed"); }); - it("preserves V1 and V2 approval decisions for an unrelated original row update", async () => { + it("refuses to revalidate a candidate-scoped review once the target can no longer be read sparsely", async () => { + const target = await context.createHistoryStore( + boundedActiveCardinalityGraph, + ); + const source = await target.nodes.Item.create(itemProps("source"), { + id: "source", + validFrom, + }); + const proposed = await target.nodes.Artifact.create( + { content: "proposed" }, + { id: "proposed", validFrom }, + ); + const writeSet: CandidateWriteSet = { + formatVersion: 1, + sourceId: "scope-capability-loss", + target: await captureCandidateWriteSetTarget(target), + nodes: [], + edges: [ + { + kind: "activeLimited", + id: "candidate-edge", + from: { kind: "Item", id: source.id }, + to: { kind: "Artifact", id: proposed.id }, + properties: {}, + validFrom, + }, + ], + }; + const args = { + target, + writeSet, + makeBackend, + policy, + reviewScope: "candidate" as const, + }; + const review = unwrap(await planCandidateWriteSetReview(args)); + expect(review.baseline.scope).toBe("referenced"); + const { + writeSet: _writeSet, + reviewScope: _reviewScope, + ...revalidate + } = args; + expect( + unwrap( + await revalidateCandidateWriteSetReview({ ...revalidate, review }), + ).status, + ).toBe("compatible"); + + const backend = storeBackend(target); + const descriptor = Object.getOwnPropertyDescriptor( + backend, + "findActiveEdgesBySourceV1", + ); + expect(descriptor).toBeDefined(); + Reflect.deleteProperty(backend, "findActiveEdgesBySourceV1"); + try { + expect(candidatePlanningScope(target)).toEqual({ + scope: "whole-target", + reason: "missing-active-source-read", + }); + const refused = await revalidateCandidateWriteSetReview({ + ...revalidate, + review, + }); + expect(isErr(refused) && refused.error.code).toBe("GRAPH_MERGE_REVIEW"); + expect(isErr(refused) && refused.error.details["reason"]).toBe( + "candidate-scope-ineligible", + ); + } finally { + if (descriptor) + Object.defineProperty( + backend, + "findActiveEdgesBySourceV1", + descriptor, + ); + } + expect( + unwrap( + await revalidateCandidateWriteSetReview({ ...revalidate, review }), + ).status, + ).toBe("compatible"); + }); + it("preserves whole-target and candidate-scoped approval decisions for an unrelated original row update", async () => { const target = await context.createHistoryStore(boundedGraph); const candidate = await target.nodes.Item.create(itemProps("candidate"), { id: "candidate", @@ -953,8 +1130,10 @@ export function registerGraphMergeReviewIntegrationTests( reviewScope: "candidate", }), ); - expect(v1.formatVersion).toBe(1); - expect(v2.formatVersion).toBe(2); + expect(v1.formatVersion).toBe(MERGE_REVIEW_FORMAT_VERSION); + expect(v2.formatVersion).toBe( + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ); expect(v2.plan.writes).toEqual(v1.plan.writes); await target.nodes.Artifact.update(unrelated.id, { content: "revised" }); @@ -976,7 +1155,7 @@ export function registerGraphMergeReviewIntegrationTests( }); expect(v2Decision.status).toBe("compatible"); }); - it("bounds V2 review reads across unrelated rows and identity assertions", async () => { + it("bounds candidate-scoped review reads across unrelated rows and identity assertions", async () => { const target = await context.createHistoryStore(boundedIdentityGraph); const candidate = await target.nodes.Item.create(itemProps("candidate"), { id: "candidate", @@ -1112,7 +1291,9 @@ export function registerGraphMergeReviewIntegrationTests( expect(await captureMergePlanTargetFence(args.target)).toEqual( beforeRevalidation, ); - unwrap(await applyMergePlan(args.target, result.plan)); + unwrap( + await applyMergePlan(args.target, result.plan, { review: result }), + ); expect(await args.target.nodes.Item.getById(proposed.id)).toMatchObject({ status: "accepted", }); @@ -1133,7 +1314,7 @@ export function registerGraphMergeReviewIntegrationTests( ); const review = unwrap(await planCandidateWriteSetReview(args)); await args.target.nodes.Item.update(item.id, { status: "rejected" }); - const fresh = unwrap(await planCandidateWriteSet(args)); + const fresh = unwrap(await planCandidateWriteSet(args)).plan; expect(fresh.writes).toEqual(review.plan.writes); const result = unwrap( await revalidateCandidateWriteSetReview({ ...args, review }), @@ -1534,4 +1715,184 @@ export function registerGraphMergeReviewIntegrationTests( ).toBeUndefined(); }); }); + + describe("candidate planning on a graph with cross-row constraints", () => { + async function seededTarget(testCase: (typeof crossRowCases)[number]) { + const target = await context.createHistoryStore(testCase.graph); + const seeded = await importGraph( + target, + { + formatVersion: "2.0", + exportedAt: "1970-01-01T00:00:00.000Z", + source: { type: "external" }, + nodes: PEER_IDS.map((id) => ({ + kind: "Peer", + id, + properties: { label: id }, + validFrom, + })), + edges: [crossRowEdge("seed-edge", testCase.seeded)], + }, + { onConflict: "error", onUnknownProperty: "error" }, + ); + expect(seeded.success).toBe(true); + return target; + } + + async function writeSetOf( + target: Awaited>, + edge: CandidateWriteSet["edges"][number], + ): Promise { + return { + formatVersion: 1, + sourceId: "cross-row-source", + target: await captureCandidateWriteSetTarget(target), + nodes: [], + edges: [edge], + }; + } + + describe.each(crossRowCases)("$name", (testCase) => { + it("plans against the whole target instead of the sparse base", async () => { + const target = await seededTarget(testCase); + expect(candidatePlanningScope(target)).toEqual(testCase.planningScope); + + const conflicting = await writeSetOf( + target, + crossRowEdge("candidate-edge", testCase.conflicting), + ); + const refused = await planCandidateWriteSet({ + target, + writeSet: conflicting, + makeBackend, + }); + expect(isErr(refused)).toBe(true); + + const clean = await writeSetOf( + target, + crossRowEdge("candidate-edge", testCase.clean), + ); + let stagedIds: readonly string[] = []; + const { plan: planned, planningScope } = unwrap( + await planCandidateWriteSet({ + target, + writeSet: clean, + makeBackend: async () => { + const staging = await makeBackend(); + const readStaged = staging.findNodesAcrossKinds; + if (readStaged === undefined) + throw new Error("Expected cross-kind read"); + vi.spyOn(staging, "findNodesAcrossKinds").mockImplementation( + async (query) => { + const rows = await readStaged(query); + stagedIds = rows.map((row) => row.id); + return rows; + }, + ); + return staging; + }, + }), + ); + // A sparse base stages only the rows the candidate touches; the + // whole-target fallback stages every target row. + expect(stagedIds.toSorted()).toEqual([...PEER_IDS]); + expect(planningScope).toEqual(testCase.planningScope); + const full = unwrap( + await ingestionBranch(target, makeBackend, { + id: asBranchId(clean.sourceId), + }), + ); + try { + const imported = await importGraph( + full, + { + formatVersion: "2.0", + exportedAt: "1970-01-01T00:00:00.000Z", + source: { type: "external" }, + nodes: clean.nodes, + edges: clean.edges, + }, + { + onConflict: "update", + onUnknownProperty: "error", + validateReferences: true, + refreshStatistics: false, + }, + ); + expect(imported.success).toBe(true); + const expected = unwrap( + await planMergeIncremental({ + forkPoint: target, + target, + branches: [full], + }), + ); + expect(canonicalMergePlanJson(planned)).toBe( + canonicalMergePlanJson(expected), + ); + } finally { + await full.close(); + } + }); + + it("refuses an explicitly requested candidate planning scope before staging", async () => { + const target = await seededTarget(testCase); + const writeSet = await writeSetOf( + target, + crossRowEdge("candidate-edge", testCase.clean), + ); + const planBackend = vi.fn(makeBackend); + + const refused = await planCandidateWriteSet({ + target, + writeSet, + makeBackend: planBackend, + planningScope: "candidate", + }); + if (!isErr(refused)) + throw new Error( + `expected a candidate planning scope refusal, planned ${JSON.stringify(refused.data.planningScope)}`, + ); + expect(refused.error).toBeInstanceOf(CandidatePlanningScopeError); + expect(refused.error.code).toBe("GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE"); + expect(refused.error.details).toEqual({ + planningScope: testCase.planningScope, + }); + expect(planBackend).not.toHaveBeenCalled(); + }); + + it("refuses an explicitly requested candidate review scope and keeps the whole-target review", async () => { + const target = await seededTarget(testCase); + const writeSet = await writeSetOf( + target, + crossRowEdge("candidate-edge", testCase.clean), + ); + const planBackend = vi.fn(makeBackend); + + const scoped = await planCandidateWriteSetReview({ + target, + writeSet, + makeBackend: planBackend, + policy, + reviewScope: "candidate", + }); + expect(isErr(scoped) && scoped.error.code).toBe("GRAPH_MERGE_REVIEW"); + expect(isErr(scoped) && scoped.error.details).toEqual({ + reason: "candidate-scope-ineligible", + planningScope: testCase.planningScope, + }); + expect(planBackend).not.toHaveBeenCalled(); + + const whole = unwrap( + await planCandidateWriteSetReview({ + target, + writeSet, + makeBackend, + policy, + }), + ); + expect(whole.formatVersion).toBe(MERGE_REVIEW_FORMAT_VERSION); + }); + }); + }); } diff --git a/packages/typegraph/tests/backends/integration/graph-merge-target-cardinality.ts b/packages/typegraph/tests/backends/integration/graph-merge-target-cardinality.ts new file mode 100644 index 000000000..ef284c4e3 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/graph-merge-target-cardinality.ts @@ -0,0 +1,158 @@ +/** + * Target-side edge cardinality during a graph merge (issue #610, merge half). + * + * Two distinct target nodes, each already holding one incoming edge of a + * `targetCardinality: "one"` kind, are reconciled into ONE canonical node by + * `planMerge`'s dedup resolution. `applyMergePlan` repoints both edges' + * target endpoint to the canonical node (`repoint()` in + * `src/graph-merge/edge-repoint.ts`, T9) — after which the SAME target node + * holds two live incoming edges, which the target-cardinality axis refuses + * exactly as it would for two ordinary writes to that node. + * + * Every case states, in its own comment, the mutation/revert that must make + * it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineEdge, defineGraph, defineNode, type Store } from "../../../src"; +import { + isErr, + type MergeConstraintConflictError, + unwrap, +} from "../../../src/graph-merge"; +import { branch } from "../../../src/graph-merge/branch"; +import { applyMergePlan, planMerge } from "../../../src/graph-merge/merge"; +import { asBranchId, type GraphBranch } from "../../../src/graph-merge/types"; +import { type IntegrationTestContext } from "./test-context"; + +const Assignee = defineNode("GmtAssignee", { schema: z.object({}) }); +const Target = defineNode("GmtTarget", { + schema: z.object({ name: z.string(), externalId: z.string() }), +}); +const assignedTo = defineEdge("gmtAssignedTo", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "graph_merge_target_cardinality", + nodes: { + GmtAssignee: { type: Assignee }, + GmtTarget: { + type: Target, + unique: [ + { + name: "gmt_target_external_id", + fields: ["externalId"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + gmtAssignedTo: { + type: assignedTo, + from: [Assignee], + to: [Target], + targetCardinality: "one", + }, + }, +}); + +type TestStore = Store; + +async function makeBranch( + context: IntegrationTestContext, + base: TestStore, + id: string, +): Promise> { + return unwrap( + await branch(base, () => context.createIsolatedBackend(), { + id: asBranchId(id), + }), + ); +} + +export function registerGraphMergeTargetCardinalityIntegrationTests( + context: IntegrationTestContext, +): void { + describe("target-side edge cardinality during graph merge", () => { + it("refuses a merge that would collapse two independently-assigned targets into one", async () => { + const base = await context.createStore(graph, { + revisionTracking: true, + }); + const left = await makeBranch(context, base, "left"); + const right = await makeBranch(context, base, "right"); + + await left.store.nodes.GmtTarget.create( + { name: "Same Target", externalId: "shared-external-id" }, + { id: "target-left" }, + ); + await left.store.nodes.GmtAssignee.create({}, { id: "assignee-left" }); + await left.store.edges.gmtAssignedTo.create( + { kind: "GmtAssignee", id: "assignee-left" }, + { kind: "GmtTarget", id: "target-left" }, + {}, + ); + + await right.store.nodes.GmtTarget.create( + { name: "Same Target", externalId: "shared-external-id" }, + { id: "target-right" }, + ); + await right.store.nodes.GmtAssignee.create({}, { id: "assignee-right" }); + await right.store.edges.gmtAssignedTo.create( + { kind: "GmtAssignee", id: "assignee-right" }, + { kind: "GmtTarget", id: "target-right" }, + {}, + ); + + const artifact = unwrap( + await planMerge(base, [left, right], { + resolve: { + GmtTarget: { + block: (node) => node.externalId, + similarity: { kind: "fulltext", fields: ["name"] }, + threshold: 1, + }, + }, + }), + ); + // Sanity: the plan really did resolve the two targets into one + // canonical row — otherwise this test would trivially pass for the + // wrong reason (no collapse, so no conflict to refuse). + expect(artifact.review.resolutions.length).toBeGreaterThanOrEqual(1); + + const result = await applyMergePlan(base, artifact); + + expect(isErr(result)).toBe(true); + if (!isErr(result)) throw new Error("Expected the merge to be refused"); + const error = result.error as MergeConstraintConflictError; + expect(error.name).toBe("MergeConstraintConflictError"); + expect(error.details.constraintErrorName).toBe("CardinalityError"); + expect(error.details.constraintDetails["direction"]).toBe("target"); + + // Nothing committed: the whole node+edge+provenance transaction rolled + // back, not just the write that tripped the fence. + expect(await base.nodes.GmtTarget.count()).toBe(0); + expect(await base.nodes.GmtAssignee.count()).toBe(0); + expect(await base.edges.gmtAssignedTo.count()).toBe(0); + }); + // MUTATION CHECK (verified): the actual repoint owner is `repoint()` in + // `src/graph-merge/edge-repoint.ts` (T9, `repointEdges`), not + // `finalEdgeEndpoint` in `merge.ts` (that one resolves an edge's + // endpoint through the retype map for a narrower, identity-driven + // cross-KIND case; ordinary duplicate-node collapse is a T9 concern). + // Reverting `const toKey = repoint(sourceToKey, canonicalOf);` to + // `const toKey = sourceToKey;` (leave the target endpoint + // uncanonicalized) makes the two edges keep their own, never-merged-away + // target ids — no target-cardinality conflict is possible — and this + // case fails. The observed failure is not the guessed "apply succeeds" + // (`isErr` false): the artifact becomes internally inconsistent (a + // dropped node with a live inbound edge still naming it) and + // `applyMergePlan` instead returns a DIFFERENT typed refusal, + // `InvalidMergePlanError`, so `error.name` reads that instead of + // `MergeConstraintConflictError` and the assertion on `error.name` + // fails. Either way, this pins that the conflict here is a genuine + // POST-repoint one, not a plan-time coincidence the setup already + // ensured some other way. + }); +} diff --git a/packages/typegraph/tests/backends/integration/identity-current-traversal.ts b/packages/typegraph/tests/backends/integration/identity-current-traversal.ts index 8ba61d70e..978872c0b 100644 --- a/packages/typegraph/tests/backends/integration/identity-current-traversal.ts +++ b/packages/typegraph/tests/backends/integration/identity-current-traversal.ts @@ -18,7 +18,7 @@ * its cases is there to catch; the historical half of the same contract lives in * `identity-historical-traversal.ts`. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { compareStrings } from "../../../src/utils/compare"; import { @@ -29,7 +29,6 @@ import { IGNORE_GRAPH, provisionIdentityFixture, readLedger, - settle, } from "./identity-traversal-model"; import { type IntegrationTestContext } from "./test-context"; @@ -247,41 +246,55 @@ export function registerCurrentIdentityTraversalTests( * of that: nothing about the graph changes, only the clock. */ it("binds a fresh read instant per execution of a reused query", async () => { - const store = await context.createStore(IGNORE_GRAPH); - const alice = await store.nodes.Person.create( - { name: "Alice" }, - { id: "instant-alice" }, - ); - const peer = await store.nodes.Person.create( - { name: "Peer" }, - { - id: "instant-peer", - validTo: new Date(Date.now() + 500).toISOString(), - }, - ); - const target = await store.nodes.Person.create( - { name: "Target" }, - { id: "instant-target" }, - ); - // The edge leaves the peer and outlives it, so only a stale read instant - // could keep returning the target after the peer's window closes. - await store.edges.link.create(peer, target, {}, { id: "instant-peer-t" }); - await store.identity.assertSame(alice, peer); + vi.useFakeTimers({ toFake: ["Date"] }); + try { + const peerWindowCloses = Date.now() + 1000; + const store = await context.createStore(IGNORE_GRAPH); + const alice = await store.nodes.Person.create( + { name: "Alice" }, + { id: "instant-alice" }, + ); + const peer = await store.nodes.Person.create( + { name: "Peer" }, + { + id: "instant-peer", + validTo: new Date(peerWindowCloses).toISOString(), + }, + ); + const target = await store.nodes.Person.create( + { name: "Target" }, + { id: "instant-target" }, + ); + // The edge leaves the peer and outlives it, so only a stale read + // instant could keep returning the target after the peer's window + // closes. + await store.edges.link.create( + peer, + target, + {}, + { id: "instant-peer-t" }, + ); + await store.identity.assertSame(alice, peer); - const query = store - .query() - .from("Person", "person") - .whereNode("person", (node) => node.name.eq("Alice")) - .traverse("link", "edge", { - expand: "none", - includeIdentityMembers: true, - }) - .to("Person", "friend") - .select((queryContext) => queryContext.friend.id); + const query = store + .query() + .from("Person", "person") + .whereNode("person", (node) => node.name.eq("Alice")) + .traverse("link", "edge", { + expand: "none", + includeIdentityMembers: true, + }) + .to("Person", "friend") + .select((queryContext) => queryContext.friend.id); - expect(await query.execute()).toEqual([target.id]); - await settle(700); - expect(await query.execute()).toEqual([]); + expect(await query.execute()).toEqual([target.id]); + vi.setSystemTime(peerWindowCloses - 1); + expect(await query.execute()).toEqual([target.id]); + vi.setSystemTime(peerWindowCloses); + expect(await query.execute()).toEqual([]); + } finally { + vi.useRealTimers(); + } }); }); } diff --git a/packages/typegraph/tests/backends/integration/identity-import.ts b/packages/typegraph/tests/backends/integration/identity-import.ts index bac4e0ad5..b2fb1e246 100644 --- a/packages/typegraph/tests/backends/integration/identity-import.ts +++ b/packages/typegraph/tests/backends/integration/identity-import.ts @@ -1,17 +1,24 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { createStoreWithSchema, defineGraph, defineNode } from "../../../src"; +import { + createStoreWithSchema, + defineGraph, + defineNode, + IdentityEndpointValidityError, +} from "../../../src"; import { snapshotExportContention } from "../../../src/backend/transaction-resource"; import { type IdentityTransferAssertion } from "../../../src/identity/service"; import { exportGraph, exportGraphStream, + FORMAT_VERSION, importGraph, importGraphStream, } from "../../../src/interchange"; import { storeBackend, storeRuntime } from "../../../src/store/runtime-port"; import { type Store } from "../../../src/store/store"; +import { requireDefined } from "../../../src/utils/presence"; import { createTestBackend, expectAuditedBackend, @@ -167,10 +174,203 @@ async function createStreamingImportTarget( return target; } +/** A one-node document stating that node's window end. */ +function windowEndDocument( + ref: Ref, + properties: Record, + validTo: string, +) { + return { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external" as const }, + nodes: [{ ...ref, properties, validTo }], + edges: [], + }; +} + +/** A same-id fold class: Person and Author both named `shared`. */ +async function makeFoldClass(context: IntegrationTestContext, graphId: string) { + const store = await context.createHistoryStore( + identityInterchangeGraph(graphId), + ); + await store.nodes.Person.create({ name: "S" }, { id: "shared" }); + await store.nodes.Author.create({ penName: "S" }, { id: "shared" }); + return store; +} + +/** + * How many `window-end` transitions the fold class records once `endWindow` + * has ended the Person member's window. + */ +async function windowEndTransitions( + context: IntegrationTestContext, + graphId: string, + endWindow: ( + store: Awaited>, + validTo: string, + ) => Promise, +): Promise { + const store = await makeFoldClass(context, graphId); + await endWindow(store, isoAt(24 * HOUR_MS)); + const history = await store.identity.transitionsOf({ + kind: "Author", + id: "shared", + }); + return history.transitions.filter( + (transition) => transition.cause === "window-end", + ).length; +} + export function registerIdentityImportIntegrationTests( context: IntegrationTestContext, ): void { describe("Operational Identity interchange", () => { + describe("a node window end stated by onConflict: update", () => { + it("refuses an end an open identity assertion would outlive, as the store update does", async () => { + const store = await context.createHistoryStore( + identityInterchangeGraph("identity_import_window_end_refusal"), + ); + const a = await store.nodes.Person.create({ name: "A" }, { id: "a" }); + const b = await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame(a, b); + const validTo = isoAt(24 * HOUR_MS); + + await expect( + store.nodes.Person.update(a.id, {}, { validTo }), + ).rejects.toBeInstanceOf(IdentityEndpointValidityError); + + const result = await importGraph( + store, + windowEndDocument( + { kind: "Person", id: "a" }, + { name: "A" }, + validTo, + ), + { onConflict: "update" }, + ); + console.info("import window-end refusal", { + nodes: result.nodes, + errors: result.errors, + }); + expect(result.nodes).toEqual({ created: 0, updated: 0, skipped: 0 }); + expect(result.errors).toEqual([ + matchingObject({ entityType: "node", kind: "Person", id: "a" }), + ]); + const stored = await store.nodes.Person.getById(a.id); + expect(stored?.meta.validTo).toBeUndefined(); + expect(await store.identity.assertionsOf(a)).toHaveLength(1); + }); + + it("records no window-end transition for a row the import then refuses", async () => { + const UniquePerson = defineNode("Person", { + schema: z.object({ name: z.string() }), + }); + const store = await context.createHistoryStore( + defineGraph({ + id: "identity_import_window_end_refused_row", + nodes: { + Person: { + type: UniquePerson, + unique: [ + { + name: "iwe_person_name", + fields: ["name"], + scope: "kind", + collation: "binary", + }, + ], + }, + Author: { type: InterchangeAuthor }, + }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }), + ); + await store.nodes.Person.create({ name: "S" }, { id: "shared" }); + await store.nodes.Author.create({ penName: "S" }, { id: "shared" }); + + // The first row reserves "Dup", so the second row's update, which also + // states a window end for a fold-class member, is refused on its own + // claim after the end was decided. + const result = await importGraph( + store, + { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external" as const }, + nodes: [ + { kind: "Person", id: "fresh", properties: { name: "Dup" } }, + { + kind: "Person", + id: "shared", + properties: { name: "Dup" }, + validTo: isoAt(24 * HOUR_MS), + }, + ], + edges: [], + }, + { onConflict: "update" }, + ); + const history = await store.identity.transitionsOf({ + kind: "Author", + id: "shared", + }); + const causes = history.transitions.map( + (transition) => transition.cause, + ); + console.info("refused-row import", { + nodes: result.nodes, + errors: result.errors, + causes, + }); + + expect(result.nodes).toEqual({ created: 1, updated: 0, skipped: 0 }); + expect(result.errors).toEqual([ + matchingObject({ entityType: "node", kind: "Person", id: "shared" }), + ]); + expect(causes).not.toContain("window-end"); + const stored = await store.nodes.Person.getById("shared" as never); + expect(stored?.meta.validTo).toBeUndefined(); + }); + + it("records the window-end transition the store update records", async () => { + const viaStore = await windowEndTransitions( + context, + "identity_import_window_end_store", + async (store, validTo) => { + const person = await store.nodes.Person.getById("shared" as never); + await store.nodes.Person.update( + requireDefined(person).id, + {}, + { validTo }, + ); + }, + ); + const viaImport = await windowEndTransitions( + context, + "identity_import_window_end_import", + async (store, validTo) => { + const result = await importGraph( + store, + windowEndDocument( + { kind: "Person", id: "shared" }, + { name: "S" }, + validTo, + ), + { onConflict: "update" }, + ); + expect(result.errors).toEqual([]); + expect(result.nodes.updated).toBe(1); + }, + ); + + console.info("window-end transitions", { viaStore, viaImport }); + expect(viaStore).toBe(1); + expect(viaImport).toBe(viaStore); + }); + }); + it("round-trips state identity and re-imports it idempotently", async () => { const { store: source, alice, author, bob } = await seedSource(context); await source.identity.assertSame(alice, author); diff --git a/packages/typegraph/tests/backends/integration/identity-replay.ts b/packages/typegraph/tests/backends/integration/identity-replay.ts new file mode 100644 index 000000000..b4eb8954b --- /dev/null +++ b/packages/typegraph/tests/backends/integration/identity-replay.ts @@ -0,0 +1,1450 @@ +/** + * Cross-backend replay equivalence: assert / assert / retract / + * re-assert must replay into steps whose `after` matches the live closure at + * each revision, on every backend `createIntegrationTestSuite` runs against — + * the backend-parity rule (AGENTS.md) applied to the replay contract. + * + * Exercises the PUBLIC facade (`store.identity.replay`), not the internal + * `identityReplay` module function it wraps: parity is asserted on what a + * caller actually gets, and the two could in principle diverge if a future + * change touched only the facade's own thin marshalling. + */ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + createStoreWithSchema, + defineGraph, + defineGraphExtension, + defineNode, + IdentityReplayError, + pruneIdentityTransitions, + type TransitionPageCursor, + ValidationError, +} from "../../../src"; +import { + asRecordedInstant, + createRecordedInstant, + recordedInstantRevision, + recordedInstantWallTime, +} from "../../../src/core/temporal"; +import { + exportGraph, + exportGraphStream, + type GraphInterchangeChunk, + importGraph, + importGraphStream, +} from "../../../src/interchange"; +import { getActiveSchema, migrateSchema } from "../../../src/schema"; +import { requireDefined } from "../../../src/utils/presence"; +import { + completenessReason, + truncatedBeforeOf, +} from "../../identity-history-completeness"; +import { type IntegrationTestContext } from "./test-context"; + +const ReplayPerson = defineNode("Person", { schema: z.object({}) }); + +const identityReplayGraph = defineGraph({ + id: "identity_replay_parity", + nodes: { Person: { type: ReplayPerson } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +async function provisionIdentityReplayStore(context: IntegrationTestContext) { + const backend = context.getStore().backend; + const [store] = await createStoreWithSchema(identityReplayGraph, backend, { + history: true, + }); + return store; +} + +const DepartedPerson = defineNode("Person", { schema: z.object({}) }); +const DepartedOrg = defineNode("Org", { schema: z.object({}) }); + +function departedLineageGraph(id: string) { + return defineGraph({ + id, + nodes: { Person: { type: DepartedPerson }, Org: { type: DepartedOrg } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); +} + +const departedLineageSourceGraph = departedLineageGraph( + "identity_departed_lineage", +); +const departedLineageRestoreGraph = departedLineageGraph( + "identity_departed_lineage_restore", +); + +async function provisionDepartedLineageStore( + context: IntegrationTestContext, + graph: ReturnType, +) { + const [store] = await createStoreWithSchema( + graph, + context.getStore().backend, + { history: true }, + ); + return store; +} + +type DepartedLineageStore = Awaited< + ReturnType +>; + +const FlipPerson = defineNode("Person", { schema: z.object({}) }); +const FlipRobot = defineNode("Robot", { schema: z.object({}) }); + +function profileFlipGraph(sameIdAcrossKinds: "fold" | "ignore") { + return defineGraph({ + id: "identity_replay_profile_flip", + nodes: { Person: { type: FlipPerson }, Robot: { type: FlipRobot } }, + edges: {}, + identity: { sameIdAcrossKinds }, + }); +} + +/** How far ahead of the faked clock a future-dated validity window ends. */ +const WINDOW_LAPSE_MS = 250; +const WINDOW_LAPSE_CLOCK_START = "2026-01-01T00:00:00.000Z"; + +function personRef(id: string) { + return { kind: "Person" as const, id }; +} + +function uncapturedCount(count: number) { + return { + status: "partial", + reasons: [{ reason: "uncaptured-members", count }], + }; +} + +const DISJOINT_PAIR_COUNT = 6; + +function disjointPair(index: number) { + return [ + personRef(`attribution-${String(index)}-a`), + personRef(`attribution-${String(index)}-b`), + ] as const; +} + +const DEPARTURES = [ + { + name: "soft-deleted", + depart: (store: DepartedLineageStore, id: string) => + store.nodes.Person.delete(asNodeId(id)), + }, + { + name: "hard-deleted", + depart: (store: DepartedLineageStore, id: string) => + store.nodes.Person.hardDelete(asNodeId(id)), + }, +] as const; + +function memberKeys( + members: readonly Readonly<{ kind: string; id: string }>[], +): readonly string[] { + return members.map((member) => `${member.kind}:${member.id}`).toSorted(); +} + +async function provisionDisjointPairs(context: IntegrationTestContext) { + const store = await provisionIdentityReplayStore(context); + const pairs = Array.from({ length: DISJOINT_PAIR_COUNT }, (_, index) => + disjointPair(index), + ); + for (const [a, b] of pairs) { + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + } + const asserted = await store.identity.bulkAssertSame( + pairs.map(([a, b]) => ({ a, b })), + ); + return { store, pairs, asserted }; +} + +async function provisionPagedLineage(context: IntegrationTestContext) { + const store = await provisionIdentityReplayStore(context); + const a = { kind: "Person" as const, id: "cursor-a" }; + const b = { kind: "Person" as const, id: "cursor-b" }; + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + const merge = await store.identity.assertSame(a, b); + await store.identity.retractAssertion(merge.assertion.id); + await store.identity.assertSame(a, b); + const firstPage = await store.identity.transitionsOf(a, { limit: 1 }); + const cursor = requireDefined(firstPage.nextCursor); + return { store, a, firstPage, cursor }; +} + +export function registerIdentityReplayIntegrationTests( + context: IntegrationTestContext, +): void { + describe("identity transition retention", () => { + it("refuses a prune watermark beyond the revision the next commit takes, and accepts exactly that revision", async () => { + const store = await provisionIdentityReplayStore(context); + const a = { kind: "Person" as const, id: "prune-a" }; + const b = { kind: "Person" as const, id: "prune-b" }; + const c = { kind: "Person" as const, id: "prune-c" }; + const d = { kind: "Person" as const, id: "prune-d" }; + for (const ref of [a, b, c, d]) { + await store.nodes.Person.create({}, { id: ref.id }); + } + await store.identity.assertSame(a, b); + const clock = requireDefined(await store.recordedNow()); + const clockRevision = recordedInstantRevision(clock); + const wallTime = recordedInstantWallTime(clock); + const beyondNextCommit = createRecordedInstant( + clockRevision + 2, + wallTime, + ); + + const refusal = await pruneIdentityTransitions(store, { + beforeRecorded: beyondNextCommit, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(IdentityReplayError); + expect((refusal as IdentityReplayError).details).toEqual({ + code: "IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK", + requestedBefore: beyondNextCommit, + requestedRevision: clockRevision + 2, + highestPrunableRevision: clockRevision + 1, + }); + + // The refusal installed nothing and deleted nothing. + const untouched = await store.identity.replay(a); + expect(truncatedBeforeOf(untouched)).toBeUndefined(); + expect(untouched.steps.map((step) => step.transition.cause)).toEqual([ + "assert", + ]); + + // The next commit's own revision is the highest truthful watermark: + // everything recorded so far is below it, everything later is not. + const nextCommit = createRecordedInstant(clockRevision + 1, wallTime); + const pruned = await pruneIdentityTransitions(store, { + beforeRecorded: nextCommit, + }); + expect(pruned).toEqual({ + pruned: 1, + prunedBeforeRevision: clockRevision + 1, + }); + + await store.identity.assertSame(c, d); + const latest = requireDefined(await store.recordedNow()); + const fresh = await store.identity.replay(c, { + fromRecorded: latest, + toRecorded: latest, + }); + expect(truncatedBeforeOf(fresh)).toBeUndefined(); + expect(fresh.steps.map((step) => step.transition.cause)).toEqual([ + "assert", + ]); + }); + + it("never lowers the watermark: a prune below it reports, keeps and exports the higher watermark", async () => { + const store = await provisionIdentityReplayStore(context); + const [first, second, third] = ["first", "second", "third"].map( + (name) => + [ + personRef(`monotone-${name}-a`), + personRef(`monotone-${name}-b`), + ] as const, + ) as [ + readonly [ReturnType, ReturnType], + readonly [ReturnType, ReturnType], + readonly [ReturnType, ReturnType], + ]; + for (const ref of [...first, ...second, ...third]) { + await store.nodes.Person.create({}, { id: ref.id }); + } + await store.identity.assertSame(...first); + await store.identity.assertSame(...second); + const earlier = requireDefined(await store.recordedNow()); + await store.identity.assertSame(...third); + const later = requireDefined(await store.recordedNow()); + const earlierRevision = recordedInstantRevision(earlier); + const laterRevision = recordedInstantRevision(later); + expect(laterRevision).toBeGreaterThan(earlierRevision); + + const high = await pruneIdentityTransitions(store, { + beforeRecorded: later, + }); + expect(high).toEqual({ pruned: 2, prunedBeforeRevision: laterRevision }); + + const low = await pruneIdentityTransitions(store, { + beforeRecorded: earlier, + }); + expect(low).toEqual({ pruned: 0, prunedBeforeRevision: laterRevision }); + + // The (earlier, later) range lost its rows to the first prune, so a + // read starting inside it must still name the higher cut. + const fromEarlier = await store.identity.transitionsOf(second[0], { + fromRecorded: earlier, + }); + expect(fromEarlier.transitions).toEqual([]); + expect( + recordedInstantRevision(requireDefined(truncatedBeforeOf(fromEarlier))), + ).toBe(laterRevision); + const replay = await store.identity.replay(third[0], { + fromRecorded: earlier, + }); + expect( + recordedInstantRevision(requireDefined(truncatedBeforeOf(replay))), + ).toBe(laterRevision); + + const archive = await exportGraph(store, { identityMode: "archival" }); + expect(archive.identity?.retention?.prunedBeforeRevision).toBe( + laterRevision, + ); + }); + + it("prunes restored transitions whose revisions sit above the destination clock, without raising the watermark past it", async () => { + const source = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_prune_restored_source"), + ); + const ids = ["pr-a", "pr-b", "pr-c", "pr-d", "pr-e"]; + for (const id of ids) await source.nodes.Person.create({}, { id }); + for (const [left, right] of [ + ["pr-a", "pr-b"], + ["pr-b", "pr-c"], + ["pr-c", "pr-d"], + ["pr-d", "pr-e"], + ] as const) { + await source.identity.assertSame(personRef(left), personRef(right)); + } + const archive = await exportGraph(source, { identityMode: "archival" }); + + const target = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_prune_restored_target"), + ); + await target.nodes.Person.create({}, { id: "pr-native" }); + const imported = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(imported.errors).toEqual([]); + + const targetClock = requireDefined(await target.recordedNow()); + const nextRevision = recordedInstantRevision(targetClock) + 1; + const restoredHistory = await target.identity.transitionsOf( + personRef("pr-a"), + ); + const restoredRevisions = restoredHistory.transitions + .filter((transition) => transition.restored !== undefined) + .map((transition) => + recordedInstantRevision(asRecordedInstant(transition.recorded)), + ); + const highestRestored = Math.max(...restoredRevisions); + expect(highestRestored).toBeGreaterThanOrEqual(nextRevision); + + const beyondEverything = createRecordedInstant( + highestRestored + 2, + recordedInstantWallTime(targetClock), + ); + const refusal = await pruneIdentityTransitions(target, { + beforeRecorded: beyondEverything, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(IdentityReplayError); + expect((refusal as IdentityReplayError).details).toMatchObject({ + code: "IDENTITY_PRUNE_BEYOND_RECORDED_CLOCK", + highestPrunableRevision: highestRestored + 1, + }); + + const pruned = await pruneIdentityTransitions(target, { + beforeRecorded: createRecordedInstant( + highestRestored + 1, + recordedInstantWallTime(targetClock), + ), + }); + expect(pruned.prunedBeforeRevision).toBe(nextRevision); + expect(pruned.pruned).toBeGreaterThanOrEqual(restoredRevisions.length); + const after = await target.identity.transitionsOf(personRef("pr-a")); + expect(after.transitions).toEqual([]); + expect( + completenessReason(after, "unattributed-restored"), + ).toBeUndefined(); + + // The watermark stays on the destination's own axis, so its next + // commit is not reported as pruned. + await target.nodes.Person.create({}, { id: "pr-later-a" }); + await target.nodes.Person.create({}, { id: "pr-later-b" }); + await target.identity.assertSame( + personRef("pr-later-a"), + personRef("pr-later-b"), + ); + const latest = requireDefined(await target.recordedNow()); + const fresh = await target.identity.replay(personRef("pr-later-a"), { + fromRecorded: latest, + toRecorded: latest, + }); + expect(truncatedBeforeOf(fresh)).toBeUndefined(); + }); + }); + + describe("archival restore sets one watermark on either entry point", () => { + for (const variant of ["unpruned", "pruned"] as const) { + it(`importGraph and importGraphStream agree on the retention cut for an ${variant} source`, async () => { + const source = await provisionDepartedLineageStore( + context, + departedLineageGraph(`identity_restore_parity_source_${variant}`), + ); + const alice = personRef("rp-alice"); + const bob = personRef("rp-bob"); + await source.nodes.Person.create({}, { id: alice.id }); + await source.nodes.Person.create({}, { id: bob.id }); + const first = await source.identity.assertSame(alice, bob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, bob); + if (variant === "pruned") { + const history = await source.identity.transitionsOf(alice); + await pruneIdentityTransitions(source, { + beforeRecorded: requireDefined(history.transitions.at(-1)).recorded, + }); + } + const archive = await exportGraph(source, { + identityMode: "archival", + }); + // Collected before any import: one backend serves source and targets. + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push(chunk); + } + expect(chunks.map((chunk) => chunk.type)).toContain("identity"); + expect(chunks.at(-1)?.type).toBe("identity-transitions"); + + const documentTarget = await provisionDepartedLineageStore( + context, + departedLineageGraph(`identity_restore_parity_document_${variant}`), + ); + const streamTarget = await provisionDepartedLineageStore( + context, + departedLineageGraph(`identity_restore_parity_stream_${variant}`), + ); + const documentResult = await importGraph(documentTarget, archive, { + onConflict: "skip", + }); + const streamResult = await importGraphStream( + streamTarget, + (async function* replayChunks() { + await Promise.resolve(); + yield* chunks; + })(), + { onConflict: "skip" }, + ); + expect(documentResult.errors).toEqual([]); + expect(streamResult.errors).toEqual([]); + + const documentReplay = await documentTarget.identity.replay(alice); + const documentFloor = requireDefined(truncatedBeforeOf(documentReplay)); + const streamReplay = await streamTarget.identity.replay(alice); + expect( + recordedInstantRevision( + requireDefined(truncatedBeforeOf(streamReplay)), + ), + ).toBe(recordedInstantRevision(documentFloor)); + // The floor sits at the restore's first commit, so the union the + // restore itself recorded is still a replayable step. + expect(streamReplay.steps.map((step) => step.transition.cause)).toEqual( + ["assert"], + ); + }); + } + + it("leaves a destination's own earlier history unfloored on a streamed restore", async () => { + const source = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_restore_native_source"), + ); + await source.nodes.Person.create({}, { id: "rn-a" }); + await source.nodes.Person.create({}, { id: "rn-b" }); + await source.identity.assertSame(personRef("rn-a"), personRef("rn-b")); + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push(chunk); + } + + const target = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_restore_native_target"), + ); + await target.nodes.Person.create({}, { id: "rn-own-a" }); + await target.nodes.Person.create({}, { id: "rn-own-b" }); + await target.identity.assertSame( + personRef("rn-own-a"), + personRef("rn-own-b"), + ); + const streamResult = await importGraphStream( + target, + (async function* replayChunks() { + await Promise.resolve(); + yield* chunks; + })(), + { onConflict: "skip" }, + ); + expect(streamResult.errors).toEqual([]); + + const own = await target.identity.replay(personRef("rn-own-a")); + expect(truncatedBeforeOf(own)).toBeUndefined(); + }); + }); + + describe("identity lineage of a departed member", () => { + it.each(DEPARTURES)( + "keeps the history of a $name non-canonical member discoverable from that member", + async ({ depart }) => { + const store = await provisionDepartedLineageStore( + context, + departedLineageSourceGraph, + ); + const a = { kind: "Person" as const, id: "departed-a" }; + const b = { kind: "Person" as const, id: "departed-b" }; + const c = { kind: "Person" as const, id: "departed-c" }; + for (const ref of [a, b, c]) { + await store.nodes.Person.create({}, { id: ref.id }); + } + await store.identity.assertSame(a, b); + await store.identity.assertSame(b, c); + await depart(store, b.id); + + // The departure is noted against the surviving canonical only, and + // `b` is a singleton in the current closure: nothing in current state + // leads from `b` to the class it left. + const history = await store.identity.transitionsOf(b); + expect( + history.transitions.map((transition) => transition.cause), + ).toEqual(["assert", "assert", "detach", "detach"]); + expect( + completenessReason(history, "unattributed-restored"), + ).toBeUndefined(); + const survivorHistory = await store.identity.transitionsOf(a); + expect( + history.transitions.map((transition) => transition.transitionId), + ).toEqual( + survivorHistory.transitions.map( + (transition) => transition.transitionId, + ), + ); + + const replay = await store.identity.replay(b); + expect( + replay.steps.map((step) => [ + step.transition.cause, + memberKeys(step.before), + memberKeys(step.after), + ]), + ).toEqual([ + [ + "assert", + ["Person:departed-b"], + ["Person:departed-a", "Person:departed-b"], + ], + [ + "assert", + ["Person:departed-a", "Person:departed-b"], + ["Person:departed-a", "Person:departed-b", "Person:departed-c"], + ], + [ + "detach", + ["Person:departed-a", "Person:departed-b", "Person:departed-c"], + [], + ], + [ + "detach", + ["Person:departed-a", "Person:departed-b", "Person:departed-c"], + [], + ], + ]); + expect( + completenessReason(replay, "unattributed-restored"), + ).toBeUndefined(); + }, + ); + + it("stops a member's history where it left its class, though that class goes on changing", async () => { + const store = await provisionDepartedLineageStore( + context, + departedLineageSourceGraph, + ); + const [a, b, c, d] = ["left-a", "left-b", "left-c", "left-d"].map( + (id) => ({ kind: "Person" as const, id }), + ) as [ + ReturnType, + ReturnType, + ReturnType, + ReturnType, + ]; + for (const ref of [a, b, c, d]) { + await store.nodes.Person.create({}, { id: ref.id }); + } + const joined = await store.identity.assertSame(a, b); + await store.identity.retractAssertion(joined.assertion.id); + const left = requireDefined(await store.recordedNow()); + // `a` is alone from here on; the class it left keeps merging. + await store.identity.assertSame(b, c); + await store.identity.assertSame(c, d); + + const history = await store.identity.transitionsOf(a); + const replay = await store.identity.replay(a); + expect(history.transitions.map((transition) => transition.cause)).toEqual( + ["assert", "retract", "retract"], + ); + expect( + history.transitions.every( + (transition) => + recordedInstantRevision(asRecordedInstant(transition.recorded)) <= + recordedInstantRevision(left), + ), + ).toBe(true); + expect( + replay.steps.map((step) => [ + step.transition.cause, + memberKeys(step.before), + memberKeys(step.after), + ]), + ).toEqual([ + ["assert", ["Person:left-a"], ["Person:left-a", "Person:left-b"]], + ["retract", ["Person:left-a", "Person:left-b"], ["Person:left-a"]], + ["retract", ["Person:left-a", "Person:left-b"], ["Person:left-a"]], + ]); + // Every step changed `a`'s class. + for (const step of replay.steps) { + expect(memberKeys(step.before)).not.toEqual(memberKeys(step.after)); + } + + // The class `a` left still reports its own later merges. + const remainingHistory = await store.identity.transitionsOf(b); + expect( + remainingHistory.transitions.map((transition) => transition.cause), + ).toEqual(["assert", "retract", "retract", "assert", "assert"]); + }); + + it("keeps a departed same-id fold member's history discoverable when no assertion ever named it", async () => { + const store = await provisionDepartedLineageStore( + context, + departedLineageSourceGraph, + ); + const person = { kind: "Person" as const, id: "departed-fold" }; + await store.nodes.Org.create({}, { id: person.id }); + await store.nodes.Person.create({}, { id: person.id }); + await store.nodes.Person.hardDelete(asNodeId(person.id)); + + const replay = await store.identity.replay(person); + expect( + replay.steps.map((step) => [ + step.transition.cause, + memberKeys(step.before), + memberKeys(step.after), + ]), + ).toEqual([ + ["fold", [], ["Org:departed-fold", "Person:departed-fold"]], + ["detach", ["Org:departed-fold", "Person:departed-fold"], []], + ]); + }); + + it("keeps the history that put a member in a class after the class's kind is dropped", async () => { + const store = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_replay_kind_drop"), + ); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Alias: { properties: { label: { type: "string" } } } }, + }), + ); + const person = personRef("dropped-p"); + await evolved.nodes.Person.create({}, { id: person.id }); + const alias = await evolved + .getNodeCollectionOrThrow("Alias") + .create({ label: "alias" }, { id: "dropped-a" }); + await evolved.identity.assertSame(person, alias); + const removed = await evolved.removeKinds(["Alias"]); + + const history = await removed.identity.transitionsOf(person); + expect(history.transitions.map((transition) => transition.cause)).toEqual( + ["assert", "kind-drop"], + ); + const replay = await removed.identity.replay(person); + expect(replay.steps.map((step) => step.transition.cause)).toEqual([ + "assert", + "kind-drop", + ]); + }); + + it("keeps the history that put a member in a class after same-id folding is switched off", async () => { + const backend = context.getStore().backend; + const foldGraph = departedLineageGraph( + "identity_replay_fold_switched_off", + ); + const ignoreGraph = defineGraph({ + id: foldGraph.id, + nodes: { Person: { type: DepartedPerson }, Org: { type: DepartedOrg } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, + }); + const [foldStore] = await createStoreWithSchema(foldGraph, backend, { + history: true, + }); + const shared = personRef("switched-off"); + await foldStore.nodes.Person.create({}, { id: shared.id }); + await foldStore.nodes.Org.create({}, { id: shared.id }); + const active = requireDefined( + await getActiveSchema(backend, foldGraph.id), + ); + await migrateSchema(backend, ignoreGraph, active.version); + const [ignoreStore] = await createStoreWithSchema(ignoreGraph, backend, { + history: true, + }); + + const history = await ignoreStore.identity.transitionsOf(shared); + const causes = history.transitions.map((transition) => transition.cause); + expect(causes).toEqual([ + "fold", + "schema-transition", + "schema-transition", + ]); + const replay = await ignoreStore.identity.replay(shared); + expect(replay.steps.map((step) => step.transition.transitionId)).toEqual( + history.transitions.map((transition) => transition.transitionId), + ); + }); + + it("reports restored transitions it can neither attribute nor rule out, instead of a silent short page", async () => { + const source = await provisionDepartedLineageStore( + context, + departedLineageSourceGraph, + ); + const a = { kind: "Person" as const, id: "restored-a" }; + const b = { kind: "Person" as const, id: "restored-b" }; + await source.nodes.Person.create({}, { id: a.id }); + await source.nodes.Person.create({}, { id: b.id }); + await source.identity.assertSame(a, b); + await source.nodes.Person.hardDelete(asNodeId(b.id)); + const sourceHistory = await source.identity.transitionsOf(b); + expect( + sourceHistory.transitions.map((transition) => transition.cause), + ).toEqual(["assert", "detach"]); + expect( + completenessReason(sourceHistory, "unattributed-restored"), + ).toBeUndefined(); + + const archive = await exportGraph(source, { identityMode: "archival" }); + const target = await provisionDepartedLineageStore( + context, + departedLineageRestoreGraph, + ); + const imported = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(imported.errors).toEqual([]); + + // `b` was gone before the archive was taken, so the target holds no + // evidence tying it to the two restored rows that explain it. + const departed = await target.identity.transitionsOf(b); + expect(departed.transitions).toEqual([]); + expect(completenessReason(departed, "unattributed-restored")).toEqual({ + reason: "unattributed-restored", + count: 2, + }); + const departedReplay = await target.identity.replay(b); + expect( + completenessReason(departedReplay, "unattributed-restored"), + ).toEqual({ reason: "unattributed-restored", count: 2 }); + + // The surviving canonical is named by both rows, so nothing is left + // unattributed and no signal is raised. + const survivor = await target.identity.transitionsOf(a); + expect( + survivor.transitions.map((transition) => transition.transitionId), + ).toEqual( + sourceHistory.transitions.map((transition) => transition.transitionId), + ); + expect( + completenessReason(survivor, "unattributed-restored"), + ).toBeUndefined(); + }); + }); + + describe("identity history completeness", () => { + it("reports a fully read lineage as complete on both transitionsOf and replay", async () => { + const store = await provisionIdentityReplayStore(context); + const a = personRef("complete-a"); + const b = personRef("complete-b"); + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + const joined = await store.identity.assertSame(a, b); + await store.identity.retractAssertion(joined.assertion.id); + + const history = await store.identity.transitionsOf(a); + const replay = await store.identity.replay(a); + console.log( + "complete lineage:", + history.completeness, + replay.completeness, + ); + expect(history.transitions[0]?.cause).toBe("assert"); + expect(history.completeness).toEqual({ status: "complete" }); + expect(replay.completeness).toEqual({ status: "complete" }); + }); + + it("reports more pages with the cursor that reads the next one, until the last page", async () => { + const { store, a, firstPage, cursor } = + await provisionPagedLineage(context); + console.log("first page completeness:", firstPage.completeness); + expect(firstPage.completeness).toEqual({ + status: "partial", + reasons: [{ reason: "more-pages", nextCursor: cursor }], + }); + const firstReplayPage = await store.identity.replay(a, { limit: 1 }); + expect(completenessReason(firstReplayPage, "more-pages")).toEqual({ + reason: "more-pages", + nextCursor: requireDefined(firstReplayPage.nextCursor), + }); + + const rest = await store.identity.transitionsOf(a, { cursor }); + expect(rest.nextCursor).toBeUndefined(); + expect(rest.completeness).toEqual({ status: "complete" }); + }); + + it("reports the retention cut on transitionsOf as on replay, without refusing a range below it", async () => { + const store = await provisionIdentityReplayStore(context); + const a = personRef("retention-a"); + const b = personRef("retention-b"); + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + const joined = await store.identity.assertSame(a, b); + const joinedAt = requireDefined(await store.recordedNow()); + await store.identity.retractAssertion(joined.assertion.id); + const retractedAt = requireDefined(await store.recordedNow()); + const pruned = await pruneIdentityTransitions(store, { + beforeRecorded: retractedAt, + }); + expect(pruned.pruned).toBe(1); + + const history = await store.identity.transitionsOf(a); + console.log("pruned lineage completeness:", history.completeness); + expect( + history.transitions.every( + (transition) => transition.cause === "retract", + ), + ).toBe(true); + const cut = requireDefined( + completenessReason(history, "retention-truncated"), + ); + expect(recordedInstantRevision(cut.before)).toBe( + pruned.prunedBeforeRevision, + ); + expect(history.completeness).toEqual({ + status: "partial", + reasons: [cut], + }); + const replay = await store.identity.replay(a); + expect(replay.completeness).toEqual(history.completeness); + + // `replay` refuses a range wholly below the watermark; `transitionsOf` + // answers it, empty, and says why it is empty. + const belowWatermark = await store.identity.transitionsOf(a, { + toRecorded: joinedAt, + }); + expect(belowWatermark.transitions).toEqual([]); + expect(belowWatermark.completeness).toEqual({ + status: "partial", + reasons: [cut], + }); + + // A range that starts at the watermark is untouched by it. + const aboveWatermark = await store.identity.transitionsOf(a, { + fromRecorded: retractedAt, + }); + expect(aboveWatermark.completeness).toEqual({ status: "complete" }); + }); + + it("counts the restored transitions of a replay page that are not steps", async () => { + const source = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_completeness_restored_source"), + ); + const alice = personRef("nr-alice"); + const bob = personRef("nr-bob"); + await source.nodes.Person.create({}, { id: alice.id }); + await source.nodes.Person.create({}, { id: bob.id }); + const first = await source.identity.assertSame(alice, bob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, bob); + const archive = await exportGraph(source, { identityMode: "archival" }); + + const target = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_completeness_restored_target"), + ); + const imported = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(imported.errors).toEqual([]); + + const history = await target.identity.transitionsOf(alice); + const restored = history.transitions.filter( + (transition) => transition.restored !== undefined, + ); + const replay = await target.identity.replay(alice); + console.log( + "restored lineage:", + history.transitions.length, + "transitions,", + replay.steps.length, + "steps,", + replay.completeness, + ); + expect(restored.length).toBeGreaterThan(0); + expect(replay.steps.length).toBe( + history.transitions.length - restored.length, + ); + expect(completenessReason(replay, "restored-not-reconstructed")).toEqual({ + reason: "restored-not-reconstructed", + count: restored.length, + }); + // `transitionsOf` returns every restored row, so it has nothing to report. + expect( + completenessReason(history, "restored-not-reconstructed"), + ).toBeUndefined(); + }); + + it("reports current class members history capture never saw, instead of an empty page that looks complete", async () => { + const backend = context.getStore().backend; + const graph = departedLineageGraph("identity_completeness_uncaptured"); + const [untracked] = await createStoreWithSchema(graph, backend, { + history: false, + }); + const [store] = await createStoreWithSchema(graph, backend, { + history: true, + }); + // A node capture never saw that is alone in its class has no + // membership to explain. + const lone = personRef("uncaptured-lone"); + await untracked.nodes.Person.create({}, { id: lone.id }); + const loneHistory = await store.identity.transitionsOf(lone); + expect(loneHistory.completeness).toEqual({ status: "complete" }); + + // Merged before capture was enabled: neither node nor assertion recorded. + const a = personRef("uncaptured-a"); + const b = personRef("uncaptured-b"); + await untracked.nodes.Person.create({}, { id: a.id }); + await untracked.nodes.Person.create({}, { id: b.id }); + await untracked.identity.assertSame(a, b); + expect(await store.identity.membersOf(a)).toHaveLength(2); + for (const ref of [a, b]) { + const history = await store.identity.transitionsOf(ref); + const replay = await store.identity.replay(ref); + console.log( + `pre-capture merge read from ${ref.id}:`, + history.transitions.length, + "transitions,", + JSON.stringify(history.completeness), + ); + expect(history.transitions).toEqual([]); + expect(history.completeness).toEqual(uncapturedCount(2)); + expect(replay.completeness).toEqual(uncapturedCount(2)); + } + + // Both nodes rewritten under capture: the nodes are recorded, the + // assertion that joined them still is not. + await store.nodes.Person.update(asNodeId(a.id), {}); + await store.nodes.Person.update(asNodeId(b.id), {}); + const rewritten = await store.identity.transitionsOf(a); + console.log( + "nodes rewritten, assertion not:", + JSON.stringify(rewritten.completeness), + ); + expect(rewritten.completeness).toEqual(uncapturedCount(1)); + + // Created before capture, merged under it: the merge is logged, but the + // historical reader has no record of either node, so the page cannot + // show it. + const c = personRef("uncaptured-c"); + const d = personRef("uncaptured-d"); + await untracked.nodes.Person.create({}, { id: c.id }); + await untracked.nodes.Person.create({}, { id: d.id }); + await store.identity.assertSame(c, d); + const capturedMerge = await store.identity.transitionsOf(c); + console.log( + "captured merge of pre-capture nodes:", + capturedMerge.transitions.length, + "transitions,", + JSON.stringify(capturedMerge.completeness), + ); + expect(capturedMerge.transitions).toEqual([]); + expect(capturedMerge.completeness).toEqual(uncapturedCount(2)); + + // A pre-capture node written again under capture, then merged with one + // created under it: fully captured, so complete. + const f = personRef("uncaptured-f"); + const g = personRef("uncaptured-g"); + await untracked.nodes.Person.create({}, { id: f.id }); + await store.nodes.Person.update(asNodeId(f.id), {}); + await store.nodes.Person.create({}, { id: g.id }); + await store.identity.assertSame(f, g); + const captured = await store.identity.transitionsOf(f); + expect( + captured.transitions.map((transition) => transition.cause), + ).toEqual(["assert"]); + expect(captured.completeness).toEqual({ status: "complete" }); + const capturedReplay = await store.identity.replay(f); + expect(capturedReplay.completeness).toEqual({ status: "complete" }); + }); + + it("reports a transaction's own pending identity changes on tx.identity reads, and nothing once committed", async () => { + const store = await provisionIdentityReplayStore(context); + const a = personRef("pending-a"); + const b = personRef("pending-b"); + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + + await store.transaction(async (tx) => { + const txIdentity = requireDefined(tx.identity); + // A write that changes no class notes nothing. + await tx.nodes.Person.create({}, { id: "pending-unrelated" }); + const beforeIdentityWrite = await txIdentity.transitionsOf(a); + expect(beforeIdentityWrite.completeness).toEqual({ + status: "complete", + }); + + await txIdentity.assertSame(a, b); + const history = await txIdentity.transitionsOf(a); + const replay = await txIdentity.replay(a); + console.log("in-transaction completeness:", history.completeness); + expect(history.transitions).toEqual([]); + expect(history.completeness).toEqual({ + status: "partial", + reasons: [{ reason: "pending-transaction-notes" }], + }); + expect(replay.completeness).toEqual(history.completeness); + }); + + const committed = await store.identity.transitionsOf(a); + expect( + committed.transitions.map((transition) => transition.cause), + ).toEqual(["assert"]); + expect(committed.completeness).toEqual({ status: "complete" }); + }); + }); + + describe("identity transition attribution", () => { + it("gives each class a bulk assert changed only the assertion that changed it", async () => { + const { store, pairs, asserted } = await provisionDisjointPairs(context); + + const idsPerPair = await Promise.all( + pairs.map(async ([a]) => { + const history = await store.identity.transitionsOf(a); + return history.transitions.map( + (transition) => transition.assertionIds, + ); + }), + ); + + console.log("bulk assert transition ids per pair:", idsPerPair); + expect(idsPerPair).toEqual( + asserted.map((result) => [[result.assertion.id]]), + ); + }); + + it("gives each class a bulk retraction split only the assertion that held it, and never an unrelated different assertion", async () => { + const { store, pairs, asserted } = await provisionDisjointPairs(context); + const [firstA] = requireDefined(pairs[0]); + const [secondA] = requireDefined(pairs[1]); + const different = await store.identity.assertDifferent(firstA, secondA); + + await store.identity.bulkRetractAssertions([ + ...asserted.map((result) => result.assertion.id), + different.assertion.id, + ]); + + const retractIdsPerPair = await Promise.all( + pairs.map(async ([a]) => { + const history = await store.identity.transitionsOf(a); + return history.transitions + .filter((transition) => transition.cause === "retract") + .map((transition) => transition.assertionIds); + }), + ); + + console.log("bulk retract transition ids per pair:", retractIdsPerPair); + for (const [index, result] of asserted.entries()) { + const idsPerTransition = requireDefined(retractIdsPerPair[index]); + expect(idsPerTransition.length).toBeGreaterThan(0); + for (const ids of idsPerTransition) { + expect(ids).toEqual([result.assertion.id]); + } + } + }); + }); + + describe("identity replay reads each step at its own coordinate", () => { + it("reconstructs `before` at the revision before each step, even where an unnoted write changed the class", async () => { + const backend = context.getStore().backend; + const ignoreGraph = profileFlipGraph("ignore"); + const foldGraph = profileFlipGraph("fold"); + const [ignoreStore] = await createStoreWithSchema(ignoreGraph, backend, { + history: true, + }); + const x = personRef("flip-x"); + const y = personRef("flip-y"); + await ignoreStore.nodes.Person.create({}, { id: x.id }); + await ignoreStore.nodes.Person.create({}, { id: y.id }); + const same = await ignoreStore.identity.assertSame(x, y); + // Under `ignore` a same-id node of another kind joins no class, so + // nothing is noted; under `fold` it is a member from this revision on. + await ignoreStore.nodes.Robot.create({}, { id: x.id }); + await ignoreStore.identity.retractAssertion(same.assertion.id); + + const active = requireDefined( + await getActiveSchema(backend, ignoreGraph.id), + ); + await migrateSchema(backend, foldGraph, active.version); + const [store] = await createStoreWithSchema(foldGraph, backend, { + history: true, + }); + + const replay = await store.identity.replay(x); + const retract = requireDefined( + replay.steps.find((step) => step.transition.cause === "retract"), + ); + const retractRevision = recordedInstantRevision( + retract.transition.recorded, + ); + const retractWallTime = recordedInstantWallTime( + retract.transition.recorded, + ); + + // What the store itself answers one revision earlier and at the + // step's own revision, from the valid instant the step was recorded at. + const membersBefore = await store + .asOfRecorded( + createRecordedInstant(retractRevision - 1, retractWallTime), + ) + .identity.membersOf(x); + const membersAfter = await store + .asOfRecorded(retract.transition.recorded) + .identity.membersOf(x); + + expect(memberKeys(retract.before)).toEqual(memberKeys(membersBefore)); + expect(memberKeys(retract.before)).toEqual([ + "Person:flip-x", + "Person:flip-y", + "Robot:flip-x", + ]); + expect(memberKeys(retract.after)).toEqual(memberKeys(membersAfter)); + }); + // MUTATION CHECK: reusing the previous step's `after` as this step's + // `before` reports [Person:flip-x, Person:flip-y] for the retraction, so + // the Robot appears to join BECAUSE of it. + + it("answers the same step identically after a future-dated window lapses", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(WINDOW_LAPSE_CLOCK_START)); + const store = await provisionDepartedLineageStore( + context, + departedLineageGraph("identity_replay_window_lapse"), + ); + const person = personRef("lapse-x"); + await store.nodes.Person.create({}, { id: person.id }); + await store.nodes.Org.create({}, { id: person.id }); + const lapsesAt = new Date(Date.now() + WINDOW_LAPSE_MS).toISOString(); + await store.nodes.Org.update( + asNodeId(person.id), + {}, + { + validTo: lapsesAt, + }, + ); + + const earlyReplay = await store.identity.replay(person); + const early = requireDefined(earlyReplay.steps.at(-1)); + vi.setSystemTime(Date.parse(lapsesAt) + WINDOW_LAPSE_MS); + const lateReplay = await store.identity.replay(person); + const late = requireDefined(lateReplay.steps.at(-1)); + const live = await store + .asOfRecorded(late.transition.recorded) + .identity.membersOf(person); + + expect(late.transition.transitionId).toBe( + early.transition.transitionId, + ); + expect(memberKeys(late.after)).toEqual(memberKeys(early.after)); + expect(memberKeys(late.before)).toEqual(memberKeys(early.before)); + expect(memberKeys(late.after)).toEqual(memberKeys(live)); + expect(memberKeys(live)).toEqual(["Org:lapse-x", "Person:lapse-x"]); + } finally { + vi.useRealTimers(); + } + }); + // MUTATION CHECK: pairing each revision with the wall clock's "now" as + // its valid instant drops Org:lapse-x from the late read's `after`. + }); + + describe("identity replay equivalence", () => { + it("replays merge / split / re-merge: four steps, causes assert/assert/retract/assert, after matching the live closure at EVERY revision", async () => { + const store = await provisionIdentityReplayStore(context); + const a = { kind: "Person" as const, id: "replay-a" }; + const b = { kind: "Person" as const, id: "replay-b" }; + const c = { kind: "Person" as const, id: "replay-c" }; + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + await store.nodes.Person.create({}, { id: c.id }); + + const same1 = await store.identity.assertSame(a, b); + await store.identity.assertSame(b, c); + await store.identity.retractAssertion(same1.assertion.id); + await store.identity.assertSame(a, b); + + const replay = await store.identity.replay(a); + + // The replay contract, at BOUNDARY granularity: four boundaries, causes + // assert/assert/retract/assert. The retract boundary itself carries TWO + // transition rows — one per resulting component (a's own departure, + // and b+c's continuation) — exactly as the replay contract documents ("several + // transitions at one revision... emitted as several steps with + // identical before/after"), so this groups by revision first rather + // than asserting step count and cause sequence directly against the + // (boundary-count-exceeding) step list. + const revisionsInOrder = [ + ...new Set(replay.steps.map((step) => step.transition.recorded)), + ]; + expect(revisionsInOrder.length).toBe(4); + const causesByBoundary = revisionsInOrder.map((recorded) => { + const stepsAtBoundary = replay.steps.filter( + (step) => step.transition.recorded === recorded, + ); + const causesAtBoundary = new Set( + stepsAtBoundary.map((step) => step.transition.cause), + ); + // Every row at one boundary shares the SAME cause (this fixture never + // mixes causes within a commit) — asserted so a future generator + // change that violates it fails loudly here, not as a silent + // `.at(0)` truncation. + expect(causesAtBoundary.size).toBe(1); + return requireDefined([...causesAtBoundary][0]); + }); + expect(causesByBoundary).toEqual([ + "assert", + "assert", + "retract", + "assert", + ]); + + // before(i) == after(i-1) for every step whose revision differs from + // the previous step's (several notes can share one revision). + for (let index = 1; index < replay.steps.length; index += 1) { + const previous = requireDefined(replay.steps[index - 1]); + const current = requireDefined(replay.steps[index]); + if (previous.transition.recorded === current.transition.recorded) { + continue; + } + expect(current.before.map((ref) => ref.id).toSorted()).toEqual( + previous.after.map((ref) => ref.id).toSorted(), + ); + } + + // EVERY step's `after` equals the LIVE closure as of that step's own + // revision — not only the final one — via `asOfRecorded`, a read path + // that never touches the transition log, so agreement is the whole + // equivalence pin. + for (const step of replay.steps) { + const liveMembersAtStep = await store + .asOfRecorded(step.transition.recorded) + .identity.membersOf(a); + expect(step.after.map((ref) => ref.id).toSorted()).toEqual( + liveMembersAtStep.map((ref) => ref.id).toSorted(), + ); + } + + const lastStep = requireDefined(replay.steps.at(-1)); + const liveMembers = await store.identity.membersOf(a); + expect(lastStep.after.map((ref) => ref.id).toSorted()).toEqual( + liveMembers.map((ref) => ref.id).toSorted(), + ); + }); + + // Discovery-vs-window and boundary paging on EVERY backend: both moved + // out of the reader's SQL (which no longer takes revision bounds at all) + // and into `replay.ts`, so the shared suite is where the two dialects are + // pinned to the same answer rather than each certifying its own. + it("discovers lineage outside the requested window, and pages by boundary through nextCursor", async () => { + const store = await provisionIdentityReplayStore(context); + const a = { kind: "Person" as const, id: "page-a" }; + const b = { kind: "Person" as const, id: "page-b" }; + const c = { kind: "Person" as const, id: "page-c" }; + await store.nodes.Person.create({}, { id: a.id }); + await store.nodes.Person.create({}, { id: b.id }); + await store.nodes.Person.create({}, { id: c.id }); + + const firstMerge = await store.identity.assertSame(b, c); + const throughFirstMerge = requireDefined(await store.recordedNow()); + await store.identity.assertSame(a, b); + // Four boundaries, not two: a page size of 1 has to be cut three times + // for reassembly to be able to catch a cursor that skips one. + await store.identity.retractAssertion(firstMerge.assertion.id); + await store.identity.assertSame(b, c); + + // `a` is the class canonical now, and no note at or below the first + // merge names it — only an unbounded walk can reach that boundary. + const windowed = await store.identity.transitionsOf(b, { + toRecorded: throughFirstMerge, + }); + expect(windowed.transitions.length).toBeGreaterThan(0); + + const whole = await store.identity.transitionsOf(a); + expect(whole.nextCursor).toBeUndefined(); + const boundaries = new Set( + whole.transitions.map((transition) => transition.recorded), + ); + expect(boundaries.size).toBeGreaterThan(2); + + // Reassembly, not merely "the second page differs from the first": a + // cursor that skipped a boundary, or a page that came back empty, + // satisfies "differs" and fails here. This is also the one place a + // dialect-decoded `recorded_at` (PostgreSQL hands back a timestamptz, + // SQLite a text column) round-trips back in through `cursor`. + const paged: string[] = []; + let cursor: TransitionPageCursor | undefined; + for (let page = 0; page <= boundaries.size; page += 1) { + const result = await store.identity.transitionsOf(a, { + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }); + expect( + new Set(result.transitions.map((transition) => transition.recorded)) + .size, + ).toBe(1); + paged.push( + ...result.transitions.map((transition) => transition.transitionId), + ); + cursor = result.nextCursor; + if (cursor === undefined) break; + } + expect(cursor).toBeUndefined(); + expect(paged).toEqual( + whole.transitions.map((transition) => transition.transitionId), + ); + }); + + // The cursor and the recorded window are different inputs. The brand is + // type-level only, so these pin the RUNTIME separation: a cursor handed to + // a window bound, or a bare instant handed to `cursor`, is refused rather + // than silently read as the other. + describe("page cursor is distinct from the recorded window", () => { + const WINDOW_GIVEN_CURSOR = + /must be a recorded instant, not a page cursor/; + const CURSOR_GIVEN_NON_CURSOR = /cursor must be a page cursor/; + + it("fromRecorded and toRecorded refuse a page cursor on both reads", async () => { + const { store, a, cursor } = await provisionPagedLineage(context); + for (const bound of ["fromRecorded", "toRecorded"] as const) { + const misuse = { [bound]: cursor }; + await expect(store.identity.transitionsOf(a, misuse)).rejects.toThrow( + WINDOW_GIVEN_CURSOR, + ); + await expect(store.identity.replay(a, misuse)).rejects.toThrow( + WINDOW_GIVEN_CURSOR, + ); + } + }); + + it("cursor refuses a bare recorded instant and arbitrary text", async () => { + const { store, a, firstPage } = await provisionPagedLineage(context); + const instant = requireDefined(firstPage.transitions[0]).recorded; + for (const notACursor of [instant, "not-a-cursor"]) { + const misuse = { cursor: notACursor as TransitionPageCursor }; + await expect(store.identity.transitionsOf(a, misuse)).rejects.toThrow( + CURSOR_GIVEN_NON_CURSOR, + ); + await expect(store.identity.replay(a, misuse)).rejects.toThrow( + CURSOR_GIVEN_NON_CURSOR, + ); + } + }); + + it("a cursor refuses a read of another reference's lineage", async () => { + const { store, cursor } = await provisionPagedLineage(context); + const other = personRef("cursor-b"); + await expect( + store.identity.transitionsOf(other, { cursor }), + ).rejects.toThrow( + /pages the lineage of Person\/cursor-a, not Person\/cursor-b/, + ); + await expect(store.identity.replay(other, { cursor })).rejects.toThrow( + ValidationError, + ); + }); + + it("cursor refuses text that looks like a cursor but cannot be decoded", async () => { + const { store, a, cursor } = await provisionPagedLineage(context); + const revision = requireDefined(cursor.split(":").at(-1)); + const undecodable = `tpc1:%:x:${revision}` as TransitionPageCursor; + await expect( + store.identity.transitionsOf(a, { cursor: undecodable }), + ).rejects.toThrow(ValidationError); + await expect( + store.identity.replay(a, { cursor: undecodable }), + ).rejects.toThrow(/Malformed transition page cursor/); + }); + + it("a cursor composes with the window by intersection", async () => { + const { store, a, firstPage, cursor } = + await provisionPagedLineage(context); + const whole = await store.identity.transitionsOf(a); + const firstBoundary = requireDefined(firstPage.transitions[0]).recorded; + const lastBoundary = requireDefined(whole.transitions.at(-1)).recorded; + const ids = (history: typeof whole) => + history.transitions.map((transition) => transition.transitionId); + const afterFirstPage = whole.transitions.filter( + (transition) => transition.recorded !== firstBoundary, + ); + + // A window that already contains the cursor changes nothing. + const sameWindow = await store.identity.transitionsOf(a, { + cursor, + toRecorded: lastBoundary, + }); + expect(ids(sameWindow)).toEqual( + afterFirstPage.map((transition) => transition.transitionId), + ); + + // A window that ends before the cursor leaves nothing to read, and + // says so without a continuation. + const endsBeforeCursor = await store.identity.transitionsOf(a, { + cursor, + toRecorded: firstBoundary, + }); + expect(endsBeforeCursor.transitions).toEqual([]); + expect(endsBeforeCursor.nextCursor).toBeUndefined(); + + // A window that starts after the cursor wins: the page begins at the + // later of the two, on replay exactly as on transitionsOf. + const laterStart = requireDefined(whole.transitions.at(-1)).recorded; + const startsAfterCursor = await store.identity.transitionsOf(a, { + cursor, + fromRecorded: laterStart, + }); + expect( + startsAfterCursor.transitions.every( + (transition) => transition.recorded === laterStart, + ), + ).toBe(true); + const replayed = await store.identity.replay(a, { + cursor, + fromRecorded: laterStart, + }); + expect( + replayed.steps.map((step) => step.transition.transitionId), + ).toEqual(ids(startsAfterCursor)); + }); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/identity.ts b/packages/typegraph/tests/backends/integration/identity.ts index 2da64ebea..33b54e2fb 100644 --- a/packages/typegraph/tests/backends/integration/identity.ts +++ b/packages/typegraph/tests/backends/integration/identity.ts @@ -14,7 +14,7 @@ import { rebuildIdentityClosure, } from "../../../src"; import { deriveBackend } from "../../../src/backend/derive-backend"; -import { exportGraph } from "../../../src/interchange"; +import { exportGraph, importGraph } from "../../../src/interchange"; import { inverseOf } from "../../../src/ontology"; import { createSqlSchema } from "../../../src/query/compiler/schema"; import { sql } from "../../../src/query/sql-fragment"; @@ -29,6 +29,7 @@ import { } from "../../../src/store/runtime-port"; import { compareStrings } from "../../../src/utils/compare"; import { requireDefined } from "../../../src/utils/presence"; +import { truncatedBeforeOf } from "../../identity-history-completeness"; import { type IntegrationTestContext } from "./test-context"; /** @@ -99,6 +100,26 @@ async function provisionIdentityTraversalStore( return store; } +/** + * Two independent `history: true` graphs, sharing a graph id namespace with + * every other fixture in this suite only by coexisting on the same backend — + * a genuine SOURCE and TARGET pair for the archival-transitions restore round + * trip below, on every backend `createIntegrationTestSuite` runs against. + */ +const RestorePerson = defineNode("Person", { schema: z.object({}) }); +const identityRestoreSourceGraph = defineGraph({ + id: "identity_restore_source_parity", + nodes: { Person: { type: RestorePerson } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); +const identityRestoreTargetGraph = defineGraph({ + id: "identity_restore_target_parity", + nodes: { Person: { type: RestorePerson } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + /** * Same node and edge kinds as {@link identityTraversalGraph} under the other * identity profile: `sameIdAcrossKinds: "ignore"` keeps the assertion ledger @@ -854,6 +875,47 @@ export function registerIdentityIntegrationTests( ).rejects.toBeInstanceOf(IdentityEndpointValidityError); }); + // Load-bearing for the batch path: a bulk upsert's window end goes through + // the same identity window-end owner as update and upsertById. Revert + // check: drop that owner's call from the upsert-update batch and the bulk + // narrowing below commits, stranding the open assertion. + it("refuses a node window end that would strand identity history on every update path", async () => { + const store = context.getStore(); + const person = await store.nodes.Person.create( + { name: "Stranded person" }, + { id: "stranded-person" }, + ); + const company = await store.nodes.Company.create( + { name: "Stranded company" }, + { id: "stranded-company" }, + ); + await store.identity.assertSame(person, company); + const endpointEnd = new Date(Date.now() + 86_400_000).toISOString(); + + await expect( + store.nodes.Person.update(person.id, {}, { validTo: endpointEnd }), + ).rejects.toBeInstanceOf(IdentityEndpointValidityError); + await expect( + store.nodes.Person.upsertById( + person.id, + { name: "Stranded person" }, + { validTo: endpointEnd }, + ), + ).rejects.toBeInstanceOf(IdentityEndpointValidityError); + await expect( + store.nodes.Person.bulkUpsertById([ + { + id: person.id, + props: { name: "Stranded person" }, + validTo: endpointEnd, + }, + ]), + ).rejects.toBeInstanceOf(IdentityEndpointValidityError); + await expect( + store.nodes.Person.getById(person.id), + ).resolves.toMatchObject({ meta: { validTo: undefined } }); + }); + it("keeps retrospective assertions behind their recorded-time commit", async () => { const store = await provisionIdentityTraversalStore(context, true); const endpointStart = "2019-01-01T00:00:00.000Z"; @@ -1072,6 +1134,10 @@ export function registerIdentityIntegrationTests( sameAssertions: 2, differentAssertions: 0, retractions: 1, + // `context.getStore()` opens without `history: true`, so no + // transition notes are ever buffered — this is the "history off" + // case the receipts load-bearing test also pins. + transitions: 0, total: 3, }); expect(outcome.receipt.writes.total).toBe(3); @@ -1263,6 +1329,71 @@ export function registerIdentityIntegrationTests( expect(document.identity?.assertions).toEqual([]); }); + it("restores archival identity transitions and the retention watermark, and tolerates a re-import", async () => { + const backend = context.getStore().backend; + const [source] = await createStoreWithSchema( + identityRestoreSourceGraph, + backend, + { history: true }, + ); + const a = { kind: "Person" as const, id: "restore-a" }; + const b = { kind: "Person" as const, id: "restore-b" }; + await source.nodes.Person.create({}, { id: a.id }); + await source.nodes.Person.create({}, { id: b.id }); + const first = await source.identity.assertSame(a, b); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(a, b); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const sourceTransitions = requireDefined(archive.identity?.transitions); + expect(sourceTransitions.length).toBeGreaterThan(0); + + const [target] = await createStoreWithSchema( + identityRestoreTargetGraph, + backend, + { history: true }, + ); + const firstImport = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(firstImport.success).toBe(true); + + // `transitionsOf` answers fully (restore is complete); none of the + // RESTORED transitions may surface as a `replay` step — that would + // pair a foreign transition with a fabricated before/after. + const { transitions: targetTransitions } = + await target.identity.transitionsOf(a); + expect(targetTransitions.length).toBeGreaterThanOrEqual( + sourceTransitions.length, + ); + const replay = await target.identity.replay(a); + const restoredTransitionIds = new Set( + sourceTransitions.map((transition) => transition.transitionId), + ); + for (const step of replay.steps) { + expect(restoredTransitionIds.has(step.transition.transitionId)).toBe( + false, + ); + } + expect(truncatedBeforeOf(replay)).toBeDefined(); + + // A second, identical import — `ON CONFLICT (graph_id, transition_id) + // DO NOTHING` — must not raise a raw driver UNIQUE-constraint error on + // EITHER dialect, and must not duplicate the restored rows. + const secondImport = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(secondImport.success).toBe(true); + expect(secondImport.errors).toEqual([]); + const { transitions: targetTransitionsAfterSecond } = + await target.identity.transitionsOf(a); + expect(targetTransitionsAfterSecond.length).toBe( + targetTransitions.length, + ); + }); + it("makes current reads equal a valid-time view at now", async () => { const store = context.getStore(); const person = await store.nodes.Person.create( diff --git a/packages/typegraph/tests/backends/integration/import-duplicate-edge-order.ts b/packages/typegraph/tests/backends/integration/import-duplicate-edge-order.ts new file mode 100644 index 000000000..0b9aac4ad --- /dev/null +++ b/packages/typegraph/tests/backends/integration/import-duplicate-edge-order.ts @@ -0,0 +1,197 @@ +/** + * Document order for two rows naming ONE edge id in a single import slice. + * + * A slice writes most edges with one multi-row insert and routes the rest — + * a repeated id, an acyclic kind, a composition kind — through the sequential + * per-row path. Which path a row takes must never decide which of two rows + * wins: the second occurrence observes the first occurrence's row, on every + * kind and under every `onConflict` policy. + * + * Each case runs a plain kind beside an acyclic kind and expects the same + * outcome from both. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineEdge, defineGraph, defineNode, partOf } from "../../../src"; +import { + FORMAT_VERSION, + type GraphData, + importGraph, + type ImportOptions, +} from "../../../src/interchange"; +import { type IntegrationTestContext } from "./test-context"; + +const Item = defineNode("IdoItem", { schema: z.object({}) }); +const Part = defineNode("IdoPart", { schema: z.object({}) }); +const Whole = defineNode("IdoWhole", { schema: z.object({}) }); +const labelled = z.object({ label: z.string() }); +const plainLink = defineEdge("idoPlainLink", { schema: labelled }); +const acyclicLink = defineEdge("idoAcyclicLink", { schema: labelled }); +const holds = defineEdge("idoHolds", { schema: labelled }); + +const graph = defineGraph({ + id: "import_duplicate_edge_order", + nodes: { + IdoItem: { type: Item }, + IdoPart: { type: Part }, + IdoWhole: { type: Whole }, + }, + edges: { + idoPlainLink: { type: plainLink, from: [Item], to: [Item] }, + idoAcyclicLink: { + type: acyclicLink, + from: [Item], + to: [Item], + acyclic: true, + }, + idoHolds: { type: holds, from: [Part], to: [Whole], cardinality: "one" }, + }, + ontology: [partOf(Part, Whole, { via: holds })], +}); + +const LINK_KINDS = ["idoPlainLink", "idoAcyclicLink"] as const; +type LinkKind = (typeof LINK_KINDS)[number]; + +function document(edges: GraphData["edges"]): GraphData { + return { + formatVersion: FORMAT_VERSION, + exportedAt: "2026-01-01T00:00:00.000Z", + source: { type: "external", description: "duplicate edge id order" }, + nodes: [ + { kind: "IdoItem", id: "a", properties: {} }, + { kind: "IdoItem", id: "w1", properties: {} }, + { kind: "IdoItem", id: "w2", properties: {} }, + { kind: "IdoPart", id: "p1", properties: {} }, + { kind: "IdoWhole", id: "w1", properties: {} }, + { kind: "IdoWhole", id: "w2", properties: {} }, + ], + edges, + }; +} + +/** + * Two rows for edge `e1`, labelled in document order. An edge's endpoints are + * immutable, so only a second row that also names `secondTo: "w1"` is one an + * `update` or `skip` policy can accept. + */ +function duplicateLinkRows( + kind: LinkKind, + secondTo: "w1" | "w2", +): GraphData["edges"] { + return [ + { + kind, + id: "e1", + from: { kind: "IdoItem", id: "a" }, + to: { kind: "IdoItem", id: "w1" }, + properties: { label: "first" }, + }, + { + kind, + id: "e1", + from: { kind: "IdoItem", id: "a" }, + to: { kind: "IdoItem", id: secondTo }, + properties: { label: "second" }, + }, + ]; +} + +function options(onConflict: ImportOptions["onConflict"]): ImportOptions { + return { + onConflict, + onUnknownProperty: "error", + validateReferences: true, + batchSize: 100, + refreshStatistics: false, + }; +} + +export function registerImportDuplicateEdgeOrderIntegrationTests( + context: IntegrationTestContext, +): void { + describe("import: document order for a repeated edge id", () => { + describe.each(LINK_KINDS)("%s", (kind) => { + it("update: the second occurrence updates the first's row", async () => { + const store = await context.createStore(graph); + const result = await importGraph( + store, + document(duplicateLinkRows(kind, "w1")), + options("update"), + ); + + console.info("duplicate edge id, update", kind, result.edges); + expect(result.errors).toEqual([]); + expect(result.edges).toEqual({ created: 1, updated: 1, skipped: 0 }); + const [stored] = await store.edges[kind].find(); + expect(stored?.label).toBe("second"); + expect(stored?.toId).toBe("w1"); + }); + + it("error: the first occurrence is stored and the second reported", async () => { + const store = await context.createStore(graph); + const result = await importGraph( + store, + document(duplicateLinkRows(kind, "w2")), + options("error"), + ); + + console.info("duplicate edge id, error", kind, result.errors); + expect(result.edges).toEqual({ created: 1, updated: 0, skipped: 0 }); + expect(result.errors).toHaveLength(1); + const stored = await store.edges[kind].find(); + expect(stored.map((edge) => [edge.label, edge.toId])).toEqual([ + ["first", "w1"], + ]); + }); + + it("skip: the first occurrence is stored and the second skipped", async () => { + const store = await context.createStore(graph); + const result = await importGraph( + store, + document(duplicateLinkRows(kind, "w1")), + options("skip"), + ); + + expect(result.errors).toEqual([]); + expect(result.edges).toEqual({ created: 1, updated: 0, skipped: 1 }); + const stored = await store.edges[kind].find(); + expect(stored.map((edge) => [edge.label, edge.toId])).toEqual([ + ["first", "w1"], + ]); + }); + }); + + it("composition: the part is attached to the first occurrence's whole", async () => { + const store = await context.createStore(graph); + const result = await importGraph( + store, + document([ + { + kind: "idoHolds", + id: "h1", + from: { kind: "IdoPart", id: "p1" }, + to: { kind: "IdoWhole", id: "w1" }, + properties: { label: "first" }, + }, + { + kind: "idoHolds", + id: "h1", + from: { kind: "IdoPart", id: "p1" }, + to: { kind: "IdoWhole", id: "w2" }, + properties: { label: "second" }, + }, + ]), + options("error"), + ); + + console.info("duplicate composition edge id", result.errors); + expect(result.edges).toEqual({ created: 1, updated: 0, skipped: 0 }); + expect(result.errors).toHaveLength(1); + const stored = await store.edges.idoHolds.find(); + expect(stored.map((edge) => [edge.label, edge.toId])).toEqual([ + ["first", "w1"], + ]); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/import-required-part-purge.ts b/packages/typegraph/tests/backends/integration/import-required-part-purge.ts new file mode 100644 index 000000000..262fc384a --- /dev/null +++ b/packages/typegraph/tests/backends/integration/import-required-part-purge.ts @@ -0,0 +1,304 @@ +/** + * What a validating import leaves behind, and reports, when it refuses a + * required-existence part. + * + * A part nothing in the payload or on the target can attach is refused before + * its row is written, so no other row of the import is judged against it. A + * part whose composition edge turns out not to attach it is refused after the + * edges are written, which removes its node row together with every edge + * touching it. Either way the result must describe exactly what was + * committed: every removed edge is named in `errors` — including one that was + * on the target before this import ran — and no counter, `created` or + * `updated`, still counts a row that is gone. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineEdge, defineGraph, defineNode, partOf } from "../../../src"; +import { + FORMAT_VERSION, + type GraphData, + importGraph, + type ImportOptions, +} from "../../../src/interchange"; +import { type IntegrationTestContext } from "./test-context"; + +const Part = defineNode("IrpPart", { + schema: z.object({ code: z.string().optional() }), +}); +const Whole = defineNode("IrpWhole", { schema: z.object({}) }); +const Tag = defineNode("IrpTag", { schema: z.object({}) }); +const partOfWhole = defineEdge("irpPartOf", { schema: z.object({}) }); +const tagged = defineEdge("irpTagged", { + schema: z.object({ note: z.string().optional() }), +}); + +function purgeGraph(id: string) { + return defineGraph({ + id, + nodes: { + IrpPart: { + type: Part, + unique: [ + { + name: "irp_part_code", + fields: ["code"], + scope: "kind", + collation: "binary", + }, + ], + }, + IrpWhole: { type: Whole }, + IrpTag: { type: Tag }, + }, + edges: { + irpPartOf: { + type: partOfWhole, + from: [Part], + to: [Whole], + cardinality: "one", + }, + irpTagged: { type: tagged, from: [Tag], to: [Part] }, + }, + ontology: [ + partOf(Part, Whole, { via: partOfWhole, existence: "required" }), + ], + }); +} + +function document( + nodes: GraphData["nodes"], + edges: GraphData["edges"], +): GraphData { + return { + formatVersion: FORMAT_VERSION, + exportedAt: "2026-01-01T00:00:00.000Z", + source: { type: "external", description: "required part purge" }, + nodes, + edges, + }; +} + +function options(overrides: Partial): ImportOptions { + return { + onConflict: "error", + onUnknownProperty: "error", + validateReferences: true, + batchSize: 100, + refreshStatistics: false, + ...overrides, + }; +} + +/** + * A composition edge to a whole that does not exist: it is written under + * `validateReferences: false` and attaches nothing, so its part is refused + * only once the edges are in. + */ +function danglingCompositionEdge( + id: string, + partId: string, +): GraphData["edges"][number] { + return { + kind: "irpPartOf", + id, + from: { kind: "IrpPart", id: partId }, + to: { kind: "IrpWhole", id: "w-missing" }, + properties: {}, + }; +} + +function tagEdge(id: string, partId: string): GraphData["edges"][number] { + return { + kind: "irpTagged", + id, + from: { kind: "IrpTag", id: "t1" }, + to: { kind: "IrpPart", id: partId }, + properties: {}, + }; +} + +export function registerImportRequiredPartPurgeIntegrationTests( + context: IntegrationTestContext, +): void { + describe("import: purging a refused required part", () => { + it("reports an edge that predates the import when the purge removes it", async () => { + const store = await context.createStore( + purgeGraph("import_required_part_purge_preexisting"), + ); + // Load 1: a tag and an edge to a part that does not exist yet. + const first = await importGraph( + store, + document( + [{ kind: "IrpTag", id: "t1", properties: {} }], + [tagEdge("tag-edge", "p1")], + ), + options({ validateReferences: false }), + ); + expect(first.errors).toEqual([]); + expect(first.edges.created).toBe(1); + + // Load 2: the part, with a composition edge that attaches nothing. The + // part is written, then refused and purged. + const second = await importGraph( + store, + document( + [{ kind: "IrpPart", id: "p1", properties: {} }], + [danglingCompositionEdge("po-1", "p1")], + ), + options({ validateReferences: false }), + ); + + console.info("purge of a part with a pre-existing edge", second); + expect(second.nodes).toEqual({ created: 0, updated: 0, skipped: 0 }); + expect(second.edges).toEqual({ created: 0, updated: 0, skipped: 0 }); + const remaining = await store.edges.irpTagged.find(); + const reported = second.errors.some( + (error) => error.entityType === "edge" && error.id === "tag-edge", + ); + // The purge removes every edge on the part; none may go unnamed. + expect(remaining.map((edge) => edge.id)).toEqual([]); + expect(reported).toBe(true); + expect(second.errors.map((error) => error.id).toSorted()).toEqual([ + "p1", + "po-1", + "tag-edge", + ]); + }); + + it("leaves an edge that predates the import alone when the part is refused unwritten", async () => { + const store = await context.createStore( + purgeGraph("import_required_part_refused_unwritten"), + ); + await importGraph( + store, + document( + [{ kind: "IrpTag", id: "t1", properties: {} }], + [tagEdge("tag-edge", "p1")], + ), + options({ validateReferences: false }), + ); + + const second = await importGraph( + store, + document([{ kind: "IrpPart", id: "p1", properties: {} }], []), + options({}), + ); + + expect(second.nodes).toEqual({ created: 0, updated: 0, skipped: 0 }); + expect(second.errors).toEqual([ + expect.objectContaining({ + entityType: "node", + id: "p1", + error: expect.stringMatching(/requires a whole/u) as string, + }), + ]); + const remaining = await store.edges.irpTagged.find(); + expect(remaining.map((edge) => edge.id)).toEqual(["tag-edge"]); + }); + + describe.each([ + { order: "doomed part first", ids: ["p1", "p2"] }, + { order: "doomed part last", ids: ["p2", "p1"] }, + ])( + "a row colliding only with a part the import refuses ($order)", + ({ ids }) => { + it.each([{ batchSize: 100 }, { batchSize: 1 }])( + "commits it whatever the row order (batchSize $batchSize)", + async ({ batchSize }) => { + // `p1` has no composition edge anywhere, so it can never be + // attached. `p2` shares its unique `code` and is otherwise valid. + const store = await context.createStore( + purgeGraph( + `import_required_part_order_${ids.join("_")}_${batchSize}`, + ), + ); + await store.nodes.IrpWhole.create({}, { id: "w1" }); + + const result = await importGraph( + store, + document( + ids.map((id) => ({ + kind: "IrpPart", + id, + properties: { code: "x" }, + })), + [ + { + kind: "irpPartOf", + id: "po-2", + from: { kind: "IrpPart", id: "p2" }, + to: { kind: "IrpWhole", id: "w1" }, + properties: {}, + }, + ], + ), + options({ batchSize }), + ); + + console.info("rows beside a refused part", result.errors); + expect(result.errors.map((error) => error.id)).toEqual(["p1"]); + expect(result.nodes.created).toBe(1); + expect(result.edges.created).toBe(1); + const stored = await store.nodes.IrpPart.find(); + expect(stored.map((part) => part.id)).toEqual(["p2"]); + }, + ); + }, + ); + + it("refuses every repeated row of a part it never writes and counts none", async () => { + const store = await context.createStore( + purgeGraph("import_required_part_refused_duplicates"), + ); + const result = await importGraph( + store, + document( + [ + { kind: "IrpPart", id: "dup", properties: {} }, + { kind: "IrpPart", id: "dup", properties: {} }, + ], + [], + ), + options({ onConflict: "update" }), + ); + + expect(result.nodes).toEqual({ created: 0, updated: 0, skipped: 0 }); + expect(result.errors.map((error) => error.id)).toEqual(["dup", "dup"]); + expect(await store.nodes.IrpPart.count()).toBe(0); + }); + + it("takes a purged part and its edges off the updated counts too", async () => { + const store = await context.createStore( + purgeGraph("import_required_part_purge_updated"), + ); + const result = await importGraph( + store, + document( + [ + { kind: "IrpTag", id: "t1", properties: {} }, + { kind: "IrpPart", id: "dup", properties: {} }, + { kind: "IrpPart", id: "dup", properties: {} }, + ], + [ + danglingCompositionEdge("po-dup", "dup"), + tagEdge("e", "dup"), + tagEdge("e", "dup"), + ], + ), + options({ onConflict: "update", validateReferences: false }), + ); + + console.info("purge of a part a later row updated", result); + expect(await store.nodes.IrpPart.count()).toBe(0); + expect(await store.edges.irpTagged.count()).toBe(0); + expect(result.nodes).toEqual({ created: 1, updated: 0, skipped: 0 }); + expect(result.edges).toEqual({ created: 0, updated: 0, skipped: 0 }); + expect(result.errors.map((error) => error.id).toSorted()).toEqual([ + "dup", + "e", + "po-dup", + ]); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/import-target-cardinality.ts b/packages/typegraph/tests/backends/integration/import-target-cardinality.ts new file mode 100644 index 000000000..2932870dc --- /dev/null +++ b/packages/typegraph/tests/backends/integration/import-target-cardinality.ts @@ -0,0 +1,261 @@ +/** + * Target-side edge cardinality during `importGraph` / `importGraphStream` + * (issue #610, import half). + * + * `registerPendingEdgeForCardinality` (`src/store/operations/edge-batch-validation.ts`) + * is the pending-batch overlay every axis import reads: a same-chunk pair + * that targets the same node must be caught the same way a persisted + * conflict is, without waiting for a flush round trip. + * + * Every case states, in its own comment, the mutation that must make it + * fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { asNodeId, defineEdge, defineGraph, defineNode } from "../../../src"; +import { + FORMAT_VERSION, + type GraphData, + importGraph, + type ImportOptions, +} from "../../../src/interchange"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const Person = defineNode("ItcPerson", { schema: z.object({}) }); +const Target = defineNode("ItcTarget", { schema: z.object({}) }); +const assignedTo = defineEdge("itcAssignedTo", { schema: z.object({}) }); +/** A two-axis kind: bob's row can fail on ONE axis while its OTHER axis is + * genuinely free — the shape that isolates "was a REJECTED row's endpoint + * ever registered as pending" from "was its own occupied key re-registered", + * which a single-axis kind cannot distinguish (a row rejected for occupying + * a key can only ever re-register that SAME, already-occupied key). */ +const ownsBoth = defineEdge("itcOwnsBoth", { schema: z.object({}) }); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { ItcPerson: { type: Person }, ItcTarget: { type: Target } }, + edges: { + itcAssignedTo: { + type: assignedTo, + from: [Person], + to: [Target], + targetCardinality: "one", + }, + itcOwnsBoth: { + type: ownsBoth, + from: [Person], + to: [Target], + cardinality: "one", + targetCardinality: "one", + }, + }, + }); +} + +let graphIdCounter = 0; +function nextGraphId(): string { + graphIdCounter += 1; + return `import_target_cardinality_${graphIdCounter}`; +} + +function edgeRow( + id: string, + fromId: string, + toId: string, +): GraphData["edges"][number] { + return { + kind: "itcAssignedTo", + id, + from: { kind: "ItcPerson", id: fromId }, + to: { kind: "ItcTarget", id: toId }, + properties: {}, + }; +} + +function ownsBothEdgeRow( + id: string, + fromId: string, + toId: string, +): GraphData["edges"][number] { + return { + kind: "itcOwnsBoth", + id, + from: { kind: "ItcPerson", id: fromId }, + to: { kind: "ItcTarget", id: toId }, + properties: {}, + }; +} + +function payload( + nodes: GraphData["nodes"], + edges: GraphData["edges"], +): GraphData { + return { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external", description: "import target cardinality" }, + nodes, + edges, + }; +} + +const IMPORT_OPTIONS: ImportOptions = { + onConflict: "error", + onUnknownProperty: "error", + validateReferences: true, + refreshStatistics: false, +}; + +export function registerImportTargetCardinalityIntegrationTests( + context: IntegrationTestContext, +): void { + describe("target-side edge cardinality during import", () => { + it("accepts the first same-chunk edge to a target and reports the second as a per-row error", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const result = await importGraph( + store, + payload( + [ + { kind: "ItcPerson", id: "alice", properties: {} }, + { kind: "ItcPerson", id: "bob", properties: {} }, + { kind: "ItcTarget", id: "target", properties: {} }, + ], + [ + edgeRow("edge-alice", "alice", "target"), + edgeRow("edge-bob", "bob", "target"), + ], + ), + IMPORT_OPTIONS, + ); + + expect(result.edges.created).toBe(1); + expect(result.errors).toEqual([ + expect.objectContaining({ entityType: "edge", id: "edge-bob" }), + ]); + // The chunk's other rows still commit: both nodes exist. + expect( + await store.nodes.ItcPerson.getById(asNodeId("alice")), + ).toBeDefined(); + expect( + await store.nodes.ItcPerson.getById(asNodeId("bob")), + ).toBeDefined(); + expect( + await store.edges.itcAssignedTo.findTo({ + kind: "ItcTarget", + id: "target", + }), + ).toHaveLength(1); + }); + // MUTATION CHECK (verified): in `registerPendingEdgeForCardinality` + // (`src/store/operations/edge-batch-validation.ts`), add an early + // `if (spec.keyShape === "to") continue;` (register nothing for a + // `keyShape === "to"` axis). Both same-chunk edges then reach the + // insert batch as unfiltered candidates and this case fails — not with + // `created` reading 2 as first guessed, but with the whole slice + // throwing `DatabaseOperationError: Two edge claims in one batch name + // the same cardinality axis and key`, because the downstream claim + // batcher's OWN same-key guard (a second, independent line of defense) + // catches the duplicate the pending overlay was supposed to prevent + // from ever reaching it. Either failure mode proves the overlay + // load-bearing. + + it("reports a row conflicting with an already-persisted edge the same way", async () => { + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.ItcPerson.create({}, { id: "alice" }); + const target = await store.nodes.ItcTarget.create({}, { id: "target" }); + await store.edges.itcAssignedTo.create( + alice, + target, + {}, + { id: "persisted-edge" }, + ); + + const bob = await store.nodes.ItcPerson.create({}, { id: "bob" }); + const result = await importGraph( + store, + payload([], [edgeRow("edge-bob", bob.id, target.id)]), + IMPORT_OPTIONS, + ); + + expect(result.edges.created).toBe(0); + expect(result.errors).toEqual([ + expect.objectContaining({ entityType: "edge", id: "edge-bob" }), + ]); + }); + // MUTATION CHECK (verified): stub `checkEdgeCardinalityConstraints` + // (`src/store/constraints.ts`) to `return;` unconditionally. The per-row + // probe this test exercises no longer catches the persisted conflict at + // all; the row reaches the real claim write, which refuses it there + // instead — but as an UNCAUGHT `CardinalityError` that aborts the whole + // import with a thrown exception rather than a clean per-row + // `result.errors` entry, so this test still fails (on the thrown + // exception) even though a *different* layer happened to catch the + // underlying conflict. + + it("a row rejected on ONE axis leaves its OTHER, unoccupied axis unclaimed for a later same-chunk row", async () => { + // `itcOwnsBoth` constrains BOTH axes. alice already owns `target` + // (both her source and the target axis are occupied). bob's row to + // `target` is rejected — on the TARGET axis, since bob's OWN source + // axis (bob has created nothing yet) is genuinely free. A LATER row + // in the SAME chunk, bob -> a FRESH target, must still succeed: bob's + // rejected attempt never actually held anything. If a rejected row's + // full declaration were registered in the pending overlay regardless + // of which axis rejected it, bob's free source axis would be wrongly + // poisoned by his own rejected attempt. + const store = await context.createStore(buildGraph(nextGraphId())); + const alice = await store.nodes.ItcPerson.create({}, { id: "alice" }); + const target = await store.nodes.ItcTarget.create({}, { id: "target" }); + await store.edges.itcOwnsBoth.create(alice, target, {}); + + const result = await importGraph( + store, + payload( + [ + { kind: "ItcPerson", id: "bob", properties: {} }, + { kind: "ItcTarget", id: "fresh-target", properties: {} }, + ], + [ + ownsBothEdgeRow("edge-bob-rejected", "bob", "target"), + ownsBothEdgeRow("edge-bob-fresh", "bob", "fresh-target"), + ], + ), + IMPORT_OPTIONS, + ); + + expect(result.errors).toEqual([ + expect.objectContaining({ + entityType: "edge", + id: "edge-bob-rejected", + }), + ]); + expect(result.edges.created).toBe(1); + const bob = requireDefined( + await store.nodes.ItcPerson.getById(asNodeId("bob")), + ); + expect(await store.edges.itcOwnsBoth.findFrom(bob)).toHaveLength(1); + }); + // MUTATION CHECK (verified): in `src/interchange/import.ts`, call + // `registerPendingEdgeForCardinality(params, declarations)` + // UNCONDITIONALLY (moved above the `if (!cardinalityResult.ok)` early + // return) instead of only for accepted rows. Verified this does NOT + // move the sibling single-axis "same-chunk" case above (a row rejected + // for occupying a key can only ever re-register that SAME, + // already-occupied key, so poisoning it changes nothing observable) — + // it DOES break this two-axis case: with the mutation, + // `edge-bob-rejected`'s rejection ALSO registers bob's otherwise-free + // SOURCE axis as pending, and `edge-bob-fresh` (which shares no key with + // alice at all) is then wrongly reported as a second error; + // `result.edges.created` reads 0 instead of 1. + + // `graphOwesClaims` (src/store/constraints.ts) refusing a + // target-only-constrained graph up front is exercised in + // `tests/constraint-fence-capability.test.ts` ("refuses an import into a + // graph whose only hazard is a target-cardinality claim") — that case + // needs a genuine transactionless backend construction + // (`transactionMode: "none"`), which this suite's registered backends + // are not, so it lives there rather than as a no-op here. + }); +} diff --git a/packages/typegraph/tests/backends/integration/import-uniqueness.ts b/packages/typegraph/tests/backends/integration/import-uniqueness.ts index 1d6ecde81..dc91e726d 100644 --- a/packages/typegraph/tests/backends/integration/import-uniqueness.ts +++ b/packages/typegraph/tests/backends/integration/import-uniqueness.ts @@ -661,7 +661,7 @@ export function registerImportUniquenessIntegrationTests( // declares at most one. The refusal has to be per ROW — first edge // committed, second reported, import resolving — and it is per row only // because the probe runs against the shared in-batch cardinality state. - // Without that state both edges read `countEdgesFrom` = 0 (neither row is + // Without that state both edges read `countEdgesAtEndpoint` = 0 (neither row is // written yet), both pass, and the refusal arrives from the ONE batch // claim, which runs outside every per-row recovery and rejects the import. graphIdCounter += 1; diff --git a/packages/typegraph/tests/backends/integration/index.ts b/packages/typegraph/tests/backends/integration/index.ts index e44d1184e..5ee0be78e 100644 --- a/packages/typegraph/tests/backends/integration/index.ts +++ b/packages/typegraph/tests/backends/integration/index.ts @@ -3,6 +3,7 @@ export { registerAdoptedEvolutionConcurrencyTests } from "./adopted-evolution-co export { registerAggregateIntegrationTests } from "./aggregates"; export { registerAlgorithmIntegrationTests } from "./algorithms"; export { registerBackendProvenanceIntegrationTests } from "./backend-provenance"; +export { registerBatchWriteVerdictIntegrationTests } from "./batch-write-verdicts"; export { registerBulkFindByIndexIntegrationTests } from "./bulk-find-by-index"; export { registerBulkFindEndpointIntegrationTests } from "./bulk-find-endpoints"; export { registerBulkFindHeterogeneousIntegrationTests } from "./bulk-find-heterogeneous"; @@ -17,6 +18,14 @@ export { registerClaimOwnerIdentityIntegrationTests } from "./claim-owner-identi export { registerClearIntegrationTests } from "./clear"; export { registerCoalesceUpsertIntegrationTests } from "./coalesce-upserts"; export { registerCollectionAggregateIntegrationTests } from "./collection-aggregates"; +export { registerCompositionAttachmentIntegrationTests } from "./composition-attachment"; +export { registerCompositionDeletePlanIntegrationTests } from "./composition-delete-plan"; +export { registerCompositionExistenceIntegrationTests } from "./composition-existence"; +export { registerCompositionFenceIntegrationTests } from "./composition-fence"; +export { registerCompositionMembershipReadIntegrationTests } from "./composition-membership-reads"; +export { registerCompositionNavigationIntegrationTests } from "./composition-navigation"; +export { registerCompositionReadsModelIntegrationTests } from "./composition-reads-model"; +export { registerCompositionTighteningIntegrationTests } from "./composition-tightening"; export { registerConstraintFenceErrorIntegrationTests } from "./constraint-fence-errors"; export { registerConstraintFenceTransactionHealthTests } from "./constraint-fence-transaction-health"; export { registerConstraintFenceVerificationIntegrationTests } from "./constraint-fence-verification"; @@ -24,26 +33,36 @@ export { registerContributionDiagnosticIntegrationTests } from "./contribution-d export { registerCrossBackendConsistencyTests } from "./cross-backend"; export { registerDatabaseExpressionQueryIntegrationTests } from "./database-expression-queries"; export { registerDurableEdgeMatchIdentityIntegrationTests } from "./durable-edge-match-identity"; +export { registerEdgeAcyclicityIntegrationTests } from "./edge-acyclicity"; +export { registerEdgeCardinalityTighteningIntegrationTests } from "./edge-cardinality-tightening"; export { registerEdgeCaseIntegrationTests } from "./edge-cases"; export { registerEdgeClaimSelfHealIntegrationTests } from "./edge-claim-self-heal"; export { registerEdgeOperationIntegrationTests } from "./edge-operations"; export { registerEdgePropertyIntegrationTests } from "./edge-properties"; export { registerAdvancedEdgePropertyIntegrationTests } from "./edge-properties-advanced"; +export { registerEdgeTargetCardinalityIntegrationTests } from "./edge-target-cardinality"; +export { registerEdgeTargetCardinalityTemporalIntegrationTests } from "./edge-target-cardinality-temporal"; export { registerExpressionSubqueryQueryIntegrationTests } from "./expression-subquery-queries"; export type { IntegrationStore } from "./fixtures"; export { integrationTestGraph } from "./fixtures"; export { registerFulltextIntegrationTests } from "./fulltext"; export { registerGraphAnnotationsIntegrationTests } from "./graph-annotations"; export { registerGraphMergeCallbackIntegrationTests } from "./graph-merge-callbacks"; +export { registerGraphMergeCompositionIntegrationTests } from "./graph-merge-composition"; export { registerGraphMergePlanIntegrationTests } from "./graph-merge-plan"; export { registerGraphMergeReviewIntegrationTests } from "./graph-merge-review"; export { registerGraphMergeReviewV2IntegrationTests } from "./graph-merge-review-v2"; +export { registerGraphMergeTargetCardinalityIntegrationTests } from "./graph-merge-target-cardinality"; export { registerGraphStorageIntegrationTests } from "./graph-storage"; export { registerIdentityIntegrationTests } from "./identity"; export { registerCurrentIdentityTraversalTests } from "./identity-current-traversal"; export { registerHistoricalIdentityTraversalTests } from "./identity-historical-traversal"; export { registerIdentityImportIntegrationTests } from "./identity-import"; +export { registerIdentityReplayIntegrationTests } from "./identity-replay"; export { registerIdentitySeparationIntegrationTests } from "./identity-separation"; +export { registerImportDuplicateEdgeOrderIntegrationTests } from "./import-duplicate-edge-order"; +export { registerImportRequiredPartPurgeIntegrationTests } from "./import-required-part-purge"; +export { registerImportTargetCardinalityIntegrationTests } from "./import-target-cardinality"; export { registerImportUniquenessIntegrationTests } from "./import-uniqueness"; export { registerLateMaterializationIntegrationTests } from "./late-materialization"; export { registerLegacyClaimAxisIntegrationTests } from "./legacy-claim-axis"; @@ -54,8 +73,12 @@ export { registerMigrateSchemaKindIntegrationTests } from "./migrate-schema-kind export { registerMultiKindQueryIntegrationTests } from "./multi-kind-queries"; export { registerMultiRecursiveTraversalIntegrationTests } from "./multi-recursive-traversals"; export { registerMultiRootSubgraphBatchIntegrationTests } from "./multi-root-subgraph-batch"; +export { registerOntologyEquivalenceIntegrationTests } from "./ontology-equivalence"; +export { registerOntologyTighteningIntegrationTests } from "./ontology-tightening"; +export { registerOntologyTypedSubsumptionIntegrationTests } from "./ontology-typed-subsumption"; export { registerOrderingIntegrationTests } from "./ordering"; export { registerPaginationIntegrationTests } from "./pagination"; +export { registerKeysetPaginationPropertyTests } from "./pagination-keyset-property"; export { registerPredicateIntegrationTests } from "./predicates"; export { registerProvenanceIntegrationTests } from "./provenance"; export { registerQueryHookIntegrationTests } from "./query-hooks"; @@ -87,6 +110,7 @@ export type { IntegrationTestContext } from "./test-context"; export { registerTransactionReceiptIntegrationTests } from "./transaction-receipt"; export { registerTraversalIntegrationTests } from "./traversals"; export { registerTrustedImportIntegrationTests } from "./trusted-import"; +export { registerUniquenessClaimGateIntegrationTests } from "./uniqueness-claim-gate"; export { registerValidityEndClearingIntegrationTests } from "./validity-end-clearing"; export { registerValidityLowerBoundIntegrationTests } from "./validity-lower-bound"; export { registerWeightedShortestPathExtractionIntegrationTests } from "./weighted-shortest-path-extraction"; diff --git a/packages/typegraph/tests/backends/integration/lineage-conformance.ts b/packages/typegraph/tests/backends/integration/lineage-conformance.ts index 540cdce26..b84081f39 100644 --- a/packages/typegraph/tests/backends/integration/lineage-conformance.ts +++ b/packages/typegraph/tests/backends/integration/lineage-conformance.ts @@ -101,6 +101,26 @@ export type LineageConformanceContext = Readonly<{ getStore: () => Readonly<{ backend: GraphBackend }>; }>; +async function provisionIdentityHistoryStore( + context: LineageConformanceContext, +) { + const backend = context.getStore().backend; + const [store] = await createStoreWithSchema(identityGapGraph, backend, { + history: true, + }); + const first = await store.nodes.LineagePerson.create({ name: "First" }); + const second = await store.nodes.LineagePerson.create({ name: "Second" }); + return { backend, store, first, second }; +} + +async function requireLineageAnchor( + store: Awaited>["store"], +): Promise { + const anchor = await store.lineageRevisionNow(); + if (anchor === undefined) throw new Error("expected a lineage anchor"); + return anchor; +} + export function registerLineageConformanceIntegrationTests( context: LineageConformanceContext, ): void { @@ -377,4 +397,63 @@ export function registerLineageConformanceIntegrationTests( expect(delta.edges).toEqual([]); }); }); + + describe("lineage: Store.changesSince and identity writes", () => { + it("answers unbounded for a span whose only change is an identity assertion, while the lineage source stays bounded", async () => { + const { backend, store, first, second } = + await provisionIdentityHistoryStore(context); + const anchor = await requireLineageAnchor(store); + + await store.identity.assertSame(first, second); + + // The keys name nodes and edges only: an exact empty delta here would + // tell the caller nothing changed although two classes fused. + expect(await store.changesSince(anchor)).toEqual({ kind: "unbounded" }); + // The source graph-merge prunes by is deliberately NOT what changed: it + // stays a bounded node/edge delta across the same identity write. + expect( + await recordedRelationsLineage(store).changesSince( + backend, + anchor, + store.graphId, + ), + ).toEqual({ kind: "keys", nodes: [], edges: [] }); + }); + + it("answers unbounded when an identity assertion shares the span with node writes, and when one is retracted", async () => { + const { store, first, second } = + await provisionIdentityHistoryStore(context); + const beforeAssert = await requireLineageAnchor(store); + const asserted = await store.identity.assertSame(first, second); + await store.nodes.LineagePerson.create({ name: "Third" }); + expect(await store.changesSince(beforeAssert)).toEqual({ + kind: "unbounded", + }); + + const beforeRetract = await requireLineageAnchor(store); + await store.identity.retractAssertion(asserted.assertion.id); + expect(await store.changesSince(beforeRetract)).toEqual({ + kind: "unbounded", + }); + }); + + it("still answers exact keys for a span that follows the identity write and holds none", async () => { + const { store, first, second } = + await provisionIdentityHistoryStore(context); + await store.identity.assertSame(first, second); + const afterAssert = await requireLineageAnchor(store); + + expect(await store.changesSince(afterAssert)).toEqual({ + kind: "keys", + nodes: [], + edges: [], + }); + const third = await store.nodes.LineagePerson.create({ name: "Third" }); + expect(await store.changesSince(afterAssert)).toEqual({ + kind: "keys", + nodes: [{ kind: "LineagePerson", id: third.id }], + edges: [], + }); + }); + }); } diff --git a/packages/typegraph/tests/backends/integration/multi-kind-queries.ts b/packages/typegraph/tests/backends/integration/multi-kind-queries.ts index 7e32b63bd..17878e81a 100644 --- a/packages/typegraph/tests/backends/integration/multi-kind-queries.ts +++ b/packages/typegraph/tests/backends/integration/multi-kind-queries.ts @@ -8,7 +8,7 @@ export function registerMultiKindQueryIntegrationTests( context: IntegrationTestContext, ): void { describe("Multi-kind queries", () => { - it("refuses empty and unknown sources, expansion options, and unshared fields", () => { + it("refuses empty and unknown sources, the removed includeSubClasses option, and unshared fields", () => { const store = context.getStore(); expect(() => store.query().from([] as unknown as readonly ["Person"], "node"), @@ -21,11 +21,11 @@ export function registerMultiKindQueryIntegrationTests( expect(() => store .query() - // @ts-expect-error List sources deliberately have no ontology expansion option. + // @ts-expect-error `expansion` replaced the includeSubClasses option. .from(["Person", "Company"] as const, "node", { includeSubClasses: true, }), - ).toThrow("does not accept from() options"); + ).toThrow('no longer accepts "includeSubClasses"'); expect(() => store .query() diff --git a/packages/typegraph/tests/backends/integration/ontology-equivalence.ts b/packages/typegraph/tests/backends/integration/ontology-equivalence.ts new file mode 100644 index 000000000..0c64584e3 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/ontology-equivalence.ts @@ -0,0 +1,128 @@ +/** + * Cross-backend code-point ordering for the equivalence-is-subsumption fold. + * + * Every kind-name ordering — the subclass-component sort behind + * the `kindWithSubClasses` claim axis chief among them — moved from + * `compareStrings` (UTF-16 code-unit order) to `compareCodePoints` + * (code-point order, matching SQLite's `BINARY` and Postgres's `C` + * collation). The two orders disagree for an astral character: its lead + * surrogate sorts BELOW U+FFFD under code-unit order, but its real code point + * is far ABOVE it. A JS-side ordering that used the wrong comparator would + * disagree with SQL row order without any test on a single backend ever + * noticing, which is why this lives in the shared cross-backend suite rather + * than a per-dialect test. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + defineGraph, + defineNode, + equivalentTo, + UniquenessError, +} from "../../../src"; +import { type IntegrationTestContext } from "./test-context"; + +/** + * `"K\u{10000}"` (an astral character) vs `"K�"` (a BMP character): + * `compareStrings` puts `ASTRAL_KIND` first because its lead surrogate + * (`0xD800`) is less than `0xFFFD`; `compareCodePoints` puts `BMP_KIND` first + * because its real code point (`0xFFFD`) is less than `0x10000`. + */ +const ASTRAL_KIND = "K\u{10000}"; +const BMP_KIND = "K�"; + +/** + * `"Zebra"` vs `"apple"`: code-point (and SQL `BINARY`/`C`) order sorts + * uppercase before lowercase (`Z` is `0x5A`, `a` is `0x61`), which + * ICU/locale-aware collation would reverse (case-insensitive "apple" < + * "Zebra"). + */ +const UPPERCASE_KIND = "Zebra"; +const LOWERCASE_KIND = "apple"; + +const EMAIL_SCHEMA = z.object({ email: z.string() }); + +const ASTRAL_NODE = defineNode(ASTRAL_KIND, { schema: EMAIL_SCHEMA }); +const BMP_NODE = defineNode(BMP_KIND, { schema: EMAIL_SCHEMA }); +const UPPERCASE_NODE = defineNode(UPPERCASE_KIND, { schema: EMAIL_SCHEMA }); +const LOWERCASE_NODE = defineNode(LOWERCASE_KIND, { schema: EMAIL_SCHEMA }); + +const EQUIV_EMAIL_CONSTRAINT = "equiv_email"; + +const EQUIV_EMAIL_UNIQUE = { + name: EQUIV_EMAIL_CONSTRAINT, + fields: ["email"], + scope: "kindWithSubClasses", + collation: "binary", +} as const; + +const codePointOrderingGraph = defineGraph({ + id: "ontology_equivalence_code_point_ordering", + nodes: { + [ASTRAL_KIND]: { type: ASTRAL_NODE, unique: [EQUIV_EMAIL_UNIQUE] }, + [BMP_KIND]: { type: BMP_NODE, unique: [EQUIV_EMAIL_UNIQUE] }, + [UPPERCASE_KIND]: { type: UPPERCASE_NODE, unique: [EQUIV_EMAIL_UNIQUE] }, + [LOWERCASE_KIND]: { type: LOWERCASE_NODE, unique: [EQUIV_EMAIL_UNIQUE] }, + }, + edges: {}, + ontology: [ + equivalentTo(ASTRAL_NODE, BMP_NODE), + equivalentTo(UPPERCASE_NODE, LOWERCASE_NODE), + ], +}); + +export function registerOntologyEquivalenceIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Ontology equivalence — code-point ordering", () => { + it("orders the astral/BMP subclass component by code point, not code unit", async () => { + const store = await context.createStore(codePointOrderingGraph); + expect(store.registry.getSubClassComponent(ASTRAL_KIND)).toEqual([ + BMP_KIND, + ASTRAL_KIND, + ]); + expect(store.registry.getSubClassComponent(BMP_KIND)).toBe( + store.registry.getSubClassComponent(ASTRAL_KIND), + ); + }); + + it("orders the uppercase/lowercase pair uppercase-first, unlike ICU collation", async () => { + const store = await context.createStore(codePointOrderingGraph); + // Code-point order: 'Z' (0x5A) < 'a' (0x61), so the uppercase kind + // sorts FIRST — the opposite of case-insensitive ICU/locale collation. + expect(store.registry.getSubClassComponent(UPPERCASE_KIND)).toEqual([ + UPPERCASE_KIND, + LOWERCASE_KIND, + ]); + }); + + it("fences a kindWithSubClasses collision across the astral/BMP equivalence class", async () => { + const store = await context.createStore(codePointOrderingGraph); + const astralCollection = store.getNodeCollection(ASTRAL_KIND); + const bmpCollection = store.getNodeCollection(BMP_KIND); + if (astralCollection === undefined || bmpCollection === undefined) { + throw new Error("Expected astral/BMP node collections to exist."); + } + + await astralCollection.create({ email: "astral-then-bmp@example.com" }); + await expect( + bmpCollection.create({ email: "astral-then-bmp@example.com" }), + ).rejects.toThrow(UniquenessError); + }); + + it("fences the reverse creation order (BMP then astral) identically", async () => { + const store = await context.createStore(codePointOrderingGraph); + const astralCollection = store.getNodeCollection(ASTRAL_KIND); + const bmpCollection = store.getNodeCollection(BMP_KIND); + if (astralCollection === undefined || bmpCollection === undefined) { + throw new Error("Expected astral/BMP node collections to exist."); + } + + await bmpCollection.create({ email: "bmp-then-astral@example.com" }); + await expect( + astralCollection.create({ email: "bmp-then-astral@example.com" }), + ).rejects.toThrow(UniquenessError); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/ontology-tightening.ts b/packages/typegraph/tests/backends/integration/ontology-tightening.ts new file mode 100644 index 000000000..e1987a80a --- /dev/null +++ b/packages/typegraph/tests/backends/integration/ontology-tightening.ts @@ -0,0 +1,1565 @@ +/** + * Cross-backend contract for ontology change classification with + * data-validated tightening. + * + * The preflight path (`createSchemaVersionMembers`) is dialect-agnostic, so + * there is deliberately no per-dialect file here — a SQLite-only or + * PG-only test would certify nothing the shared suite does not. Every case + * states, in its own comment, the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createAdapterStoreWithSchema, + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + disjointWith, + inverseOf, + MigrationError, + relatedTo, + subClassOf, +} from "../../../src"; +import { + deriveBackend, + projectBackendWithout, +} from "../../../src/backend/derive-backend"; +import { type AdapterBackend } from "../../../src/backend/types"; +import { computeUniqueKey } from "../../../src/constraints"; +import { buildKindRegistry } from "../../../src/registry"; +import { + getActiveSchema, + migrateSchema, + rollbackSchema, +} from "../../../src/schema"; +import { + DISJOINT_CONSTRAINT_NAME, + disjointnessClaimAxis, +} from "../../../src/store/claims/axis"; +import { compareStrings } from "../../../src/utils/compare"; +import { requireDefined } from "../../../src/utils/presence"; +import { matchingObject } from "../../test-utils"; +import { type IntegrationTestContext } from "./test-context"; + +const Person = defineNode("Person", { schema: z.object({}) }); +const Company = defineNode("Company", { schema: z.object({}) }); +const Organization = defineNode("Organization", { schema: z.object({}) }); +const worksFor = defineEdge("worksFor", { schema: z.object({}) }); +const keyedKnows = defineEdge("keyedKnows", { + schema: z.object({ label: z.string(), note: z.string() }), +}); + +/** + * A subClassOf/disjointWith relation between these two never changes across + * a test's before/after graphs. `serializeOntology` derives a schema's + * `metaEdges` dict from the meta-edges its surviving RELATIONS actually + * reference, so removing the only relation that names a meta-edge also + * removes the meta-edge itself — an unrelated, pre-existing `breaking` + * change ("meta-edge removed") that would otherwise mask the relation-level + * classification these tests are about. Keeping one relation of each + * meta-edge alive on both sides isolates the assertion to the relation this + * test actually adds or removes. + */ +const Bystander = defineNode("Bystander", { schema: z.object({}) }); +const OtherBystander = defineNode("OtherBystander", { schema: z.object({}) }); + +/** Used only by the removed-kind-rule test: a kind removed alongside its own `subClassOf` relation. */ +const Gadget = defineNode("Gadget", { schema: z.object({}) }); + +const SIBLING_EMAIL_UNIQUE = { + name: "sibling_email_unique", + fields: ["email"], + scope: "kindWithSubClasses", + collation: "binary", +} as const; +const SiblingA = defineNode("SiblingA", { + schema: z.object({ email: z.string() }), +}); +const SiblingB = defineNode("SiblingB", { + schema: z.object({ email: z.string() }), +}); + +/** Re-pairing neighbours that sort after `SiblingA` / `SiblingB`. */ +const SiblingTailA = defineNode("SiblingTailA", { + schema: z.object({ email: z.string() }), +}); +const SiblingTailB = defineNode("SiblingTailB", { + schema: z.object({ email: z.string() }), +}); + +async function activeVersion( + context: IntegrationTestContext, + id: string, +): Promise { + const active = await getActiveSchema(context.getBackend(), id); + return requireDefined(active, "active schema").version; +} + +/** + * Wraps `backend.commitSchemaVersionWithPreflight` so the preflight closure + * runs against a target lacking `readConstraintFenceViolations` — the + * shape a custom port takes, never a spread copy (`src/backend/derive-backend.ts`). + */ +function withoutFenceAudit( + backend: AdapterBackend, +): AdapterBackend { + const commitWithPreflight = requireDefined( + backend.commitSchemaVersionWithPreflight, + ); + return deriveBackend(backend, { + commitSchemaVersionWithPreflight: (params, preflight) => + commitWithPreflight(params, (target) => + preflight( + projectBackendWithout(target, ["readConstraintFenceViolations"]), + ), + ), + }); +} + +/** + * Wraps `backend.commitSchemaVersionWithPreflight` so the preflight closure's + * `readConstraintFenceViolations` answers every existing family but never + * `misassignedEdgeEndpointRows` — the shape a custom backend takes when it + * implemented the audit contract before the `edgeEndpointAssignability` + * family existed and has not been updated for it. `ignoreRestSiblings` + * covers the destructured, deliberately unused field. + */ +function withoutEdgeEndpointFamily( + backend: AdapterBackend, +): AdapterBackend { + const commitWithPreflight = requireDefined( + backend.commitSchemaVersionWithPreflight, + ); + return deriveBackend(backend, { + commitSchemaVersionWithPreflight: (params, preflight) => + commitWithPreflight(params, (target) => { + const audit = requireDefined(target.readConstraintFenceViolations); + return preflight( + deriveBackend(target, { + readConstraintFenceViolations: async (auditParams) => { + const { misassignedEdgeEndpointRows, ...rest } = + await audit(auditParams); + return rest; + }, + }), + ); + }), + }); +} + +/** + * Strips `commitSchemaVersionWithPreflight` entirely — the shape a custom + * backend that never implemented the atomic preflight primitive takes. + */ +function withoutCommitWithPreflight( + backend: AdapterBackend, +): AdapterBackend { + return projectBackendWithout(backend, ["commitSchemaVersionWithPreflight"]); +} + +/** v1 = `subClassOf(Company, Organization)`; v2 additionally declares `disjointWith(Person, Organization)`. */ +const Task = defineNode("Task", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +/** Acyclicity tightening: `acyclic` absent, then declared `true`. */ +function acyclicTighteningGraph(id: string, withAcyclic: boolean) { + return defineGraph({ + id, + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + ...(withAcyclic ? { acyclic: true } : {}), + }, + }, + }); +} + +/** + * v1 declares `acyclic: true`, v2 drops it, and (when `withCycle`) a cycle is + * written under v2: reactivating v1 is the same tightening as committing it + * forward. + */ +async function cycleWrittenAfterDroppingAcyclic( + context: IntegrationTestContext, + id: string, + withCycle: boolean, +): Promise { + await context.createStore(acyclicTighteningGraph(id, true)); + const [relaxed] = await createAdapterStoreWithSchema( + acyclicTighteningGraph(id, false), + context.getBackend(), + ); + expect(await activeVersion(context, id)).toBe(2); + const a = await relaxed.nodes.Task.create({}); + const b = await relaxed.nodes.Task.create({}); + await relaxed.edges.dependsOn.create(a, b); + if (withCycle) await relaxed.edges.dependsOn.create(b, a); +} + +const Animal = defineNode("Animal", { schema: z.object({}) }); +const likes = defineEdge("likes", { schema: z.object({}) }); + +/** `likes(Person → Animal)`, with the subclass chain added by {@link MID_CHAIN_EXTENSION}. */ +function midChainHostGraph(id: string) { + return defineGraph({ + id, + nodes: { Person: { type: Person }, Animal: { type: Animal } }, + edges: { likes: { type: likes, from: [Person], to: [Animal] } }, + }); +} + +/** `Dog ⊂ Mammal ⊂ Animal`: removing `Mammal` cuts `Dog` off from `Animal`. */ +const MID_CHAIN_EXTENSION = defineGraphExtension({ + nodes: { Mammal: { properties: {} }, Dog: { properties: {} } }, + ontology: [ + { metaEdge: "subClassOf", from: "Mammal", to: "Animal" }, + { metaEdge: "subClassOf", from: "Dog", to: "Mammal" }, + ], +}); + +/** + * `worksFor(Person → Organization | Company)`, or the same edge kind with + * `Company` dropped from its targets — as a plain list, or as a + * source-dependent map. + */ +function declaredEndpointGraph( + id: string, + endpoints: "wide" | "list" | "bySource", +) { + const to = + endpoints === "wide" ? [Organization, Company] + : endpoints === "list" ? [Organization] + : { Person: [Organization] }; + return defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + }, + edges: { worksFor: { type: worksFor, from: [Person], to } }, + }); +} + +/** + * An edge kind a graph declares, drops with its rows left in the table + * (`discardDroppedKindRows`), and then re-adds: `acyclic` and `narrowed` shape + * the re-added declaration. + */ +const Gizmo = defineNode("Gizmo", { schema: z.object({}) }); +const Widget = defineNode("Widget", { schema: z.object({}) }); +const feeds = defineEdge("feeds", { schema: z.object({}) }); +function reAddedEdgeGraph( + id: string, + state: "declared" | "dropped" | "acyclic" | "narrowed", +) { + return defineGraph({ + id, + nodes: { Gizmo: { type: Gizmo }, Widget: { type: Widget } }, + edges: + state === "dropped" ? + {} + : { + feeds: { + type: feeds, + from: [Gizmo], + to: state === "narrowed" ? [Widget] : [Gizmo, Widget], + ...(state === "acyclic" ? { acyclic: true } : {}), + }, + }, + }); +} + +/** Drops `feeds` from `id`'s schema while keeping its rows. */ +async function dropKeepingRows( + context: IntegrationTestContext, + id: string, +): Promise { + await migrateSchema( + context.getBackend(), + reAddedEdgeGraph(id, "dropped"), + 1, + { discardDroppedKindRows: true }, + ); + expect(await activeVersion(context, id)).toBe(2); +} + +function probeGraph(id: string, withDisjoint: boolean) { + return defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + }, + edges: {}, + ontology: [ + subClassOf(Company, Organization), + ...(withDisjoint ? [disjointWith(Person, Organization)] : []), + ], + }); +} + +export function registerOntologyTighteningIntegrationTests( + context: IntegrationTestContext, +): void { + describe("ontology change classification with data-validated tightening", () => { + it("refuses a disjointWith addition existing rows already violate (the disjointness probe)", async () => { + const id = "ontology_tightening_probe"; + const store = await context.createStore(probeGraph(id, false)); + await store.nodes.Person.create({}, { id: "shared" }); + await store.nodes.Company.create({}, { id: "shared" }); + + const v2 = probeGraph(id, true); + const registry = buildKindRegistry(v2); + + const error = await createAdapterStoreWithSchema( + v2, + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "nodeDisjointness", + target: { + relation: "uniques", + graphId: id, + axis: disjointnessClaimAxis("Company", "Person", registry), + constraintName: DISJOINT_CONSTRAINT_NAME, + key: "shared", + }, + owners: [ + { concreteKind: "Company", nodeId: "shared" }, + { concreteKind: "Person", nodeId: "shared" }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: this case fails against + // today's `main` (the commit migrates instead of refusing) and fails + // again when the classifier's `disjointWith`-added row is flipped to + // "safe". + + it("refuses declaring acyclic: true on an edge kind whose live rows already carry a cycle", async () => { + const id = "ontology_tightening_acyclic_probe"; + const store = await context.createStore( + acyclicTighteningGraph(id, false), + ); + const a = await store.nodes.Task.create({}); + const b = await store.nodes.Task.create({}); + // Legal under v1: `dependsOn` declares no `acyclic` yet, so the store + // enforces nothing about the relation's shape. + const forward = await store.edges.dependsOn.create(a, b); + const backward = await store.edges.dependsOn.create(b, a); + + const error = await createAdapterStoreWithSchema( + acyclicTighteningGraph(id, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "edgeAcyclicity", + relation: "dependsOn", + edgeIds: [forward.id, backward.id].toSorted(compareStrings), + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: classifying the `acyclic` + // addition `safe` instead of `warning` (dropping its `probes` entry in + // `edgeAcyclicityAddedDelta`'s caller) makes the commit migrate instead + // of refusing — the defect the tightening preflight exists to close, + // reproduced for the acyclicity axis. + + it("refuses rolling back to a stricter version whose acyclic declaration live rows violate", async () => { + const id = "ontology_tightening_rollback_acyclic"; + await cycleWrittenAfterDroppingAcyclic(context, id, true); + + const error = await rollbackSchema(context.getBackend(), id, 1).catch( + (error_: unknown) => error_, + ); + + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "ontology-tightening-violated", + fromVersion: 2, + toVersion: 1, + }); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: flipping through `backend.setActiveVersion` without the + // tightening preflight in `rollbackSchema` makes the rollback succeed and + // this case fail. + + it("refuses rolling back to a version that keys a populated edge kind by a different match identity", async () => { + const id = "ontology_tightening_rollback_match_identity"; + const keyedGraph = (fields: readonly ("label" | "note")[]) => + defineGraph({ + id, + nodes: { Person: { type: Person } }, + edges: { + keyedKnows: { + type: keyedKnows, + from: [Person], + to: [Person], + matchIdentity: { name: `keyed-${fields.join("-")}`, fields }, + }, + }, + }); + await context.createStore(keyedGraph(["label"])); + await migrateSchema( + context.getBackend(), + keyedGraph(["label", "note"]), + 1, + ); + const [store] = await createAdapterStoreWithSchema( + keyedGraph(["label", "note"]), + context.getBackend(), + ); + const alice = await store.nodes.Person.create({}); + const bob = await store.nodes.Person.create({}); + await store.edges.keyedKnows.create(alice, bob, { + label: "friend", + note: "met at work", + }); + + const error = await rollbackSchema(context.getBackend(), id, 1).catch( + (error_: unknown) => error_, + ); + + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "edge-match-identity-rekey", + fromVersion: 2, + toVersion: 1, + edgeKinds: ["keyedKnows"], + }); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK (verified): passing `edgeMatchIdentity: undefined` to + // `planSchemaCommitPreflight` in `prepareRollbackPreflight` + // (src/schema/manager.ts) reactivates version 1 over rows keyed by + // version 2's identity, and this case fails. + + it("rolls back to a stricter version when live rows satisfy it", async () => { + const id = "ontology_tightening_rollback_clean"; + await cycleWrittenAfterDroppingAcyclic(context, id, false); + + await rollbackSchema(context.getBackend(), id, 1); + + expect(await activeVersion(context, id)).toBe(1); + }); + + it("rolls back a version that tightens nothing on a backend without setActiveVersionWithPreflight", async () => { + const id = "ontology_tightening_rollback_loosening"; + await context.createStore(acyclicTighteningGraph(id, false)); + await createAdapterStoreWithSchema( + acyclicTighteningGraph(id, true), + context.getBackend(), + ); + expect(await activeVersion(context, id)).toBe(2); + const restrictedBackend = projectBackendWithout(context.getBackend(), [ + "setActiveVersionWithPreflight", + ]); + + await rollbackSchema(restrictedBackend, id, 1); + + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses a tightening rollback on a backend without setActiveVersionWithPreflight, leaving the active version", async () => { + const id = "ontology_tightening_rollback_capability"; + await cycleWrittenAfterDroppingAcyclic(context, id, false); + const restrictedBackend = projectBackendWithout(context.getBackend(), [ + "setActiveVersionWithPreflight", + ]); + + const error = await rollbackSchema(restrictedBackend, id, 1).catch( + (error_: unknown) => error_, + ); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("still migrates cleanly when the newly-acyclic edge kind's data has no cycle", async () => { + const id = "ontology_tightening_acyclic_clean"; + const store = await context.createStore( + acyclicTighteningGraph(id, false), + ); + const a = await store.nodes.Task.create({}); + const b = await store.nodes.Task.create({}); + await store.edges.dependsOn.create(a, b); + + const [migrated] = await createAdapterStoreWithSchema( + acyclicTighteningGraph(id, true), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await expect(migrated.edges.dependsOn.create(b, a)).rejects.toMatchObject( + { name: "EdgeAcyclicityError" }, + ); + }); + + it("excludes an unrelated safe change from a refused tightening's details.changes", async () => { + const id = "ontology_tightening_details_changes_filtered"; + const graphWith = (withTighteningAndUnrelated: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + Bystander: { type: Bystander }, + OtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + subClassOf(Company, Organization), + ...(withTighteningAndUnrelated ? + [ + disjointWith(Person, Organization), + relatedTo(Bystander, OtherBystander), + ] + : []), + ], + }); + + const store = await context.createStore(graphWith(false)); + await store.nodes.Person.create({}, { id: "shared" }); + await store.nodes.Company.create({}, { id: "shared" }); + + const error = await createAdapterStoreWithSchema( + graphWith(true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + // The `relatedTo` addition classifies `safe` and carries no probe: it + // must not appear in `details.changes` alongside the `disjointWith` + // addition that actually required (and failed) a data check. + expect(details.changes).toHaveLength(1); + expect(details.changes[0]).toMatchObject({ + entity: "relation", + type: "added", + name: "disjointWith:Person:Organization", + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: reporting the full unfiltered `classifyOntologyChanges` + // result instead of `probedChanges` in + // `buildOntologyTighteningViolatedError` makes `details.changes` carry + // both entries and this test fail. + + it("refuses the same tightening through Store.evolve()", async () => { + const id = "ontology_tightening_evolve_twin"; + const store = await context.createStore(probeGraph(id, false)); + await store.nodes.Person.create({}, { id: "shared" }); + await store.nodes.Company.create({}, { id: "shared" }); + + await expect( + store.evolve( + defineGraphExtension({ + ontology: [ + { metaEdge: "disjointWith", from: "Person", to: "Organization" }, + ], + }), + ), + ).rejects.toMatchObject({ + name: "MigrationError", + details: matchingObject({ reason: "ontology-tightening-violated" }), + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: removing the + // `ontologyPreflight` step from `Store.evolve`'s composed callback makes + // the evolve succeed and this test fail. + + it("refuses a subClassOf removal a live edge's endpoint relies on", async () => { + const id = "ontology_tightening_endpoint_shrink"; + const graphWith = (withSubClass: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + Bystander: { type: Bystander }, + OtherBystander: { type: OtherBystander }, + }, + edges: { + worksFor: { type: worksFor, from: [Person], to: [Organization] }, + }, + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(withSubClass ? [subClassOf(Company, Organization)] : []), + ], + }); + + const store = await context.createStore(graphWith(true)); + const person = await store.nodes.Person.create({}); + const company = await store.nodes.Company.create({}); + // Written through the backend directly: Company is only assignable to + // `worksFor`'s declared `to: [Organization]` via the live subClassOf + // relation, which the compile-time edge-collection types cannot + // express — this is the exact shape the removal below invalidates. + const edgeRow = await store.backend.insertEdge({ + graphId: id, + id: "shrink-endpoint-edge", + kind: "worksFor", + fromKind: "Person", + fromId: person.id, + toKind: "Company", + toId: company.id, + props: {}, + }); + + const error = await createAdapterStoreWithSchema( + graphWith(false), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "edgeEndpointAssignability", + edgeKind: "worksFor", + allowedPairs: [["Person", "Organization"]], + edges: [ + { + edgeKind: "worksFor", + edgeId: edgeRow.id, + fromKind: "Person", + fromId: person.id, + toKind: "Company", + toId: company.id, + }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: dropping + // `edgeEndpointAllowances` from the probe plan makes the commit succeed + // and this test fail. + + it("refuses a subClassOf addition that merges two duplicate-holding uniqueness components", async () => { + const id = "ontology_tightening_uniqueness_merge"; + const graphWith = (merged: boolean) => + defineGraph({ + id, + nodes: { + SiblingA: { type: SiblingA, unique: [SIBLING_EMAIL_UNIQUE] }, + SiblingB: { type: SiblingB, unique: [SIBLING_EMAIL_UNIQUE] }, + }, + edges: {}, + ontology: merged ? [subClassOf(SiblingA, SiblingB)] : [], + }); + + const store = await context.createStore(graphWith(false)); + const nodeA = await store.nodes.SiblingA.create({ + email: "dup@example.com", + }); + const nodeB = await store.nodes.SiblingB.create({ + email: "dup@example.com", + }); + + const error = await createAdapterStoreWithSchema( + graphWith(true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "nodeUniqueness", + target: { + relation: "uniques", + graphId: id, + axis: "SiblingA", + constraintName: "sibling_email_unique", + key: computeUniqueKey( + { email: "dup@example.com" }, + ["email"], + "binary", + ), + }, + owners: [ + { concreteKind: "SiblingA", nodeId: nodeA.id }, + { concreteKind: "SiblingB", nodeId: nodeB.id }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: dropping the + // `nodeUniquenessComponent` probe makes the commit succeed and this + // test fail. + + it("refuses a re-pairing that merges two duplicate-holding kinds while each loses its old neighbour", async () => { + const id = "ontology_tightening_uniqueness_repair"; + const nodes = { + SiblingA: { type: SiblingA, unique: [SIBLING_EMAIL_UNIQUE] }, + SiblingB: { type: SiblingB, unique: [SIBLING_EMAIL_UNIQUE] }, + SiblingTailA: { type: SiblingTailA }, + SiblingTailB: { type: SiblingTailB }, + }; + // Before: {SiblingA, SiblingTailA} and {SiblingB, SiblingTailB}. After: + // {SiblingA, SiblingB}. Neither sibling's component GREW as a set — each + // swapped one neighbour for another — yet the two now share an axis. + // Each old neighbour sorts AFTER its sibling, so every existing claim + // sits at an axis the merged component still probes: the commit is + // decided by the collision probe, not refused as a stranded axis. + const before = defineGraph({ + id, + nodes, + edges: {}, + ontology: [ + subClassOf(SiblingA, SiblingTailA), + subClassOf(SiblingB, SiblingTailB), + ], + }); + const after = defineGraph({ + id, + nodes, + edges: {}, + ontology: [subClassOf(SiblingA, SiblingB)], + }); + + const store = await context.createStore(before); + const nodeA = await store.nodes.SiblingA.create({ + email: "dup@example.com", + }); + const nodeB = await store.nodes.SiblingB.create({ + email: "dup@example.com", + }); + + const error = await createAdapterStoreWithSchema( + after, + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "nodeUniqueness", + target: { + relation: "uniques", + graphId: id, + axis: "SiblingA", + constraintName: "sibling_email_unique", + key: computeUniqueKey( + { email: "dup@example.com" }, + ["email"], + "binary", + ), + }, + owners: [ + { concreteKind: "SiblingA", nodeId: nodeA.id }, + { concreteKind: "SiblingB", nodeId: nodeB.id }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: restoring the proper-subset test in + // `nodeUniquenessComponentGroups` (a component must strictly GROW) + // yields no group for this diff, the commit migrates, and the store + // resolves instead of throwing. + + it("classifies an added inverseOf relation as breaking, blocking auto-migrate", async () => { + const id = "ontology_tightening_inverse_breaking"; + const likes = defineEdge("likes", { schema: z.object({}) }); + const likedBy = defineEdge("likedBy", { schema: z.object({}) }); + const graphWith = (withInverse: boolean) => + defineGraph({ + id, + nodes: { Person: { type: Person } }, + edges: { + likes: { type: likes, from: [Person], to: [Person] }, + likedBy: { type: likedBy, from: [Person], to: [Person] }, + }, + ontology: withInverse ? [inverseOf(likes, likedBy)] : [], + }); + await context.createStore(graphWith(false)); + const v2 = graphWith(true); + + const error = await createAdapterStoreWithSchema( + v2, + context.getBackend(), + ).catch((error_: unknown) => error_); + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "breaking-change") { + throw new Error(`expected breaking-change, got ${details.reason}`); + } + expect(await activeVersion(context, id)).toBe(1); + + await migrateSchema( + context.getBackend(), + v2, + await activeVersion(context, id), + ); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: setting the `inverseOf` row + // to "warning" makes the first half of this test fail (auto-migrate + // would succeed instead of throwing `breaking-change`). + + it("still auto-migrates a clean tightening", async () => { + const id = "ontology_tightening_clean"; + const graphWith = (withSubClass: boolean, withDisjoint: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + }, + edges: { + worksFor: { type: worksFor, from: [Person], to: [Organization] }, + }, + ontology: [ + ...(withSubClass ? [subClassOf(Company, Organization)] : []), + ...(withDisjoint ? [disjointWith(Person, Organization)] : []), + ], + }); + + const store = await context.createStore(graphWith(true, false)); + const person = await store.nodes.Person.create({}); + const organization = await store.nodes.Organization.create({}); + await store.edges.worksFor.create(person, organization, {}); + + const [evolvedStore, result] = await createAdapterStoreWithSchema( + graphWith(true, true), + context.getBackend(), + ); + expect(result.status).toBe("migrated"); + expect(await activeVersion(context, id)).toBe(2); + expect(await evolvedStore.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: making the preflight throw + // unconditionally makes this test fail (status would never reach + // "migrated"). + + it("still auto-migrates removing a disjointWith declaration with existing conflicts", async () => { + const id = "ontology_tightening_disjoint_removal_safe"; + const graphWith = (withDisjoint: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Organization: { type: Organization }, + Bystander: { type: Bystander }, + OtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + disjointWith(Bystander, OtherBystander), + ...(withDisjoint ? [disjointWith(Person, Organization)] : []), + ], + }); + + const store = await context.createStore(graphWith(true)); + const person = await store.nodes.Person.create({}, { id: "conflict" }); + // Seeded through the backend directly: the store's own write path + // enforces the live `disjointWith` declaration and would refuse this. + await store.backend.insertNode({ + graphId: id, + kind: "Organization", + id: person.id, + props: {}, + }); + + const [, result] = await createAdapterStoreWithSchema( + graphWith(false), + context.getBackend(), + ); + expect(result.status).toBe("migrated"); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: classifying a `disjointWith` + // removal as "breaking" makes this migration refuse instead of + // auto-migrating, and the test fails. (Reclassifying it as "warning" + // alone does not: the `nodeDisjointness` delta is computed from pairs the + // AFTER registry adds that the BEFORE registry lacks, so a pure removal + // always folds to an empty probe payload regardless of the severity + // label, and `ontologyTighteningProbes` drops an empty-payload probe + // before it ever reaches a preflight.) + + it("lets removeKinds drop an extension kind that participates in disjointWith with live rows on the surviving side", async () => { + const id = "ontology_tightening_remove_kinds"; + const hostGraph = defineGraph({ + id, + nodes: { Person: { type: Person } }, + edges: {}, + }); + const store = await context.createStore(hostGraph); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Widget: { properties: {} } }, + ontology: [ + { metaEdge: "disjointWith", from: "Person", to: "Widget" }, + ], + }), + ); + await evolved.nodes.Person.create({}); + + const afterRemoval = await evolved.removeKinds(["Widget"]); + expect( + afterRemoval.introspect().kinds.map((kind) => kind.name), + ).not.toContain("Widget"); + expect(await activeVersion(context, id)).toBe(3); + }); + // A regression guard for "removeKinds is not refused over a relation + // naming the removed kind"; the next case guards the removed-kind rule + // through `migrateSchema`, and the mid-chain cases below guard what a + // removal still owes a SURVIVING kind. + + it("classifies a subClassOf removal alongside its own kind's removal as safe, through migrateSchema", async () => { + const id = "ontology_tightening_removed_kind_rule"; + const graphWith = (withGadgetAndSubClass: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Organization: { type: Organization }, + ...(withGadgetAndSubClass ? { Gadget: { type: Gadget } } : {}), + }, + edges: { + worksFor: { type: worksFor, from: [Person], to: [Organization] }, + }, + ontology: [ + ...(withGadgetAndSubClass ? + [subClassOf(Gadget, Organization)] + : []), + ], + }); + + const store = await context.createStore(graphWith(true)); + const person = await store.nodes.Person.create({}); + // Relies on the live `subClassOf(Gadget, Organization)` relation: + // `worksFor` only declares `to: [Organization]`. No `Gadget` NODE is + // ever created, so dropping the `Gadget` kind destroys no node rows — + // `migrateSchema`'s own structural drop guard + // (`assertDroppedKindsEmpty`) has nothing to refuse on that basis, + // isolating the assertion to the ontology classifier's own + // removed-kind rule. `ensureSchema`'s auto-migrate branch cannot host + // this case: removing ANY node kind is unconditionally `breaking` + // (unrelated to ontology), so it always requires the explicit + // `migrateSchema()` this case drives directly. + const edgeRow = await store.backend.insertEdge({ + graphId: id, + id: "removed-kind-edge", + kind: "worksFor", + fromKind: "Person", + fromId: person.id, + toKind: "Gadget", + toId: "phantom-gadget", + props: {}, + }); + + // v2 drops the `Gadget` node kind AND its `subClassOf` relation in the + // SAME commit. Without the removed-kind rule, this relation removal + // classifies as `warning` + `edgeEndpointAssignability` like any other + // `subClassOf` removal, and the probe would find the edge above + // sitting outside the shrunken `worksFor` allowance. With the rule, + // the relation is `safe` (it names a kind THIS commit removes), and + // the only admitted pair lost names that removed kind, so no probe + // runs at all. + const version = await migrateSchema( + context.getBackend(), + graphWith(false), + await activeVersion(context, id), + ); + expect(version).toBe(2); + expect(await activeVersion(context, id)).toBe(2); + // The edge that would have been flagged is untouched: the commit + // never ran a probe over it. + expect(await store.backend.getEdge(id, edgeRow.id)).toBeDefined(); + }); + // MUTATION CHECK (verified): deleting the removed-kind guard in + // `classifyRelation` (`src/schema/ontology-change.ts`) makes this + // classify the `subClassOf` removal as an ordinary `warning` + + // `edgeEndpointAssignability` change; the probe then finds the edge + // above outside the shrunken allowance, `migrateSchema` throws + // `MigrationError` `reason: "ontology-tightening-violated"` instead of + // returning the new version, and this test fails. + + it("refuses removeKinds of a mid-chain kind that strands a surviving subclass's live edge", async () => { + const id = "ontology_tightening_mid_chain_removal"; + const store = await context.createStore(midChainHostGraph(id)); + const evolved = await store.evolve(MID_CHAIN_EXTENSION); + const person = await evolved.nodes.Person.create({}); + const dog = await evolved.getNodeCollectionOrThrow("Dog").create({}); + const mammal = await evolved + .getNodeCollectionOrThrow("Mammal") + .create({}); + const dogEdge = await store.backend.insertEdge({ + graphId: id, + id: "likes-dog", + kind: "likes", + fromKind: "Person", + fromId: person.id, + toKind: "Dog", + toId: dog.id, + props: {}, + }); + await store.backend.insertEdge({ + graphId: id, + id: "likes-mammal", + kind: "likes", + fromKind: "Person", + fromId: person.id, + toKind: "Mammal", + toId: mammal.id, + props: {}, + }); + expect(await evolved.verifyConstraintFences()).toEqual([]); + + // Dropping Mammal drops both `subClassOf` relations, so `likes(Person → + // Animal)` stops admitting Dog while the Dog edge survives. Only that + // edge is reported: the Mammal edge belongs to the removed kind, whose + // rows the removal itself reclaims. + const error = await evolved + .removeKinds(["Mammal"]) + .catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.violations).toEqual([ + { + family: "edgeEndpointAssignability", + edgeKind: "likes", + allowedPairs: [ + ["Person", "Animal"], + ["Person", "Mammal"], + ], + edges: [ + { + edgeKind: "likes", + edgeId: dogEdge.id, + fromKind: "Person", + fromId: person.id, + toKind: "Dog", + toId: dog.id, + }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + expect(await evolved.verifyConstraintFences()).toEqual([]); + }); + + it("lets removeKinds drop a mid-chain kind when only its own rows relied on the chain", async () => { + const id = "ontology_tightening_mid_chain_removal_clean"; + const store = await context.createStore(midChainHostGraph(id)); + const evolved = await store.evolve(MID_CHAIN_EXTENSION); + const person = await evolved.nodes.Person.create({}); + const mammal = await evolved + .getNodeCollectionOrThrow("Mammal") + .create({}); + await store.backend.insertEdge({ + graphId: id, + id: "likes-mammal", + kind: "likes", + fromKind: "Person", + fromId: person.id, + toKind: "Mammal", + toId: mammal.id, + props: {}, + }); + + const afterRemoval = await evolved.removeKinds(["Mammal"], { + eager: {}, + }); + + expect(await activeVersion(context, id)).toBe(3); + expect(await afterRemoval.verifyConstraintFences()).toEqual([]); + }); + + it("refuses narrowing an edge kind's declared endpoints out from under a live edge", async () => { + const id = "ontology_tightening_declared_endpoint_narrowing"; + const store = await context.createStore( + declaredEndpointGraph(id, "wide"), + ); + const person = await store.nodes.Person.create({}); + const company = await store.nodes.Company.create({}); + const edge = await store.edges.worksFor.create(person, company, {}); + + for (const narrowing of ["list", "bySource"] as const) { + // A narrowed source-dependent map is `breaking`, so it only ever + // commits through an explicit `migrateSchema()`; a narrowed list + // auto-migrates. Both owe the same probe. + const commit = + narrowing === "list" ? + createAdapterStoreWithSchema( + declaredEndpointGraph(id, narrowing), + context.getBackend(), + ) + : migrateSchema( + context.getBackend(), + declaredEndpointGraph(id, narrowing), + 1, + ); + const error = await commit.catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(details.changes).toEqual([ + matchingObject({ + entity: "edgeRegistration", + name: "worksFor", + severity: "warning", + }), + ]); + expect(details.violations).toEqual([ + { + family: "edgeEndpointAssignability", + edgeKind: "worksFor", + allowedPairs: [["Person", "Organization"]], + edges: [ + { + edgeKind: "worksFor", + edgeId: edge.id, + fromKind: "Person", + fromId: person.id, + toKind: "Company", + toId: company.id, + }, + ], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + } + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses narrowing an edge kind's endpoints out from under an edge that is not yet current", async () => { + const id = "ontology_tightening_endpoint_narrowing_pending_edge"; + const store = await context.createStore( + declaredEndpointGraph(id, "wide"), + ); + const person = await store.nodes.Person.create({}); + const company = await store.nodes.Company.create({}); + const pending = await store.edges.worksFor.create( + person, + company, + {}, + { validFrom: "2999-01-01T00:00:00.000Z" }, + ); + + const error = await createAdapterStoreWithSchema( + declaredEndpointGraph(id, "list"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + // The edge becomes current once the clock reaches its window, so it + // must not end up on a pair the narrowed declaration no longer admits. + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "ontology-tightening-violated", + violations: [ + matchingObject({ + family: "edgeEndpointAssignability", + edges: [matchingObject({ edgeId: pending.id })], + }), + ], + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: reading the endpoint family through the current-window + // predicate instead of `auditedRowTerm` in + // `buildMisassignedEdgeEndpointAudit` commits this narrowing. + + it("refuses re-adding an edge kind as acyclic over rows a discarded drop left in a cycle", async () => { + const id = "ontology_tightening_readded_acyclic"; + const store = await context.createStore(reAddedEdgeGraph(id, "declared")); + const a = await store.nodes.Gizmo.create({}); + const b = await store.nodes.Gizmo.create({}); + const forward = await store.edges.feeds.create(a, b); + const backward = await store.edges.feeds.create(b, a); + await dropKeepingRows(context, id); + + const error = await createAdapterStoreWithSchema( + reAddedEdgeGraph(id, "acyclic"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "ontology-tightening-violated", + violations: [ + { + family: "edgeAcyclicity", + relation: "feeds", + edgeIds: [forward.id, backward.id].toSorted(compareStrings), + }, + ], + }); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: returning no `acyclicEdgeKinds` from + // `addedEdgeKindProbes` commits the re-added kind over the cycle. + + it("refuses re-adding an edge kind with narrower endpoints than the rows a discarded drop left", async () => { + const id = "ontology_tightening_readded_narrowed"; + const store = await context.createStore(reAddedEdgeGraph(id, "declared")); + const a = await store.nodes.Gizmo.create({}); + const b = await store.nodes.Gizmo.create({}); + const stranded = await store.edges.feeds.create(a, b); + await dropKeepingRows(context, id); + + const error = await createAdapterStoreWithSchema( + reAddedEdgeGraph(id, "narrowed"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + expect((error as MigrationError).details).toMatchObject({ + reason: "ontology-tightening-violated", + violations: [ + matchingObject({ + family: "edgeEndpointAssignability", + edgeKind: "feeds", + allowedPairs: [["Gizmo", "Widget"]], + edges: [matchingObject({ edgeId: stranded.id })], + }), + ], + }); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: returning no `endpointAllowances` from + // `addedEdgeKindProbes` commits the re-added kind over the stranded row. + + it("re-adds an edge kind whose declaration admits the rows a discarded drop left", async () => { + const id = "ontology_tightening_readded_admitted"; + const store = await context.createStore(reAddedEdgeGraph(id, "declared")); + const a = await store.nodes.Gizmo.create({}); + const b = await store.nodes.Gizmo.create({}); + await store.edges.feeds.create(a, b); + await dropKeepingRows(context, id); + + const [reAdded] = await createAdapterStoreWithSchema( + reAddedEdgeGraph(id, "acyclic"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(3); + expect(await reAdded.verifyConstraintFences()).toEqual([]); + }); + + it("auto-migrates a narrowed endpoint declaration no live edge sits outside", async () => { + const id = "ontology_tightening_declared_endpoint_narrowing_clean"; + const store = await context.createStore( + declaredEndpointGraph(id, "wide"), + ); + const person = await store.nodes.Person.create({}); + const organization = await store.nodes.Organization.create({}); + await store.edges.worksFor.create(person, organization, {}); + + const [narrowed] = await createAdapterStoreWithSchema( + declaredEndpointGraph(id, "list"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + expect(await narrowed.verifyConstraintFences()).toEqual([]); + }); + + it("refuses rolling back to a version whose narrower endpoints a live edge sits outside", async () => { + const id = "ontology_tightening_declared_endpoint_rollback"; + await context.createStore(declaredEndpointGraph(id, "list")); + const [widened] = await createAdapterStoreWithSchema( + declaredEndpointGraph(id, "wide"), + context.getBackend(), + ); + const person = await widened.nodes.Person.create({}); + const company = await widened.nodes.Company.create({}); + await widened.edges.worksFor.create(person, company, {}); + + await expect( + rollbackSchema(context.getBackend(), id, 1), + ).rejects.toMatchObject({ + details: matchingObject({ reason: "ontology-tightening-violated" }), + }); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("refuses the same probe-1 tightening through migrateSchema directly", async () => { + const id = "ontology_tightening_via_migrate_schema"; + const store = await context.createStore(probeGraph(id, false)); + await store.nodes.Person.create({}, { id: "shared" }); + await store.nodes.Company.create({}, { id: "shared" }); + + const error = await migrateSchema( + context.getBackend(), + probeGraph(id, true), + await activeVersion(context, id), + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(MigrationError); + const details = (error as MigrationError).details; + if (details.reason !== "ontology-tightening-violated") { + throw new Error( + `expected ontology-tightening-violated, got ${details.reason}`, + ); + } + expect(await activeVersion(context, id)).toBe(1); + }); + // Proves the documented `migrateSchema()` escape hatch is not a path + // that skips the probe. + + it("refuses with CONSTRAINT_FENCE_AUDIT_UNSUPPORTED when the preflight target cannot run the audit", async () => { + const id = "ontology_tightening_custom_port_refusal"; + const store = await context.createStore(probeGraph(id, false)); + const shared = await store.nodes.Person.create({}, { id: "shared" }); + await store.nodes.Company.create({}, { id: "shared" }); + + const restrictedBackend = withoutFenceAudit(context.getBackend()); + const error = await createAdapterStoreWithSchema( + probeGraph(id, true), + restrictedBackend, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "CONSTRAINT_FENCE_AUDIT_UNSUPPORTED", + }); + expect(await activeVersion(context, id)).toBe(1); + // No rows moved: the "shared" id still resolves, unaffected by the + // refused (and therefore rolled-back) commit. + expect(await store.nodes.Person.getById(shared.id)).toBeDefined(); + }); + // MUTATION CHECK: skipping the probe when + // `readConstraintFenceViolations` is absent from the preflight target + // (instead of refusing) makes the tightening commit and this test fail. + + it("refuses with CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED when the audit answers no misassignedEdgeEndpointRows", async () => { + const id = "ontology_tightening_family_unsupported"; + const graphWith = (withSubClass: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Organization: { type: Organization }, + Bystander: { type: Bystander }, + OtherBystander: { type: OtherBystander }, + }, + edges: { + worksFor: { type: worksFor, from: [Person], to: [Organization] }, + }, + // `subClassOf(Bystander, OtherBystander)` never changes: removing + // the only relation naming a meta-edge also removes the meta-edge + // itself, an unrelated, pre-existing `breaking` change that would + // otherwise mask this case's family-unsupported refusal behind a + // plain "breaking-change" one. See the module-level Bystander + // doc comment. + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(withSubClass ? [subClassOf(Company, Organization)] : []), + ], + }); + + const store = await context.createStore(graphWith(true)); + const person = await store.nodes.Person.create({}); + const company = await store.nodes.Company.create({}); + const edgeRow = await store.backend.insertEdge({ + graphId: id, + id: "family-unsupported-edge", + kind: "worksFor", + fromKind: "Person", + fromId: person.id, + toKind: "Company", + toId: company.id, + props: {}, + }); + + const restrictedBackend = withoutEdgeEndpointFamily(context.getBackend()); + const error = await createAdapterStoreWithSchema( + graphWith(false), + restrictedBackend, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "CONSTRAINT_FENCE_AUDIT_FAMILY_UNSUPPORTED", + }); + expect(await activeVersion(context, id)).toBe(1); + // No rows moved: the edge the tightening would have flagged is + // untouched by the refused (and therefore rolled-back) commit. + expect(await store.backend.getEdge(id, edgeRow.id)).toBeDefined(); + }); + // MUTATION CHECK: reporting `rest` (which never carries + // `misassignedEdgeEndpointRows`) as a clean, present result instead of + // refusing — i.e. defaulting the omitted key to `[]` in + // `auditConstraintFences` rather than treating `undefined` as "the + // backend did not answer" — makes the tightening commit and this test + // fail. + + it("refuses with ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND when the backend cannot commit a preflight atomically", async () => { + const id = "ontology_tightening_capability_atomic"; + await context.createStore(probeGraph(id, false)); + + const restrictedBackend = withoutCommitWithPreflight( + context.getBackend(), + ); + const error = await createAdapterStoreWithSchema( + probeGraph(id, true), + restrictedBackend, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: defaulting `commitNewSchemaVersionWithPreflight`'s + // `capabilityError` parameter to the ontology error unconditionally + // (losing the "identity contributed no step of its own" distinction) + // would not be caught here alone — the next case pins the other side. + + it("still names IDENTITY_REQUIRES_ATOMIC_BACKEND for an identity-only commit facing the same backend limitation", async () => { + const id = "ontology_tightening_identity_capability"; + const graphWith = (withIdentity: boolean) => + defineGraph({ + id, + nodes: { Person: { type: Person } }, + edges: {}, + ...(withIdentity ? + { identity: { sameIdAcrossKinds: "ignore" as const } } + : {}), + }); + + await context.createStore(graphWith(false)); + + const restrictedBackend = withoutCommitWithPreflight( + context.getBackend(), + ); + const error = await createAdapterStoreWithSchema( + graphWith(true), + restrictedBackend, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "IDENTITY_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: always passing + // `ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR` (instead of + // only when identity contributed no preflight step) would misdirect this + // identity-only commit's refusal and make this case fail. + + it("names EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND, not the ontology code, for a commit that only newly constrains an edge cardinality", async () => { + // v1 declares no ontology relation at all and no edge kinds; v2 adds a + // BRAND-NEW edge kind with a constrained `cardinality` — no + // disjointness, uniqueness, or endpoint-assignability axiom is touched + // by either version, so a refusal naming the ontology code would be + // actively misleading about which axis this commit tightened. + const id = "edge_cardinality_tightening_capability_atomic"; + const graphWith = (withEdge: boolean) => + defineGraph({ + id, + nodes: { + Person: { type: Person }, + Organization: { type: Organization }, + }, + edges: + withEdge ? + { + worksFor: { + type: worksFor, + from: [Person], + to: [Organization], + cardinality: "one" as const, + }, + } + : {}, + }); + + await context.createStore(graphWith(false)); + + const restrictedBackend = withoutCommitWithPreflight( + context.getBackend(), + ); + const error = await createAdapterStoreWithSchema( + graphWith(true), + restrictedBackend, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "EDGE_CARDINALITY_TIGHTENING_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): reverting `prepareSchemaTighteningPreflight` + // to always report `ONTOLOGY_TIGHTENING_ATOMIC_PREFLIGHT_CAPABILITY_ERROR` + // makes this case fail with + // `details.code` reading `"ONTOLOGY_TIGHTENING_REQUIRES_ATOMIC_BACKEND"`. + }); +} diff --git a/packages/typegraph/tests/backends/integration/ontology-typed-subsumption.ts b/packages/typegraph/tests/backends/integration/ontology-typed-subsumption.ts new file mode 100644 index 000000000..52d98d768 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/ontology-typed-subsumption.ts @@ -0,0 +1,1788 @@ +/** + * Cross-backend query semantics for typed subsumption. + * + * Query-feature tests live in the shared cross-backend suite (AGENTS.md + * "Backend parity") — a per-dialect test would happily certify a + * divergence between SQLite and PostgreSQL; only the same case run on both + * engines verifies equivalence. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + broader, + count, + createStoreWithSchema, + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + embedding, + field, + searchable, + subClassOf, +} from "../../../src"; +import { + ConfigurationError, + EndpointError, + KindNotFoundError, + UnsupportedPredicateError, +} from "../../../src/errors"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const Media = defineNode("TsMedia", { + schema: z.object({ title: z.string() }), +}); +const Podcast = defineNode("TsPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); +const linksTo = defineEdge("tsLinksTo", { schema: z.object({}) }); + +const subsumptionGraph = defineGraph({ + id: "typed_subsumption_integration", + nodes: { TsMedia: { type: Media }, TsPodcast: { type: Podcast } }, + edges: { + tsLinksTo: { type: linksTo, from: [Media], to: [Media] }, + }, + ontology: [subClassOf(Podcast, Media)], +}); + +// A three-level `broader` chain: Root -> Mid -> Leaf (Leaf is the most +// specific concept; `broader(narrower, broader)` per the factory's own +// parameter order). +const RootConcept = defineNode("TsRootConcept", { + schema: z.object({ name: z.string() }), +}); +const MidConcept = defineNode("TsMidConcept", { + schema: z.object({ name: z.string() }), +}); +const LeafConcept = defineNode("TsLeafConcept", { + schema: z.object({ name: z.string() }), +}); +// `from`/`to` declared directly on each EdgeType (not only in the graph's +// edge registration) — `#assertValidEndpoint`'s admitted-kinds set reads +// `registry.getEdgeType(name).to`, which is only populated when the edge +// type itself carries endpoints. +// +// Two edges, deliberately different admitted `to` sets: `tsConceptLink` +// admits every concept kind (the narrower-expansion SUCCESS case), +// `tsConceptLinkNarrow` admits only the root (the endpoint-refused case) — +// `expansion: "narrower"` is not an assignability axis, so admission must be +// declared explicitly per kind, unlike `subClassOf` expansion. +const conceptLink = defineEdge("tsConceptLink", { + schema: z.object({}), + from: [RootConcept], + to: [RootConcept, MidConcept, LeafConcept], +}); +const conceptLinkNarrow = defineEdge("tsConceptLinkNarrow", { + schema: z.object({}), + from: [RootConcept], + to: [RootConcept], +}); +// The same root-only admission as `tsConceptLinkNarrow`, declared where graphs +// normally declare endpoints: on the graph's edge registration, with a bare +// edge type that carries none of its own. +const conceptLinkGraphNarrow = defineEdge("tsConceptLinkGraphNarrow", { + schema: z.object({}), +}); + +const narrowerGraph = defineGraph({ + id: "typed_narrower_integration", + nodes: { + TsRootConcept: { type: RootConcept }, + TsMidConcept: { type: MidConcept }, + TsLeafConcept: { type: LeafConcept }, + }, + edges: { + tsConceptLink: conceptLink, + tsConceptLinkNarrow: conceptLinkNarrow, + tsConceptLinkGraphNarrow: { + type: conceptLinkGraphNarrow, + from: [RootConcept], + to: [RootConcept], + }, + }, + ontology: [ + broader(MidConcept, RootConcept), + broader(LeafConcept, MidConcept), + ], +}); + +// Search fixture: a distinct graph so its `searchable()` and `embedding()` +// fields don't have to be threaded through `subsumptionGraph`'s other, +// non-search cases. +const SearchMedia = defineNode("TsSearchMedia", { + schema: z.object({ + title: searchable({ language: "english" }), + embedding: embedding(4).optional(), + }), +}); +const SearchPodcast = defineNode("TsSearchPodcast", { + schema: z.object({ + title: searchable({ language: "english" }), + embedding: embedding(4).optional(), + rssUrl: z.string(), + }), +}); +const SEARCH_QUERY_EMBEDDING = [1, 0, 0, 0]; +const SEARCH_KINDS = ["TsSearchMedia", "TsSearchPodcast"]; + +const searchGraph = defineGraph({ + id: "typed_subsumption_search_integration", + nodes: { + TsSearchMedia: { type: SearchMedia }, + TsSearchPodcast: { type: SearchPodcast }, + }, + edges: {}, + ontology: [subClassOf(SearchPodcast, SearchMedia)], +}); + +// A hierarchy the structural contract admits: the subclass omits every +// parent-optional property, and narrows `code` from `string | number` to +// `string`. The parent alias promises the parent's property types. +const OptionalMedia = defineNode("TsOptionalMedia", { + schema: z.object({ + title: z.string(), + tags: z.array(z.string()).optional(), + featured: z.boolean().optional(), + info: z.object({ lang: z.string() }).optional(), + rank: z.number().optional(), + code: z.union([z.string(), z.number()]).optional(), + }), +}); +const OptionalPodcast = defineNode("TsOptionalPodcast", { + schema: z.object({ + title: z.string(), + rssUrl: z.string(), + code: z.string().optional(), + }), +}); + +const optionalFieldGraph = defineGraph({ + id: "typed_subsumption_optional_field_integration", + nodes: { + TsOptionalMedia: { type: OptionalMedia }, + TsOptionalPodcast: { type: OptionalPodcast }, + }, + edges: {}, + ontology: [subClassOf(OptionalPodcast, OptionalMedia)], +}); + +// The parent declares the only `searchable()` and `embedding()` fields, both +// optional. The structural contract does not see either annotation, so a +// subclass may omit the field (it holds no such content) or redeclare it as a +// plain string / number array (it holds content the parent's index never +// reaches). +const SearchableDocument = defineNode("TsSearchableDoc", { + schema: z.object({ + title: searchable({ language: "english" }).optional(), + vector: embedding(3).optional(), + }), +}); +const TitlelessMemo = defineNode("TsTitlelessMemo", { + schema: z.object({ note: z.string() }), +}); +const RedeclaredTitleMemo = defineNode("TsRedeclaredTitleMemo", { + schema: z.object({ title: z.string().optional(), note: z.string() }), +}); +const RedeclaredVectorMemo = defineNode("TsRedeclaredVectorMemo", { + schema: z.object({ vector: z.array(z.number()).length(3).optional() }), +}); + +const omittedSearchableGraph = defineGraph({ + id: "typed_subsumption_omitted_searchable_integration", + nodes: { + TsSearchableDoc: { type: SearchableDocument }, + TsTitlelessMemo: { type: TitlelessMemo }, + }, + edges: {}, + ontology: [subClassOf(TitlelessMemo, SearchableDocument)], +}); + +const redeclaredTitleGraph = defineGraph({ + id: "typed_subsumption_redeclared_title_integration", + nodes: { + TsSearchableDoc: { type: SearchableDocument }, + TsRedeclaredTitleMemo: { type: RedeclaredTitleMemo }, + }, + edges: {}, + ontology: [subClassOf(RedeclaredTitleMemo, SearchableDocument)], +}); + +const redeclaredVectorGraph = defineGraph({ + id: "typed_subsumption_redeclared_vector_integration", + nodes: { + TsSearchableDoc: { type: SearchableDocument }, + TsRedeclaredVectorMemo: { type: RedeclaredVectorMemo }, + }, + edges: {}, + ontology: [ + // The compile-time filter reads the branded `EmbeddingValue`; the registry + // build compares an embedding as the fixed-length number array it + // validates, so it accepts a plain array of the same length. + // @ts-expect-error a plain number array is not the branded embedding type + subClassOf(RedeclaredVectorMemo, SearchableDocument), + ], +}); + +// The reverse divergence: the parent declares the vector field as a plain +// number array and the subclass tags it embedding(), so the subclass's rows are +// indexed and the parent's are not. +const PlainVectorDocument = defineNode("TsPlainVectorDoc", { + schema: z.object({ vector: z.array(z.number()).length(3).optional() }), +}); +const TaggedVectorMemo = defineNode("TsTaggedVectorMemo", { + schema: z.object({ vector: embedding(3).optional() }), +}); +const taggedSubclassGraph = defineGraph({ + id: "typed_subsumption_tagged_subclass_integration", + nodes: { + TsPlainVectorDoc: { type: PlainVectorDocument }, + TsTaggedVectorMemo: { type: TaggedVectorMemo }, + }, + edges: {}, + ontology: [ + // The compile-time filter reads the branded `EmbeddingValue` on the child + // against the plain array on the parent; the registry, comparing the + // embedding as a fixed-length number array, accepts it. + // @ts-expect-error a branded embedding is not a plain number array to the filter + subClassOf(TaggedVectorMemo, PlainVectorDocument), + ], +}); + +// List sources: two unrelated listed kinds, one of which has a subclass that +// omits the parent-optional `rank` both listed kinds declare. +const ListMedia = defineNode("TsListMedia", { + schema: z.object({ title: z.string(), rank: z.number().optional() }), +}); +const ListPodcast = defineNode("TsListPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); +const ListCompany = defineNode("TsListCompany", { + schema: z.object({ title: z.string(), rank: z.number().optional() }), +}); +const LIST_KINDS = ["TsListCompany", "TsListMedia"] as const; + +const listSourceGraph = defineGraph({ + id: "typed_subsumption_list_source_integration", + nodes: { + TsListMedia: { type: ListMedia }, + TsListPodcast: { type: ListPodcast }, + TsListCompany: { type: ListCompany }, + }, + edges: {}, + ontology: [subClassOf(ListPodcast, ListMedia)], +}); + +async function seedListSourceStore( + context: IntegrationTestContext, + options?: Parameters[1], +) { + const store = await context.createStore(listSourceGraph, options); + await store.nodes.TsListMedia.create({ title: "media", rank: 2 }); + await store.nodes.TsListPodcast.create({ + title: "podcast", + rssUrl: "https://x", + }); + await store.nodes.TsListCompany.create({ title: "company", rank: 1 }); + return store; +} + +// A compile-time kind with no subclass in the compile-time graph, which a +// persisted extension then subclasses. +const RuntimeSubclassDocument = defineNode("TsRuntimeDoc", { + schema: z.object({ title: searchable({ language: "english" }) }), +}); +const RuntimeSubclassOwner = defineNode("TsRuntimeOwner", { + schema: z.object({ name: z.string() }), +}); +const runtimeSubclassOwns = defineEdge("tsRuntimeOwns", { + schema: z.object({}), +}); +const runtimeSubclassGraph = defineGraph({ + id: "typed_subsumption_runtime_subclass_integration", + nodes: { + TsRuntimeDoc: { type: RuntimeSubclassDocument }, + TsRuntimeOwner: { type: RuntimeSubclassOwner }, + }, + edges: { + tsRuntimeOwns: { + type: runtimeSubclassOwns, + from: [RuntimeSubclassOwner], + to: [RuntimeSubclassDocument], + }, + }, +}); +const runtimeSubclassExtension = defineGraphExtension({ + nodes: { + TsRuntimeMemo: { + properties: { + title: { type: "string", searchable: { language: "english" } }, + }, + }, + }, + ontology: [ + { metaEdge: "subClassOf", from: "TsRuntimeMemo", to: "TsRuntimeDoc" }, + ], +}); +const UNTYPED_SUBCLASSES_CODE = "QUERY_ALIAS_EXPANSION_UNTYPED_SUBCLASSES"; + +async function expectRefusedAndStatedAxesAnswered( + store: Awaited< + ReturnType> + >[0], +): Promise { + expectUntypedSubclassRefusal( + await rejectionOf( + Promise.resolve().then(() => store.query().from("TsRuntimeDoc", "d")), + ), + ["from()", "fromDynamic()"], + ); + expectUntypedSubclassRefusal( + await rejectionOf( + Promise.resolve().then(() => store.query().from(["TsRuntimeDoc"], "d")), + ), + ["from()", "fromDynamic()"], + ); + expectUntypedSubclassRefusal( + await rejectionOf( + Promise.resolve().then(() => + store + .query() + .from("TsRuntimeOwner", "o") + .traverse("tsRuntimeOwns", "e") + .to("TsRuntimeDoc", "d"), + ), + ), + ["to()", "toDynamic()"], + ); + + // A stated "subclasses" search hit is typed polymorphically whatever the + // compile-time graph declares, so the extension's subclass is answered. + const searchHits = await store.search.fulltext("TsRuntimeDoc", { + query: "runtime", + limit: 10, + expansion: "subclasses", + }); + expect(kindsOf(searchHits.map((hit) => hit.node))).toEqual([ + "TsRuntimeDoc", + "TsRuntimeMemo", + ]); + const searchedKind = "TsRuntimeDoc"; + const stringKindHits = await store.search.fulltext(searchedKind, { + query: "runtime", + limit: 10, + expansion: "subclasses", + }); + expect(kindsOf(stringKindHits.map((hit) => hit.node))).toEqual([ + "TsRuntimeDoc", + "TsRuntimeMemo", + ]); + + expect( + kindsOf( + await store + .query() + .from("TsRuntimeDoc", "d", { expansion: "subclasses" }) + .select((ctx) => ctx.d) + .execute(), + ), + ).toEqual(["TsRuntimeDoc", "TsRuntimeMemo"]); + expect( + kindsOf( + await store + .query() + .from("TsRuntimeDoc", "d", { expansion: "exact" }) + .select((ctx) => ctx.d) + .execute(), + ), + ).toEqual(["TsRuntimeDoc"]); + expect( + kindsOf( + await store + .query() + .fromDynamic("TsRuntimeDoc", "d") + .select((ctx) => ctx.d) + .execute(), + ), + ).toEqual(["TsRuntimeDoc", "TsRuntimeMemo"]); +} + +async function seedOptionalFieldStore(context: IntegrationTestContext) { + const store = await context.createStore(optionalFieldGraph); + await store.nodes.TsOptionalMedia.create({ + title: "alpha", + tags: ["a", "b"], + featured: true, + info: { lang: "en" }, + rank: 3, + code: "c", + }); + await store.nodes.TsOptionalMedia.create({ + title: "bravo", + featured: false, + rank: 1, + code: "a", + }); + await store.nodes.TsOptionalPodcast.create({ + title: "charlie", + rssUrl: "https://x", + code: "b", + }); + return store; +} + +const UNINDEXED_SEARCH_CODE = "SEARCH_EXPANSION_KIND_UNINDEXED"; + +/** The error an awaited attempt rejects with, or `undefined` if it resolved. */ +async function rejectionOf(attempt: Promise): Promise { + try { + await attempt; + } catch (error) { + return error; + } + return undefined; +} + +function expectConfigurationError(caught: unknown): ConfigurationError { + expect(caught).toBeInstanceOf(ConfigurationError); + return caught as ConfigurationError; +} + +function kindsOf(rows: readonly Readonly<{ kind: string }>[]): string[] { + return rows.map((row) => row.kind).toSorted(); +} + +function expectUntypedSubclassRefusal( + caught: unknown, + suggestedSurfaces: readonly string[], +): void { + const error = expectConfigurationError(caught); + expect(error.details).toMatchObject({ + code: UNTYPED_SUBCLASSES_CODE, + kind: "TsRuntimeDoc", + extraKinds: ["TsRuntimeMemo"], + }); + for (const surface of suggestedSurfaces) { + expect(error.suggestion).toContain(surface); + } +} + +// TsSubDoc is a subclass at compile time but has no subclass of its own, so +// its alias type stays exact; a persisted extension then subclasses it. +const SubBaseDocument = defineNode("TsSubBase", { + schema: z.object({ title: z.string() }), +}); +const SubDocument = defineNode("TsSubDoc", { + schema: z.object({ title: z.string() }), +}); +const subDocumentCites = defineEdge("tsSubDocCites", { schema: z.object({}) }); +const subclassOfSubclassGraph = defineGraph({ + id: "typed_subsumption_subclass_of_subclass_integration", + nodes: { + TsSubBase: { type: SubBaseDocument }, + TsSubDoc: { type: SubDocument }, + }, + edges: { + tsSubDocCites: { + type: subDocumentCites, + from: [SubBaseDocument], + to: [SubDocument], + }, + }, + ontology: [subClassOf(SubDocument, SubBaseDocument)], +}); +const subclassOfSubclassExtension = defineGraphExtension({ + nodes: { TsSubMemo: { properties: { title: { type: "string" } } } }, + ontology: [{ metaEdge: "subClassOf", from: "TsSubMemo", to: "TsSubDoc" }], +}); + +// Four compile-time kinds; only TsCoverTopic is polymorphic at compile time. +// A persisted extension then declares TsCoverNote and TsCoverTag subclasses +// of TsCoverDoc, and TsCoverTag a subclass of TsCoverTopic too. +const CoverDocument = defineNode("TsCoverDoc", { + schema: z.object({ title: z.string() }), +}); +const CoverNote = defineNode("TsCoverNote", { + schema: z.object({ title: z.string() }), +}); +const CoverTopic = defineNode("TsCoverTopic", { + schema: z.object({ title: z.string() }), +}); +const CoverTag = defineNode("TsCoverTag", { + schema: z.object({ title: z.string() }), +}); +const listCoverageGraph = defineGraph({ + id: "typed_subsumption_list_coverage_integration", + nodes: { + TsCoverDoc: { type: CoverDocument }, + TsCoverNote: { type: CoverNote }, + TsCoverTopic: { type: CoverTopic }, + TsCoverTag: { type: CoverTag }, + }, + edges: {}, + ontology: [subClassOf(CoverNote, CoverTopic)], +}); +const listCoverageExtension = defineGraphExtension({ + ontology: [ + { metaEdge: "subClassOf", from: "TsCoverNote", to: "TsCoverDoc" }, + { metaEdge: "subClassOf", from: "TsCoverTag", to: "TsCoverDoc" }, + { metaEdge: "subClassOf", from: "TsCoverTag", to: "TsCoverTopic" }, + ], +}); + +export function registerOntologyTypedSubsumptionIntegrationTests( + context: IntegrationTestContext, +): void { + describe("Typed subsumption — polymorphic default", () => { + it("returns subtype rows by default and only exact rows when narrowed", async () => { + const store = await context.createStore(subsumptionGraph); + await store.nodes.TsMedia.create({ title: "plain" }); + await store.nodes.TsPodcast.create({ + title: "cast", + rssUrl: "https://x", + }); + + const polymorphic = await store + .query() + .from("TsMedia", "m") + .select((ctx) => ctx.m) + .execute(); + expect(polymorphic.map((row) => row.kind).toSorted()).toEqual([ + "TsMedia", + "TsPodcast", + ]); + + const exact = await store + .query() + .from("TsMedia", "m", { expansion: "exact" }) + .select((ctx) => ctx.m) + .execute(); + expect(exact.map((row) => row.kind)).toEqual(["TsMedia"]); + }); + + // Mutation-checked: flipping the store's default + // `queryDefaults.expansion` from `"subclasses"` to `"exact"` drops the + // `TsPodcast` row and fails this assertion on BOTH engines; restored. + it("returns identical row ordering under an explicit orderBy on both engines", async () => { + const store = await context.createStore(subsumptionGraph); + await store.nodes.TsMedia.create({ title: "bravo" }); + await store.nodes.TsPodcast.create({ + title: "alpha", + rssUrl: "https://x", + }); + await store.nodes.TsMedia.create({ title: "charlie" }); + + const rows = await store + .query() + .from("TsMedia", "m") + .orderBy("m", "title", "asc") + .select((ctx) => ctx.m) + .execute(); + + expect(rows.map((row) => row.title)).toEqual([ + "alpha", + "bravo", + "charlie", + ]); + }); + + // NOT mutation-checked on the "exact" half: `backend.fulltextSearch({ + // nodeKind, ... })` already scopes the physical search to the exact + // kind regardless of what the candidate subquery's `from()` widens to + // (src/store/search.ts's module docblock), so dropping its + // `{ expansion: "exact" }` pin leaves that assertion green on + // BOTH engines — same defense-in-depth shape the SQLite-only pin in + // tests/polymorphic-default.test.ts documents. This is cross-backend + // PARITY coverage (AGENTS.md "Backend parity": the candidate + // subquery composes with FTS5 on SQLite and tsvector on PostgreSQL, so + // only running the case on both engines can prove they agree), not an + // independent load-bearing guard. + it(`fulltext search's candidate subquery stays exact-kind by default and expands with expansion: "subclasses", on both engines`, async (ctx) => { + const store = await context.createStore(searchGraph); + if (store.backend.capabilities.fulltext?.supported !== true) { + ctx.skip(); + } + + await store.nodes.TsSearchMedia.create({ + title: "unique_ts_fulltext_marker media", + }); + await store.nodes.TsSearchPodcast.create({ + title: "unique_ts_fulltext_marker podcast", + rssUrl: "https://x", + }); + + const exact = await store.search.fulltext("TsSearchMedia", { + query: "unique_ts_fulltext_marker", + limit: 10, + }); + expect(exact.map((result) => result.node.kind)).toEqual([ + "TsSearchMedia", + ]); + + const expanded = await store.search.fulltext("TsSearchMedia", { + query: "unique_ts_fulltext_marker", + limit: 10, + expansion: "subclasses", + }); + expect(expanded.map((result) => result.node.kind).toSorted()).toEqual( + SEARCH_KINDS, + ); + }); + + it(`vector search stays exact-kind by default and returns the subclass row with expansion: "subclasses", on both engines`, async (ctx) => { + const store = await context.createStore(searchGraph); + if (store.backend.capabilities.vector?.supported !== true) { + ctx.skip(); + } + + await store.nodes.TsSearchMedia.create({ + title: "vector media", + embedding: SEARCH_QUERY_EMBEDDING, + }); + await store.nodes.TsSearchPodcast.create({ + title: "vector podcast", + embedding: SEARCH_QUERY_EMBEDDING, + rssUrl: "https://x", + }); + + const exact = await store.search.vector("TsSearchMedia", { + fieldPath: "embedding", + queryEmbedding: SEARCH_QUERY_EMBEDDING, + limit: 10, + }); + expect(exact.map((result) => result.node.kind)).toEqual([ + "TsSearchMedia", + ]); + + const expanded = await store.search.vector("TsSearchMedia", { + fieldPath: "embedding", + queryEmbedding: SEARCH_QUERY_EMBEDDING, + limit: 10, + expansion: "subclasses", + }); + expect(expanded.map((result) => result.node.kind).toSorted()).toEqual( + SEARCH_KINDS, + ); + }); + + it(`hybrid search stays exact-kind by default and returns the subclass row with expansion: "subclasses", on both engines`, async (ctx) => { + const store = await context.createStore(searchGraph); + if ( + store.backend.capabilities.fulltext?.supported !== true || + store.backend.capabilities.vector?.supported !== true + ) { + ctx.skip(); + } + + await store.nodes.TsSearchMedia.create({ + title: "unique_ts_hybrid_marker media", + embedding: SEARCH_QUERY_EMBEDDING, + }); + await store.nodes.TsSearchPodcast.create({ + title: "unique_ts_hybrid_marker podcast", + embedding: SEARCH_QUERY_EMBEDDING, + rssUrl: "https://x", + }); + + const exact = await store.search.hybrid("TsSearchMedia", { + vector: { + fieldPath: "embedding", + queryEmbedding: SEARCH_QUERY_EMBEDDING, + }, + fulltext: { query: "unique_ts_hybrid_marker" }, + limit: 10, + }); + expect(exact.map((result) => result.node.kind)).toEqual([ + "TsSearchMedia", + ]); + + const expanded = await store.search.hybrid("TsSearchMedia", { + vector: { + fieldPath: "embedding", + queryEmbedding: SEARCH_QUERY_EMBEDDING, + }, + fulltext: { query: "unique_ts_hybrid_marker" }, + limit: 10, + expansion: "subclasses", + }); + expect(expanded.map((result) => result.node.kind).toSorted()).toEqual( + SEARCH_KINDS, + ); + // Both legs found the subclass row, not only the fused union. + const podcast = expanded.find( + (result) => result.node.kind === "TsSearchPodcast", + ); + expect(podcast?.vector?.node.kind).toBe("TsSearchPodcast"); + expect(podcast?.fulltext?.node.kind).toBe("TsSearchPodcast"); + }); + + // NOT mutation-checked: the outer `WHERE nodes.kind = ` in + // `executeNodeSetUpdate` re-filters the candidate set to `TsMedia` + // regardless of what this root's `fromDynamic` pin (node-collection.ts) + // widens to — same defense-in-depth shape the SQLite-only pin in + // tests/polymorphic-default.test.ts documents. Cross-backend PARITY + // coverage: the outer fence's SQL differs by dialect, so only running + // the case on both engines proves neither one's fence lets a + // `TsPodcast` row through. + it("updateWhere on the parent kind leaves subtype rows untouched, on both engines", async () => { + const store = await context.createStore(subsumptionGraph); + await store.nodes.TsMedia.create({ title: "before" }); + const podcast = await store.nodes.TsPodcast.create({ + title: "before", + rssUrl: "https://x", + }); + + const result = await store.nodes.TsMedia.updateWhere({ + all: true, + patch: { title: "after" }, + }); + + expect(result.affectedCount).toBe(1); + const stillPodcast = await store.nodes.TsPodcast.getById(podcast.id); + expect(stillPodcast?.title).toBe("before"); + }); + }); + + describe("Typed subsumption — subclass endpoints on typed edge collections", () => { + it("connects a polymorphic row without a cast and resolves its endpoint by reference", async () => { + const store = await context.createStore(subsumptionGraph); + const media = await store.nodes.TsMedia.create({ title: "plain" }); + const podcast = await store.nodes.TsPodcast.create({ + title: "cast", + rssUrl: "https://x", + }); + const rows = await store + .query() + .from("TsMedia", "m") + .whereNode("m", (m) => m.title.eq("cast")) + .select((ctx) => ctx.m) + .execute(); + const podcastRow = requireDefined(rows[0]); + expect(podcastRow.kind).toBe("TsPodcast"); + + await store.edges.tsLinksTo.create(media, podcastRow); + const links = await store.edges.tsLinksTo.findFrom(media); + const link = requireDefined(links[0]); + expect(link.toKind).toBe("TsPodcast"); + + const reference = { kind: link.toKind, id: link.toId }; + const resolved = await store.getNodeByRef(reference); + expect(resolved).toMatchObject({ + kind: "TsPodcast", + id: podcast.id, + rssUrl: "https://x", + }); + const inTransaction = await store.transaction((tx) => + tx.getNodeByRef(reference), + ); + expect(inTransaction?.id).toBe(podcast.id); + const inView = await store.snapshot().getNodeByRef(reference); + expect(inView?.id).toBe(podcast.id); + + const supertypeNamed = await store.getNodeByRef({ + kind: "TsMedia", + id: podcast.id, + }); + expect(supertypeNamed).toBeUndefined(); + await expect( + store.getNodeByRef({ kind: "TsUnregistered", id: podcast.id }), + ).rejects.toBeInstanceOf(KindNotFoundError); + }); + }); + + describe("Typed subsumption — widened endpoints feed the next read", () => { + it("reads the next hop from a widened endpoint and a polymorphic row without a cast", async () => { + const store = await context.createStore(subsumptionGraph); + const media = await store.nodes.TsMedia.create({ title: "plain" }); + const podcast = await store.nodes.TsPodcast.create({ + title: "cast", + rssUrl: "https://x", + }); + const rows = await store + .query() + .from("TsMedia", "m") + .whereNode("m", (m) => m.title.eq("cast")) + .select((ctx) => ctx.m) + .execute(); + const podcastRow = requireDefined(rows[0]); + await store.edges.tsLinksTo.create(media, podcastRow); + await store.edges.tsLinksTo.create(podcastRow, media); + const links = await store.edges.tsLinksTo.findFrom(media); + const link = requireDefined(links[0]); + const target = { kind: link.toKind, id: link.toId }; + const edgeKinds = ["tsLinksTo"] as const; + + const incoming = await store.bulkFindEdgesTo({ + targets: [{ kind: link.toKind, ids: [link.toId] }], + edgeKinds, + }); + expect(incoming).toHaveLength(1); + expect(incoming[0]?.target).toEqual({ + kind: "TsPodcast", + id: podcast.id, + }); + expect(incoming[0]?.edges.map((edge) => edge.fromId)).toEqual([media.id]); + + const outgoing = await store.bulkFindEdgesFrom({ + sources: [{ kind: podcastRow.kind, ids: [podcastRow.id] }], + edgeKinds, + }); + expect(outgoing[0]?.edges.map((edge) => edge.toId)).toEqual([media.id]); + + const callers = await store.neighbors(target, { + edges: edgeKinds, + direction: "in", + }); + expect(callers.map((neighbor) => neighbor.node.id)).toEqual([media.id]); + expect( + await store.countNeighbors(podcastRow, { + edges: edgeKinds, + direction: "out", + }), + ).toBe(1); + expect( + await store.edges.tsLinksTo + .findTo(target) + .then((found) => found.length), + ).toBe(1); + + await expect( + store.neighbors( + { kind: "TsUnregistered", id: link.toId }, + { edges: edgeKinds }, + ), + ).rejects.toBeInstanceOf(KindNotFoundError); + await expect( + store.bulkFindEdgesTo({ + targets: [{ kind: "TsUnregistered", ids: [link.toId] }], + edgeKinds, + }), + ).rejects.toBeInstanceOf(KindNotFoundError); + }); + + it("resolves a reference on a recorded view at its recorded coordinate", async () => { + const store = await context.createHistoryStore(subsumptionGraph); + const media = await store.nodes.TsMedia.create({ title: "plain" }); + const podcast = await store.nodes.TsPodcast.create({ + title: "cast", + rssUrl: "https://x", + }); + await store.edges.tsLinksTo.create(media, podcast); + const links = await store.edges.tsLinksTo.findFrom(media); + const link = requireDefined(links[0]); + const reference = { kind: link.toKind, id: link.toId }; + const beforeRename = requireDefined(await store.recordedNow()); + await store.nodes.TsPodcast.update(podcast.id, { title: "recast" }); + + const recorded = store.asOfRecorded(beforeRename); + expect(await recorded.getNodeByRef(reference)).toMatchObject({ + kind: "TsPodcast", + id: podcast.id, + title: "cast", + }); + expect( + await recorded.getNodeByRef({ kind: "TsMedia", id: podcast.id }), + ).toBeUndefined(); + await expect( + recorded.getNodeByRef({ kind: "TsUnregistered", id: podcast.id }), + ).rejects.toBeInstanceOf(KindNotFoundError); + expect(await store.getNodeByRef(reference)).toMatchObject({ + title: "recast", + }); + }); + }); + + describe("Typed subsumption — parent properties a subclass omits", () => { + it("decodes a field-level select as the parent's property types", async () => { + const store = await seedOptionalFieldStore(context); + + const exact = await store + .query() + .from("TsOptionalMedia", "m", { expansion: "exact" }) + .orderBy("m", "title", "asc") + .select((ctx) => ({ + title: ctx.m.title, + tags: ctx.m.tags, + featured: ctx.m.featured, + info: ctx.m.info, + rank: ctx.m.rank, + })) + .execute(); + const polymorphic = await store + .query() + .from("TsOptionalMedia", "m") + .orderBy("m", "title", "asc") + .select((ctx) => ({ + title: ctx.m.title, + tags: ctx.m.tags, + featured: ctx.m.featured, + info: ctx.m.info, + rank: ctx.m.rank, + })) + .execute(); + + expect(exact[0]).toEqual({ + title: "alpha", + tags: ["a", "b"], + featured: true, + info: { lang: "en" }, + rank: 3, + }); + expect(polymorphic.slice(0, 2)).toEqual(exact); + expect(polymorphic[2]?.title).toBe("charlie"); + expect(polymorphic[2]?.tags ?? undefined).toBeUndefined(); + expect(polymorphic[2]?.featured ?? undefined).toBeUndefined(); + expect(polymorphic[2]?.info ?? undefined).toBeUndefined(); + }); + + it("filters, orders and groups by a parent property a subclass omits or narrows", async () => { + const store = await seedOptionalFieldStore(context); + + const filtered = await store + .query() + .from("TsOptionalMedia", "m") + .whereNode("m", (m) => m.rank.gt(1)) + .select((ctx) => ctx.m.title) + .execute(); + expect(filtered).toEqual(["alpha"]); + + const byRank = await store + .query() + .from("TsOptionalMedia", "m") + .whereNode("m", (m) => m.rank.isNotNull()) + .orderBy("m", "rank", "asc") + .select((ctx) => ctx.m.title) + .execute(); + expect(byRank).toEqual(["bravo", "alpha"]); + + const byCode = await store + .query() + .from("TsOptionalMedia", "m") + .orderBy("m", "code", "asc") + .select((ctx) => ctx.m.title) + .execute(); + expect(byCode).toEqual(["bravo", "charlie", "alpha"]); + + const grouped = await store + .query() + .from("TsOptionalMedia", "m") + .groupBy("m", "featured") + .aggregate({ featured: field("m", "featured"), total: count("m") }) + .execute(); + expect(grouped).toHaveLength(3); + expect(grouped.find((row) => row.featured === true)?.total).toBe(1); + expect(grouped.find((row) => row.featured === false)?.total).toBe(1); + }); + + it("still refuses a property only the subclass declares", async () => { + const store = await context.createStore(optionalFieldGraph); + + expect(() => + store + .query() + .from("TsOptionalMedia", "m") + .orderBy("m", "rssUrl", "asc"), + ).toThrow(ConfigurationError); + }); + + it("runs $fulltext.matches() when the subclass omits the parent's searchable field", async (ctx) => { + const store = await context.createStore(omittedSearchableGraph); + if (store.backend.capabilities.fulltext?.supported !== true) { + ctx.skip(); + } + + const document = await store.nodes.TsSearchableDoc.create({ + title: "unique_ts_parent_marker climate", + }); + await store.nodes.TsTitlelessMemo.create({ + note: "unique_ts_parent_marker climate", + }); + + const matches = await store + .query() + .from("TsSearchableDoc", "d") + .whereNode("d", (d) => d.$fulltext.matches("unique_ts_parent_marker")) + .select((selection) => ({ + id: selection.d.id, + kind: selection.d.kind, + })) + .execute(); + + expect(matches).toEqual([{ id: document.id, kind: "TsSearchableDoc" }]); + }); + + it("refuses $fulltext.matches() when a subclass redeclares the searchable field untagged", async () => { + const store = await context.createStore(redeclaredTitleGraph); + + let caught: unknown; + try { + store + .query() + .from("TsSearchableDoc", "d") + .whereNode("d", (d) => d.$fulltext.matches("anything")); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(UnsupportedPredicateError); + expect((caught as Error).message).toContain( + 'kind "TsRedeclaredTitleMemo"', + ); + + await store.nodes.TsSearchableDoc.create({ title: "alpha" }); + await store.nodes.TsRedeclaredTitleMemo.create({ + title: "alpha", + note: "n", + }); + const byTitle = await store + .query() + .from("TsSearchableDoc", "d") + .whereNode("d", (d) => d.title.eq("alpha")) + .select((selection) => selection.d.kind) + .execute(); + expect(byTitle.toSorted()).toEqual([ + "TsRedeclaredTitleMemo", + "TsSearchableDoc", + ]); + }); + + it("refuses an embedding accessor when a subclass redeclares the vector field untagged", async () => { + const store = await context.createStore(redeclaredVectorGraph); + + expect(() => + store + .query() + .from("TsSearchableDoc", "d") + .whereNode("d", (d) => d.vector.similarTo([1, 0, 0], 5)), + ).toThrow(ConfigurationError); + }); + }); + + describe("Typed subsumption — search facade over kinds an index would miss", () => { + it(`refuses a fulltext expansion when a subclass redeclares the searchable field untagged, as the builder does`, async (ctx) => { + const store = await context.createStore(redeclaredTitleGraph); + if (store.backend.capabilities.fulltext?.supported !== true) { + ctx.skip(); + } + await store.nodes.TsSearchableDoc.create({ + title: "unique_ts_gap_marker", + }); + await store.nodes.TsRedeclaredTitleMemo.create({ + title: "unique_ts_gap_marker", + note: "n", + }); + + const exact = await store.search.fulltext("TsSearchableDoc", { + query: "unique_ts_gap_marker", + limit: 10, + }); + expect(exact.map((hit) => hit.node.kind)).toEqual(["TsSearchableDoc"]); + + const refusal = expectConfigurationError( + await rejectionOf( + store.search.fulltext("TsSearchableDoc", { + query: "unique_ts_gap_marker", + limit: 10, + expansion: "subclasses", + }), + ), + ); + expect(refusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "fulltext", + kinds: ["TsRedeclaredTitleMemo"], + }); + expect(refusal.message).toContain('"TsRedeclaredTitleMemo"'); + }); + + it(`refuses the fulltext leg of a hybrid expansion over the same kinds`, async (ctx) => { + const store = await context.createStore(redeclaredTitleGraph); + if ( + store.backend.capabilities.fulltext?.supported !== true || + store.backend.capabilities.vector?.supported !== true + ) { + ctx.skip(); + } + + const refusal = expectConfigurationError( + await rejectionOf( + store.search.hybrid("TsSearchableDoc", { + vector: { fieldPath: "vector", queryEmbedding: [1, 0, 0] }, + fulltext: { query: "anything" }, + limit: 10, + expansion: "subclasses", + }), + ), + ); + expect(refusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "fulltext", + }); + }); + + it(`refuses a vector expansion when a subclass redeclares the embedding field untagged`, async (ctx) => { + const store = await context.createStore(redeclaredVectorGraph); + if (store.backend.capabilities.vector?.supported !== true) { + ctx.skip(); + } + await store.nodes.TsSearchableDoc.create({ vector: [1, 0, 0] }); + await store.nodes.TsRedeclaredVectorMemo.create({ vector: [1, 0, 0] }); + + const exact = await store.search.vector("TsSearchableDoc", { + fieldPath: "vector", + queryEmbedding: [1, 0, 0], + limit: 10, + }); + expect(exact.map((hit) => hit.node.kind)).toEqual(["TsSearchableDoc"]); + + const refusal = expectConfigurationError( + await rejectionOf( + store.search.vector("TsSearchableDoc", { + fieldPath: "vector", + queryEmbedding: [1, 0, 0], + limit: 10, + expansion: "subclasses", + }), + ), + ); + expect(refusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "vector", + kinds: ["TsRedeclaredVectorMemo"], + }); + }); + + it(`refuses an exact fulltext search of a kind the builder refuses, rather than returning nothing`, async (ctx) => { + const store = await context.createStore(redeclaredTitleGraph); + if (store.backend.capabilities.fulltext?.supported !== true) { + ctx.skip(); + } + await store.nodes.TsRedeclaredTitleMemo.create({ + title: "unique_ts_exact_marker", + note: "n", + }); + + expect(() => + store + .query() + .from("TsRedeclaredTitleMemo", "m", { expansion: "exact" }) + .whereNode("m", (m) => m.$fulltext.matches("unique_ts_exact_marker")), + ).toThrow(UnsupportedPredicateError); + + const refusal = expectConfigurationError( + await rejectionOf( + store.search.fulltext("TsRedeclaredTitleMemo", { + query: "unique_ts_exact_marker", + limit: 10, + }), + ), + ); + expect(refusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "fulltext", + kinds: ["TsRedeclaredTitleMemo"], + }); + }); + + it(`refuses an exact fulltext search of a kind with no searchable field, in a hybrid leg too`, async (ctx) => { + const store = await context.createStore(omittedSearchableGraph); + if ( + store.backend.capabilities.fulltext?.supported !== true || + store.backend.capabilities.vector?.supported !== true + ) { + ctx.skip(); + } + + const fulltextRefusal = expectConfigurationError( + await rejectionOf( + store.search.fulltext("TsTitlelessMemo", { + query: "anything", + limit: 10, + }), + ), + ); + expect(fulltextRefusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "fulltext", + kinds: ["TsTitlelessMemo"], + }); + + const hybridRefusal = expectConfigurationError( + await rejectionOf( + store.search.hybrid("TsTitlelessMemo", { + vector: { fieldPath: "vector", queryEmbedding: [1, 0, 0] }, + fulltext: { query: "anything" }, + limit: 10, + }), + ), + ); + expect(hybridRefusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "fulltext", + }); + }); + + it(`refuses a vector expansion whose subclass tags a field the parent declares plain, as the builder does`, async (ctx) => { + const store = await context.createStore(taggedSubclassGraph); + if (store.backend.capabilities.vector?.supported !== true) { + ctx.skip(); + } + + expect(() => + store + .query() + .from("TsPlainVectorDoc", "d") + .whereNode("d", (d) => + // The parent declares a plain array, so the typed accessor has no + // `similarTo`; the runtime accessor is what the builder decides on. + ( + d.vector as unknown as Readonly<{ + similarTo: (query: readonly number[], limit: number) => never; + }> + ).similarTo([1, 0, 0], 5), + ), + ).toThrow(ConfigurationError); + + const refusal = expectConfigurationError( + await rejectionOf( + store.search.vector("TsPlainVectorDoc", { + fieldPath: "vector", + queryEmbedding: [1, 0, 0], + limit: 10, + expansion: "subclasses", + }), + ), + ); + expect(refusal.details).toMatchObject({ + code: UNINDEXED_SEARCH_CODE, + capability: "vector", + kinds: ["TsTaggedVectorMemo"], + }); + }); + + it(`still runs a fulltext expansion whose subclass omits the searchable field, which holds no text there`, async (ctx) => { + const store = await context.createStore(omittedSearchableGraph); + if (store.backend.capabilities.fulltext?.supported !== true) { + ctx.skip(); + } + await store.nodes.TsSearchableDoc.create({ + title: "unique_ts_omitted_marker", + }); + await store.nodes.TsTitlelessMemo.create({ + note: "unique_ts_omitted_marker", + }); + + const hits = await store.search.fulltext("TsSearchableDoc", { + query: "unique_ts_omitted_marker", + limit: 10, + expansion: "subclasses", + }); + expect(hits.map((hit) => hit.node.kind)).toEqual(["TsSearchableDoc"]); + }); + + it(`still runs a vector expansion whose subclass omits the embedding field, which holds no vector there`, async (ctx) => { + const store = await context.createStore(omittedSearchableGraph); + if (store.backend.capabilities.vector?.supported !== true) { + ctx.skip(); + } + await store.nodes.TsSearchableDoc.create({ vector: [1, 0, 0] }); + await store.nodes.TsTitlelessMemo.create({ note: "no vector" }); + + const hits = await store.search.vector("TsSearchableDoc", { + fieldPath: "vector", + queryEmbedding: [1, 0, 0], + limit: 10, + expansion: "subclasses", + }); + expect(hits.map((hit) => hit.node.kind)).toEqual(["TsSearchableDoc"]); + }); + }); + + describe("Typed subsumption — the removed includeSubClasses option", () => { + it("is refused by every alias entry point, naming expansion", async () => { + const store = await context.createStore(subsumptionGraph); + const stated = { includeSubClasses: false } as never; + const attempts: Readonly unknown>> = { + from: () => store.query().from("TsMedia", "m", stated), + fromDynamic: () => store.query().fromDynamic("TsMedia", "m", stated), + to: () => + store + .query() + .from("TsMedia", "m") + .traverse("tsLinksTo", "e") + .to("TsMedia", "t", stated), + toDynamic: () => + store + .query() + .from("TsMedia", "m") + .traverse("tsLinksTo", "e") + .toDynamic("TsMedia", "t", stated), + }; + for (const attempt of Object.values(attempts)) { + const refusal = expectConfigurationError( + await rejectionOf(Promise.resolve().then(attempt)), + ); + expect(refusal.details).toMatchObject({ + code: "QUERY_ALIAS_EXPANSION_INVALID", + option: "includeSubClasses", + }); + expect(refusal.message).toContain("expansion"); + } + }); + + it("leaves a null options argument from untyped code as no options", async () => { + const store = await context.createStore(subsumptionGraph); + await store.nodes.TsMedia.create({ title: "plain" }); + await store.nodes.TsPodcast.create({ + title: "cast", + rssUrl: "https://x", + }); + + const rows = await store + .query() + // eslint-disable-next-line unicorn/no-null -- Validate invalid JavaScript input. + .from("TsMedia", "m", null as never) + .select((ctx) => ctx.m.kind) + .execute(); + + expect(rows.toSorted()).toEqual(["TsMedia", "TsPodcast"]); + }); + + it("is refused by every search leg instead of searching exact-kind", async (ctx) => { + const store = await context.createStore(searchGraph); + const { fulltext, vector } = store.backend.capabilities; + const hasFulltext = fulltext?.supported === true; + const hasVector = vector?.supported === true; + if (!hasFulltext && !hasVector) ctx.skip(); + + const queryEmbedding = SEARCH_QUERY_EMBEDDING; + const attempts: Readonly Promise>> = { + ...(hasFulltext && { + fulltext: () => + store.search.fulltext("TsSearchMedia", { + query: "anything", + limit: 10, + includeSubClasses: true, + } as never), + }), + ...(hasVector && { + vector: () => + store.search.vector("TsSearchMedia", { + fieldPath: "embedding", + queryEmbedding, + limit: 10, + includeSubClasses: true, + } as never), + }), + ...(hasFulltext && + hasVector && { + hybrid: () => + store.search.hybrid("TsSearchMedia", { + vector: { fieldPath: "embedding", queryEmbedding }, + fulltext: { query: "anything" }, + limit: 10, + includeSubClasses: true, + } as never), + }), + }; + for (const attempt of Object.values(attempts)) { + const refusal = expectConfigurationError(await rejectionOf(attempt())); + expect(refusal.details).toMatchObject({ + code: "QUERY_ALIAS_EXPANSION_INVALID", + option: "includeSubClasses", + }); + } + }); + }); + + describe("narrower expansion over a kind taxonomy", () => { + it("from() expands through a three-level broader/narrower chain", async () => { + const store = await context.createStore(narrowerGraph); + await store.nodes.TsRootConcept.create({ name: "root" }); + await store.nodes.TsMidConcept.create({ name: "mid" }); + await store.nodes.TsLeafConcept.create({ name: "leaf" }); + + const rows = await store + .query() + .from("TsRootConcept", "c", { expansion: "narrower" }) + .select((ctx) => ctx.c) + .execute(); + + expect(rows.map((row) => row.kind).toSorted()).toEqual([ + "TsLeafConcept", + "TsMidConcept", + "TsRootConcept", + ]); + }); + + it("to() expands through the same chain, composing with a predicate and limit", async () => { + const store = await context.createStore(narrowerGraph); + const root = await store.nodes.TsRootConcept.create({ name: "root" }); + const mid = await store.nodes.TsMidConcept.create({ name: "mid" }); + const leaf = await store.nodes.TsLeafConcept.create({ name: "leaf" }); + await store.edges.tsConceptLink.create(root, root, {}); + await store.edges.tsConceptLink.create(root, mid, {}); + await store.edges.tsConceptLink.create(root, leaf, {}); + + const rows = await store + .query() + .from("TsRootConcept", "root") + .whereNode("root", (accessor) => accessor.id.eq(root.id)) + .traverse("tsConceptLink", "e") + .to("TsRootConcept", "target", { expansion: "narrower" }) + .select((ctx) => ctx.target) + .execute(); + + expect(rows.map((row) => row.kind).toSorted()).toEqual([ + "TsLeafConcept", + "TsMidConcept", + "TsRootConcept", + ]); + }); + + it("refuses a narrower expansion naming an unregistered kind, identically on both engines", async () => { + // `broader`/`narrower` accept any NodeType, registered or not — the + // concept node is never added to `unregisteredNarrowerGraph.nodes`. + const UnregisteredLeaf = defineNode("TsUnregisteredLeaf", { + schema: z.object({ name: z.string() }), + }); + const unregisteredNarrowerGraph = defineGraph({ + id: "typed_narrower_unregistered_integration", + nodes: { TsRootConcept: { type: RootConcept } }, + edges: {}, + ontology: [broader(UnregisteredLeaf, RootConcept)], + }); + const store = await context.createStore(unregisteredNarrowerGraph); + + let caught: unknown; + try { + store.query().from("TsRootConcept", "c", { expansion: "narrower" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).details["code"]).toBe( + "ONTOLOGY_NARROWER_KIND_NOT_REGISTERED", + ); + }); + + it("refuses a narrower expansion whose kinds are not admitted edge endpoints, identically on both engines", async () => { + // tsConceptLinkNarrow admits ONLY TsRootConcept as a `to` endpoint — + // the narrower expansion (Mid, Leaf) is not an assignability + // axis, so those two kinds are never automatically admitted the way + // subClassOf descendants are. + const store = await context.createStore(narrowerGraph); + const root = await store.nodes.TsRootConcept.create({ name: "root" }); + + let caught: unknown; + try { + store + .query() + .from("TsRootConcept", "root") + .whereNode("root", (accessor) => accessor.id.eq(root.id)) + .traverse("tsConceptLinkNarrow", "e") + .to("TsRootConcept", "target", { expansion: "narrower" }); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).details["code"]).toBe( + "ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED", + ); + }); + + it("refuses the same narrower expansion when the edge's endpoints are declared on the graph registration", async () => { + const store = await context.createStore(narrowerGraph); + const traversal = () => + store + .query() + .from("TsRootConcept", "root") + .traverse("tsConceptLinkGraphNarrow", "e"); + const attempts: Record unknown> = { + to: () => + traversal().to("TsRootConcept", "target", { expansion: "narrower" }), + toDynamic: () => + traversal().toDynamic("TsRootConcept", "target", { + expansion: "narrower", + }), + }; + + const refusals = Object.fromEntries( + Object.entries(attempts).map(([name, attempt]) => { + try { + attempt(); + return [name, "accepted"]; + } catch (error) { + return [ + name, + error instanceof ConfigurationError ? + error.details["code"] + : error, + ]; + } + }), + ); + + expect(refusals).toEqual({ + to: "ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED", + toDynamic: "ONTOLOGY_NARROWER_ENDPOINT_NOT_ADMITTED", + }); + }); + + it("refuses a toDynamic target the graph registration does not admit, and accepts one it does", async () => { + const store = await context.createStore(narrowerGraph); + const traversal = () => + store + .query() + .from("TsRootConcept", "root") + .traverse("tsConceptLinkGraphNarrow", "e"); + + expect(() => + traversal().toDynamic("TsLeafConcept", "target", { + expansion: "exact", + }), + ).toThrow(EndpointError); + + const root = await store.nodes.TsRootConcept.create({ name: "root" }); + const other = await store.nodes.TsRootConcept.create({ name: "other" }); + await store.edges.tsConceptLinkGraphNarrow.create(root, other, {}); + const rows = await traversal() + .toDynamic("TsRootConcept", "target", { expansion: "exact" }) + .select((ctx) => ({ id: ctx.target.id })) + .execute(); + expect(rows).toEqual([{ id: other.id }]); + }); + }); + describe("Typed subsumption — list sources", () => { + it("expands each listed kind through its subclasses when no expansion is stated", async () => { + const store = await seedListSourceStore(context); + + expect( + kindsOf( + await store + .query() + .from(LIST_KINDS, "n") + .select((ctx) => ctx.n) + .execute(), + ), + ).toEqual(["TsListCompany", "TsListMedia", "TsListPodcast"]); + expect( + kindsOf( + await store + .query() + .from(LIST_KINDS, "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n) + .execute(), + ), + ).toEqual(["TsListCompany", "TsListMedia", "TsListPodcast"]); + expect( + kindsOf( + await store + .query() + .from(LIST_KINDS, "n", { expansion: "exact" }) + .select((ctx) => ctx.n) + .execute(), + ), + ).toEqual(["TsListCompany", "TsListMedia"]); + }); + + it("follows the store's queryDefaults.expansion like a single-kind source", async () => { + const store = await seedListSourceStore(context, { + queryDefaults: { expansion: "exact" }, + }); + + const rows = await store + .query() + .from(LIST_KINDS, "n") + .select((ctx) => ctx.n) + .execute(); + expect(rows.map((row) => row.kind).toSorted()).toEqual([ + "TsListCompany", + "TsListMedia", + ]); + }); + + it("filters an expanded list on a parent-optional field a subclass omits", async () => { + const store = await seedListSourceStore(context); + + const rows = await store + .query() + .from(LIST_KINDS, "n") + .whereNode("n", (node) => node.rank.gte(1)) + .orderBy("n", "rank") + .select((ctx) => ({ kind: ctx.n.kind, rank: ctx.n.rank })) + .execute(); + expect(rows).toEqual([ + { kind: "TsListCompany", rank: 1 }, + { kind: "TsListMedia", rank: 2 }, + ]); + }); + }); + + describe("Typed subsumption — subclasses the compile-time graph does not declare", () => { + it("refuses an unstated expansion after store.evolve() adds a subclass, and after a reload from the persisted schema", async () => { + const backend = context.getBackend(); + const [store] = await createStoreWithSchema( + runtimeSubclassGraph, + backend, + ); + const evolved = await store.evolve(runtimeSubclassExtension); + await evolved.nodes.TsRuntimeDoc.create({ title: "runtime doc" }); + await requireDefined(evolved.getNodeCollection("TsRuntimeMemo")).create({ + title: "runtime memo", + }); + + await expectRefusedAndStatedAxesAnswered(evolved); + + const [reloaded] = await createStoreWithSchema( + runtimeSubclassGraph, + backend, + ); + await expectRefusedAndStatedAxesAnswered(reloaded); + }); + + it("refuses a list source only for extension subclasses no listed kind's type admits", async () => { + const backend = context.getBackend(); + const [store] = await createStoreWithSchema(listCoverageGraph, backend); + const evolved = await store.evolve(listCoverageExtension); + await evolved.nodes.TsCoverDoc.create({ title: "doc" }); + await evolved.nodes.TsCoverNote.create({ title: "note" }); + await evolved.nodes.TsCoverTopic.create({ title: "topic" }); + await evolved.nodes.TsCoverTag.create({ title: "tag" }); + + // Every extra kind is listed, so the list type admits each row exactly. + expect( + kindsOf( + await evolved + .query() + .from(["TsCoverDoc", "TsCoverNote", "TsCoverTag"], "n") + .select((ctx) => ctx.n) + .execute(), + ), + ).toEqual(["TsCoverDoc", "TsCoverNote", "TsCoverTag"]); + // TsCoverTag is not listed, but TsCoverTopic is polymorphic at compile + // time and expands to it, so the list type's TsCoverTopic member admits + // TsCoverTag rows. + expect( + kindsOf( + await evolved + .query() + .from(["TsCoverDoc", "TsCoverNote", "TsCoverTopic"], "n") + .select((ctx) => ctx.n) + .execute(), + ), + ).toEqual(["TsCoverDoc", "TsCoverNote", "TsCoverTag", "TsCoverTopic"]); + + const uncovered = expectConfigurationError( + await rejectionOf( + Promise.resolve().then(() => + evolved.query().from(["TsCoverDoc", "TsCoverNote"], "n"), + ), + ), + ); + expect(uncovered.details).toMatchObject({ + code: UNTYPED_SUBCLASSES_CODE, + kind: "TsCoverDoc", + extraKinds: ["TsCoverTag"], + }); + expect(uncovered.message).toContain("one of the listed kinds"); + expect(uncovered.suggestion).toContain("every listed kind"); + expect(uncovered.suggestion).not.toContain("fromDynamic()"); + }); + + it("refuses an unstated expansion of a compile-time subclass once store.evolve() gives it a subclass of its own", async () => { + const backend = context.getBackend(); + const [store] = await createStoreWithSchema( + subclassOfSubclassGraph, + backend, + ); + const evolved = await store.evolve(subclassOfSubclassExtension); + const base = await evolved.nodes.TsSubBase.create({ title: "base" }); + const subDocument = await evolved.nodes.TsSubDoc.create({ title: "doc" }); + await requireDefined(evolved.getNodeCollection("TsSubMemo")).create({ + title: "memo", + }); + await evolved.edges.tsSubDocCites.create(base, subDocument, {}); + + // Being the subject of a `subClassOf` does not make a kind's alias type + // polymorphic; only being its target (or an equivalence side) does. + // MUTATION CHECK: marking a `subClassOf` source as affected in + // `subsumptionAffectedKinds` (src/query/builder/alias-expansion.ts) + // answers both reads with an untyped TsSubMemo row. + for (const [surface, read] of [ + ["from()", () => evolved.query().from("TsSubDoc", "d")], + [ + "to()", + () => + evolved + .query() + .from("TsSubBase", "b") + .traverse("tsSubDocCites", "e") + .to("TsSubDoc", "d"), + ], + ] as const satisfies readonly (readonly [string, () => unknown])[]) { + const error = expectConfigurationError( + await rejectionOf(Promise.resolve().then(() => read())), + ); + expect({ surface, details: error.details }).toMatchObject({ + surface, + details: { + code: UNTYPED_SUBCLASSES_CODE, + kind: "TsSubDoc", + extraKinds: ["TsSubMemo"], + }, + }); + expect(error.suggestion).toContain(surface); + } + + // Its superclass was polymorphic at compile time, so it answers the + // extension's kind too. + expect( + kindsOf( + await evolved + .query() + .from("TsSubBase", "b") + .select((ctx) => ctx.b) + .execute(), + ), + ).toEqual(["TsSubBase", "TsSubDoc", "TsSubMemo"]); + }); + + it("answers an unstated expansion when the compile-time graph already types the kind polymorphically", async () => { + const backend = context.getBackend(); + const [store] = await createStoreWithSchema(subsumptionGraph, backend); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { + TsEpisode: { + properties: { + title: { type: "string" }, + rssUrl: { type: "string" }, + }, + }, + }, + ontology: [ + { metaEdge: "subClassOf", from: "TsEpisode", to: "TsMedia" }, + ], + }), + ); + await evolved.nodes.TsMedia.create({ title: "plain" }); + await requireDefined(evolved.getNodeCollection("TsEpisode")).create({ + title: "episode", + rssUrl: "https://x", + }); + + const rows = await evolved + .query() + .from("TsMedia", "m") + .select((ctx) => ctx.m) + .execute(); + expect(rows.map((row) => row.kind).toSorted()).toEqual([ + "TsEpisode", + "TsMedia", + ]); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/pagination-keyset-property.ts b/packages/typegraph/tests/backends/integration/pagination-keyset-property.ts new file mode 100644 index 000000000..7894241d2 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/pagination-keyset-property.ts @@ -0,0 +1,350 @@ +/** + * Keyset pagination as a property: for a random population and a random + * partial order, walking `paginate()` forward (`first`/`after`) and backward + * (`last`/`before`) at random page sizes concatenates to exactly the rows + * `execute()` returns for the same query ordered by its full keyset — the + * caller's keys followed by the row identity spelled out explicitly (start + * `kind` and `id`, then each traversal's edge `id`). + * + * The plain query's own `execute()` is the second reference: same row + * multiset, same caller-key sequence. Ties in the caller's keys are where a + * keyset without a unique tail skips or repeats rows, so the generator keeps + * them dense: two start kinds sharing ids, sort keys drawn from a two-value + * set plus NULL, parallel edges between one pair, and an optional traversal + * whose unmatched rows carry a NULL edge id. + * + * Each run writes under a fresh graph id over the already-provisioned backend + * (a bare `createStore` issues no DDL), so runs and shrinks are hermetic. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStore, defineEdge, defineGraph, defineNode } from "../../../src"; +import type { GraphBackend } from "../../../src/backend/types"; +import { requireDefined } from "../../../src/utils/presence"; +import { propertyRuns } from "../../property/run-budget"; +import { type IntegrationTestContext } from "./test-context"; + +const KpItem = defineNode("KpItem", { + schema: z.object({ rank: z.number().optional() }), +}); +const KpPart = defineNode("KpPart", { + schema: z.object({ rank: z.number().optional() }), +}); +const KpTarget = defineNode("KpTarget", { + schema: z.object({ rank: z.number().optional() }), +}); +const kpLinks = defineEdge("kpLinks", { schema: z.object({}) }); + +function keysetGraph(id: string) { + return defineGraph({ + id, + nodes: { + KpItem: { type: KpItem }, + KpPart: { type: KpPart }, + KpTarget: { type: KpTarget }, + }, + edges: { + kpLinks: { + type: kpLinks, + from: [KpItem, KpPart], + to: [KpTarget], + cardinality: "many", + }, + }, + }); +} + +let runSequence = 0; + +function freshStore(backend: GraphBackend) { + runSequence += 1; + return createStore(keysetGraph(`keyset_property_${runSequence}`), backend); +} + +type KeysetStore = ReturnType; + +const START_KINDS = ["KpItem", "KpPart"] as const; +type StartKind = (typeof START_KINDS)[number]; + +/** Two sort values plus NULL: every key is tied with something. */ +const rankArb = fc.constantFrom(undefined, 1, 2); + +type Population = Readonly<{ + starts: readonly Readonly<{ + kind: StartKind; + id: string; + rank: number | undefined; + }>[]; + targets: readonly (number | undefined)[]; + links: readonly Readonly<{ start: number; target: number }>[]; +}>; + +/** + * Start ids come from a pool of three, so the same id recurs across both + * kinds; within one kind ids stay unique. Links repeat freely, giving + * parallel edges between one pair. + */ +const populationArb: fc.Arbitrary = fc + .record({ + starts: fc.uniqueArray( + fc.record({ + kind: fc.constantFrom(...START_KINDS), + id: fc.constantFrom("s0", "s1", "s2"), + rank: rankArb, + }), + { + minLength: 1, + maxLength: 6, + selector: (start) => `${start.kind}:${start.id}`, + }, + ), + targets: fc.array(rankArb, { minLength: 1, maxLength: 3 }), + linkPicks: fc.array(fc.record({ start: fc.nat(), target: fc.nat() }), { + maxLength: 8, + }), + }) + .map(({ starts, targets, linkPicks }) => ({ + starts, + targets, + links: linkPicks.map((pick) => ({ + start: pick.start % starts.length, + target: pick.target % targets.length, + })), + })); + +type Shape = "start-only" | "fan-out" | "optional"; +type Direction = "asc" | "desc"; +type OrderKey = Readonly<{ alias: "s" | "t"; direction: Direction }>; + +type QueryPlan = Readonly<{ shape: Shape; order: readonly OrderKey[] }>; + +const directionArb = fc.constantFrom("asc", "desc"); + +/** + * The caller's order names only `rank` fields, never an identity column, so + * the keyset always has to append its own tiebreaker. + */ +const queryPlanArb: fc.Arbitrary = fc + .constantFrom("start-only", "fan-out", "optional") + .chain((shape) => + fc.record({ + shape: fc.constant(shape), + order: + shape === "start-only" ? + fc + .tuple(directionArb) + .map(([direction]) => [{ alias: "s" as const, direction }]) + : fc.subarray(["s", "t"] as const, { minLength: 1 }).chain((aliases) => + fc + .array(directionArb, { + minLength: aliases.length, + maxLength: aliases.length, + }) + .map((directions) => + aliases.map((alias, index) => ({ + alias, + direction: requireDefined(directions[index], "direction"), + })), + ), + ), + }), + ); + +const walkArb = fc.array(fc.integer({ min: 1, max: 4 }), { + minLength: 1, + maxLength: 4, +}); + +type Row = Readonly<{ identity: string; sortKey: string }>; + +function rankText(rank: number | undefined): string { + return rank === undefined ? "null" : String(rank); +} + +async function seed(store: KeysetStore, population: Population) { + const starts = []; + for (const start of population.starts) { + const props = start.rank === undefined ? {} : { rank: start.rank }; + starts.push( + start.kind === "KpItem" ? + await store.nodes.KpItem.create(props, { id: start.id }) + : await store.nodes.KpPart.create(props, { id: start.id }), + ); + } + const targets = []; + for (const [index, rank] of population.targets.entries()) { + targets.push( + await store.nodes.KpTarget.create(rank === undefined ? {} : { rank }, { + id: `t${index}`, + }), + ); + } + for (const [index, link] of population.links.entries()) { + await store.edges.kpLinks.create( + requireDefined(starts[link.start], "link start"), + requireDefined(targets[link.target], "link target"), + {}, + { id: `e${index}` }, + ); + } +} + +interface OrderableTraversal { + orderBy(alias: "s" | "t" | "e", field: string, direction?: Direction): this; +} + +/** The caller's keys, then — with `explicitIdentity` — the full row identity. */ +function applyOrder( + builder: Q, + order: readonly OrderKey[], + explicitIdentity: boolean, +): Q { + let callerOrdered = builder; + for (const key of order) { + callerOrdered = callerOrdered.orderBy(key.alias, "rank", key.direction); + } + return explicitIdentity ? + callerOrdered + .orderBy("s", "kind", "asc") + .orderBy("s", "id", "asc") + .orderBy("e", "id", "asc") + : callerOrdered; +} + +/** + * The query under test, and — with `explicitIdentity` — the same query with + * its full keyset spelled out, whose `execute()` is the order reference. + */ +function buildQuery( + store: KeysetStore, + plan: QueryPlan, + explicitIdentity: boolean, +) { + const start = store.query().from(START_KINDS, "s"); + const orderedStart = ( + builder: ReturnType, + ): typeof builder => + explicitIdentity ? + builder.orderBy("s", "kind", "asc").orderBy("s", "id", "asc") + : builder; + + if (plan.shape === "start-only") { + const [key] = plan.order; + return orderedStart( + start.orderBy("s", "rank", requireDefined(key, "order key").direction), + ).select((ctx): Row => ({ + identity: `${ctx.s.kind}:${ctx.s.id}`, + sortKey: rankText(ctx.s.rank), + })); + } + + const traversed = + plan.shape === "optional" ? + applyOrder( + start.optionalTraverse("kpLinks", "e").to("KpTarget", "t"), + plan.order, + explicitIdentity, + ) + : applyOrder( + start.traverse("kpLinks", "e").to("KpTarget", "t"), + plan.order, + explicitIdentity, + ); + return traversed.select((ctx): Row => ({ + identity: `${ctx.s.kind}:${ctx.s.id}>${ctx.e?.id ?? "-"}`, + sortKey: plan.order + .map((key) => + key.alias === "s" ? rankText(ctx.s.rank) : rankText(ctx.t?.rank), + ) + .join("|"), + })); +} + +type PagedQuery = ReturnType; + +/** Hard stop: a cursor that never reaches the end fails instead of hanging. */ +const MAX_PAGES = 64; + +async function walkForward( + query: PagedQuery, + sizes: readonly number[], +): Promise { + const rows: Row[] = []; + let after: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await query.paginate({ + first: requireDefined(sizes[page % sizes.length], "page size"), + ...(after === undefined ? {} : { after }), + }); + rows.push(...result.data); + if (!result.hasNextPage) return rows; + after = requireDefined(result.nextCursor, "next cursor of a non-last page"); + } + throw new Error("forward pagination did not terminate"); +} + +async function walkBackward( + query: PagedQuery, + sizes: readonly number[], +): Promise { + const pages: (readonly Row[])[] = []; + let before: string | undefined; + for (let page = 0; page < MAX_PAGES; page += 1) { + const result = await query.paginate({ + last: requireDefined(sizes[page % sizes.length], "page size"), + ...(before === undefined ? {} : { before }), + }); + pages.unshift(result.data); + if (!result.hasPrevPage) return pages.flat(); + before = requireDefined( + result.prevCursor, + "prev cursor of a non-first page", + ); + } + throw new Error("backward pagination did not terminate"); +} + +function identities(rows: readonly Row[]): readonly string[] { + return rows.map((row) => row.identity); +} + +export function registerKeysetPaginationPropertyTests( + context: IntegrationTestContext, +): void { + describe("Keyset pagination property", () => { + it("pages walked forward and backward at random sizes concatenate to the fully ordered execute()", async () => { + await fc.assert( + fc.asyncProperty( + populationArb, + queryPlanArb, + walkArb, + walkArb, + async (population, plan, forwardSizes, backwardSizes) => { + const store = freshStore(context.getBackend()); + await seed(store, population); + const query = buildQuery(store, plan, false); + + const reference = await buildQuery(store, plan, true).execute(); + const plain = await query.execute(); + const forward = await walkForward(query, forwardSizes); + const backward = await walkBackward(query, backwardSizes); + + const rowSet = identities(plain).toSorted(); + // A skip or a repeat across a page boundary shows up here first. + expect(identities(forward).toSorted()).toEqual(rowSet); + expect(identities(backward).toSorted()).toEqual(rowSet); + expect(identities(reference).toSorted()).toEqual(rowSet); + expect(plain.map((row) => row.sortKey)).toEqual( + reference.map((row) => row.sortKey), + ); + expect(identities(forward)).toEqual(identities(reference)); + expect(identities(backward)).toEqual(identities(reference)); + }, + ), + { numRuns: propertyRuns({ ci: 60, local: 150 }) }, + ); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/pagination.ts b/packages/typegraph/tests/backends/integration/pagination.ts index fcee563b6..f4e2bba47 100644 --- a/packages/typegraph/tests/backends/integration/pagination.ts +++ b/packages/typegraph/tests/backends/integration/pagination.ts @@ -1,9 +1,111 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { + ConfigurationError, + count, + createStore, + expr, + field, +} from "../../../src"; +import { + deriveBackend, + projectGraphBackend, +} from "../../../src/backend/derive-backend"; +import type { CompiledRowsSql } from "../../../src/query/sql-intent"; import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationStore, integrationTestGraph } from "./fixtures"; import { seedProductsForCursorPagination } from "./seed-helpers"; import { type IntegrationTestContext } from "./test-context"; +async function seedFanOut(context: IntegrationTestContext) { + const store = context.getStore(); + const hub = await store.nodes.Person.create({ name: "hub" }); + const names = ["n1", "n2", "n3", "n4", "n5"]; + for (const name of names) { + const neighbor = await store.nodes.Person.create({ name }); + await store.edges.knows.create(hub, neighbor); + } + const fanOut = () => + store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.eq(hub.id)) + .traverse("knows", "edge") + .to("Person", "neighbor"); + return { store, hub, names, fanOut }; +} + +function recursiveFanOut( + store: Pick, + hubId: string, + hops?: Readonly<{ maxHops: number }>, +) { + return store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.eq(hubId)) + .traverse("knows", "edge") + .recursive(hops) + .to("Person", "neighbor") + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); +} + +/** + * Three articles by one author who works at two companies, plus one article by + * a second author at one of them: the shared author is an intermediate node + * several upstream rows reach, so each of its companies belongs to several + * result rows. + */ +async function seedFanIn(context: IntegrationTestContext) { + const store = context.getStore(); + const [shared, solo] = await Promise.all([ + store.nodes.Person.create({ name: "shared" }), + store.nodes.Person.create({ name: "solo" }), + ]); + const [acme, globex] = await Promise.all([ + store.nodes.Company.create({ name: "acme" }), + store.nodes.Company.create({ name: "globex" }), + ]); + await store.edges.worksAt.create(shared, acme, { role: "editor" }); + await store.edges.worksAt.create(shared, globex, { role: "editor" }); + await store.edges.worksAt.create(solo, acme, { role: "writer" }); + const articles = []; + for (const [title, author] of [ + ["a1", shared], + ["a2", shared], + ["a3", shared], + ["a4", solo], + ] as const) { + const article = await store.nodes.Article.create({ + title, + body: title, + category: "fan-in", + published: true, + }); + await store.edges.authoredBy.create(article, author); + articles.push(article); + } + const articleIds = articles.map((article) => article.id); + const authored = () => + store + .query() + .from("Article", "article") + .whereNode("article", (article) => article.id.in(articleIds)) + .traverse("authoredBy", "authorship") + .to("Person", "author"); + const rows = [ + "a1>acme", + "a1>globex", + "a2>acme", + "a2>globex", + "a3>acme", + "a3>globex", + "a4>acme", + ]; + return { store, authored, rows }; +} + export function registerPaginationIntegrationTests( context: IntegrationTestContext, ): void { @@ -209,4 +311,399 @@ export function registerPaginationIntegrationTests( ]); }); }); + + // One start node fanning out into several result rows: every row ties on a + // start-alias order key, so the keyset has to identify the ROW (the start + // node and the edge each traversal matched), and the cursor has to be + // positioned against the completed row, whose order keys may read any alias. + describe("Cursor pagination over traversal fan-out", () => { + type Page = Readonly<{ + data: readonly string[]; + nextCursor?: string | undefined; + prevCursor?: string | undefined; + }>; + + async function walkForward( + paginate: ( + options: Readonly<{ first: number; after?: string }>, + ) => Promise, + ): Promise { + const seen: string[] = []; + let after: string | undefined; + for (let page = 0; page < 20; page += 1) { + const result = await paginate({ + first: 2, + ...(after === undefined ? {} : { after }), + }); + seen.push(...result.data); + if (result.nextCursor === undefined) return seen; + after = result.nextCursor; + } + throw new Error("pagination did not terminate"); + } + + async function walkBackward( + paginate: ( + options: Readonly<{ last: number; before?: string }>, + ) => Promise, + ): Promise { + const seen: string[] = []; + let before: string | undefined; + for (let page = 0; page < 20; page += 1) { + const result = await paginate({ + last: 2, + ...(before === undefined ? {} : { before }), + }); + seen.unshift(...result.data); + if (result.prevCursor === undefined) return seen; + before = result.prevCursor; + } + throw new Error("pagination did not terminate"); + } + + it("returns every row once when the order names only the start alias", async () => { + const { names, fanOut } = await seedFanOut(context); + const query = fanOut() + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); + + const forward = await walkForward((options) => query.paginate(options)); + const backward = await walkBackward((options) => query.paginate(options)); + + expect(forward.toSorted()).toEqual(names); + expect(backward).toEqual(forward); + }); + + it("orders and pages by a traversal alias's own keys", async () => { + const { names, fanOut } = await seedFanOut(context); + const byNeighborName = fanOut() + .orderBy("neighbor", "name", "desc") + .select((ctx) => ctx.neighbor.name); + const byNeighborId = fanOut() + .orderBy("source", "name", "asc") + .orderBy("neighbor", "id", "asc") + .select((ctx) => ctx.neighbor.name); + + expect( + await walkForward((options) => byNeighborName.paginate(options)), + ).toEqual(names.toReversed()); + expect( + await walkBackward((options) => byNeighborName.paginate(options)), + ).toEqual(names.toReversed()); + const byId = await walkForward((options) => + byNeighborId.paginate(options), + ); + expect(byId.toSorted()).toEqual(names); + }); + + it("keeps two parallel edges between one pair of nodes as two rows", async () => { + const store = context.getStore(); + const hub = await store.nodes.Person.create({ name: "hub" }); + const neighbor = await store.nodes.Person.create({ name: "twice" }); + const other = await store.nodes.Person.create({ name: "once" }); + await store.edges.knows.create(hub, neighbor); + await store.edges.knows.create(hub, neighbor); + await store.edges.knows.create(hub, other); + const query = store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.eq(hub.id)) + .traverse("knows", "edge") + .to("Person", "neighbor") + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); + + const forward = await walkForward((options) => query.paginate(options)); + + expect(forward.toSorted()).toEqual(["once", "twice", "twice"]); + }); + + it("pages an optional traversal's unmatched rows alongside its fan-out", async () => { + const { store, hub, names } = await seedFanOut(context); + const loner = await store.nodes.Person.create({ name: "loner" }); + const query = store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.in([hub.id, loner.id])) + .optionalTraverse("knows", "edge") + .to("Person", "neighbor") + .orderBy("source", "name", "asc") + .select((ctx) => `${ctx.source.name}>${ctx.neighbor?.name ?? "-"}`); + + const forward = await walkForward((options) => query.paginate(options)); + const backward = await walkBackward((options) => query.paginate(options)); + + expect(forward.toSorted()).toEqual( + [...names.map((name) => `hub>${name}`), "loner>-"].toSorted(), + ); + expect(backward).toEqual(forward); + }); + + it("pages the same rows through page() inside batchOnce", async () => { + const { store, names, fanOut } = await seedFanOut(context); + const query = fanOut() + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); + + const viaBatch = await walkForward(async (options) => { + const [page] = await store.batchOnce( + () => [query.page(options)] as const, + ); + return page; + }); + + expect(viaBatch.toSorted()).toEqual(names); + }); + + it("composes the cursor with a completed-match where()", async () => { + const { names, fanOut } = await seedFanOut(context); + const query = fanOut() + .where((fields) => expr.neq(fields.neighbor.name, expr.literal("n3"))) + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); + + const forward = await walkForward((options) => query.paginate(options)); + + expect(forward.toSorted()).toEqual(names.filter((name) => name !== "n3")); + }); + }); + // A recursive traversal has no single edge per row, so the start-node keyset + // cannot position a cursor inside the rows one start node fans out into: the + // rows past a page boundary are lost. Pagination refuses such a query up + // front rather than returning pages that silently drop rows. + describe("Cursor pagination over a recursive traversal", () => { + const REFUSAL_CODE = "RECURSIVE_PAGINATION_UNSUPPORTED"; + + function expectRefusal( + attempt: () => unknown, + operation: "paginate" | "page" | "stream", + ): void { + let caught: unknown; + try { + attempt(); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + const error = caught as ConfigurationError; + expect(error.details["code"]).toBe(REFUSAL_CODE); + expect(error.details["operation"]).toBe(operation); + } + + it("refuses paginate() with a typed error before executing a statement", async () => { + const { store, hub } = await seedFanOut(context); + let statements = 0; + const base = store.backend; + const counted = deriveBackend(projectGraphBackend(base), { + execute: (query: CompiledRowsSql) => { + statements += 1; + return base.execute(query); + }, + ...(base.executeRaw === undefined ? + {} + : { + executeRaw: (sqlText: string, params: readonly unknown[]) => { + statements += 1; + return requireDefined(base.executeRaw)(sqlText, params); + }, + }), + }); + const countedStore = createStore(integrationTestGraph, counted); + + const caught = await recursiveFanOut(countedStore, hub.id) + .paginate({ first: 2 }) + .catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(ConfigurationError); + const error = caught as ConfigurationError; + expect(error.details["code"]).toBe(REFUSAL_CODE); + expect(error.details["operation"]).toBe("paginate"); + expect(statements).toBe(0); + }); + + it("refuses page() and stream() when they are built", async () => { + const { store, hub } = await seedFanOut(context); + const query = recursiveFanOut(store, hub.id); + + expectRefusal(() => query.page({ first: 2 }), "page"); + expectRefusal(() => query.stream({ batchSize: 2 }), "stream"); + }); + + it("refuses a recursive traversal whatever else the query holds", async () => { + const { store, hub } = await seedFanOut(context); + const boundedOneHop = recursiveFanOut(store, hub.id, { maxHops: 1 }); + const afterFixedHop = store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.eq(hub.id)) + .traverse("knows", "first") + .to("Person", "middle") + .traverse("knows", "second") + .recursive() + .to("Person", "far") + .orderBy("source", "name", "asc") + .select((ctx) => ctx.far.name); + + for (const query of [boundedOneHop, afterFixedHop]) { + expectRefusal(() => query.page({ first: 2 }), "page"); + } + }); + + it("refuses a continuation cursor the same way", async () => { + const { store, hub } = await seedFanOut(context); + const nonRecursive = store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.eq(hub.id)) + .traverse("knows", "edge") + .to("Person", "neighbor") + .orderBy("source", "name", "asc") + .select((ctx) => ctx.neighbor.name); + const { nextCursor } = await nonRecursive.paginate({ first: 2 }); + + const caught = await recursiveFanOut(store, hub.id) + .paginate({ first: 2, after: requireDefined(nextCursor) }) + .catch((error: unknown) => error); + + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).details["code"]).toBe(REFUSAL_CODE); + }); + + it("still reads the whole recursive result through execute()", async () => { + const { store, hub, names } = await seedFanOut(context); + + const rows = await recursiveFanOut(store, hub.id).execute(); + + expect([...new Set(rows)].toSorted()).toEqual(names); + }); + + it("pages the start nodes and runs the recursive walk per page", async () => { + const store = context.getStore(); + const roots = []; + for (const name of ["r1", "r2", "r3"]) { + const root = await store.nodes.Person.create({ name }); + for (const child of ["a", "b"]) { + const node = await store.nodes.Person.create({ + name: `${name}-${child}`, + }); + await store.edges.knows.create(root, node); + } + roots.push(root); + } + const rootIds = roots.map((root) => root.id); + const startNodes = store + .query() + .from("Person", "root") + .whereNode("root", (root) => root.id.in(rootIds)) + .orderBy("root", "name", "asc") + .select((ctx) => ctx.root.id); + const reached: string[] = []; + + let after: string | undefined; + do { + const page = await startNodes.paginate({ + first: 2, + ...(after === undefined ? {} : { after }), + }); + const walked = await store + .query() + .from("Person", "source") + .whereNode("source", (source) => source.id.in(page.data)) + .traverse("knows", "edge") + .recursive() + .to("Person", "neighbor") + .select((ctx) => ctx.neighbor.name) + .execute(); + reached.push(...walked); + after = page.nextCursor; + } while (after !== undefined); + + expect(reached.toSorted()).toEqual( + ["r1-a", "r1-b", "r2-a", "r2-b", "r3-a", "r3-b"].toSorted(), + ); + }); + }); + + describe("Multi-hop traversal over a shared intermediate node", () => { + it.each([1, 2, 3, 5])( + "pages the rows execute() returns at page size %i", + async (pageSize) => { + const { authored, rows } = await seedFanIn(context); + const query = authored() + .traverse("worksAt", "employment") + .to("Company", "company") + .orderBy("article", "title", "asc") + .select((ctx) => `${ctx.article.title}>${ctx.company.name}`); + + const executed = await query.execute(); + const paged: string[] = []; + let after: string | undefined; + for (let page = 0; page <= rows.length; page += 1) { + const result = await query.paginate({ + first: pageSize, + ...(after === undefined ? {} : { after }), + }); + paged.push(...result.data); + after = result.nextCursor; + if (after === undefined) break; + } + const streamed: string[] = []; + for await (const row of query.stream({ batchSize: pageSize })) { + streamed.push(row); + } + + expect(executed.toSorted()).toEqual(rows); + expect(paged.toSorted()).toEqual(rows); + expect(streamed.toSorted()).toEqual(rows); + }, + ); + + it("returns one row per path through a completed-match where()", async () => { + const { authored, rows } = await seedFanIn(context); + + const filtered = await authored() + .traverse("worksAt", "employment") + .to("Company", "company") + .where((fields) => expr.neq(fields.company.name, expr.literal("none"))) + .select((ctx) => `${ctx.article.title}>${ctx.company.name}`) + .execute(); + + expect(filtered.toSorted()).toEqual(rows); + }); + + it("returns one row per path through an optional second traversal", async () => { + const { authored, rows } = await seedFanIn(context); + + const optional = await authored() + .optionalTraverse("worksAt", "employment") + .to("Company", "company") + .select((ctx) => `${ctx.article.title}>${ctx.company?.name ?? "-"}`) + .execute(); + + expect(optional.toSorted()).toEqual(rows); + }); + + it("aggregates one row per path", async () => { + const { authored } = await seedFanIn(context); + + const counted = await authored() + .traverse("worksAt", "employment") + .to("Company", "company") + .groupBy("company", "name") + .aggregate({ + company: field("company", "name"), + paths: count("article"), + }) + .execute(); + + expect( + counted.toSorted((left, right) => + left.company.localeCompare(right.company), + ), + ).toEqual([ + { company: "acme", paths: 4 }, + { company: "globex", paths: 3 }, + ]); + }); + }); } diff --git a/packages/typegraph/tests/backends/integration/provenance.ts b/packages/typegraph/tests/backends/integration/provenance.ts index 17c33742f..f0a5b57f3 100644 --- a/packages/typegraph/tests/backends/integration/provenance.ts +++ b/packages/typegraph/tests/backends/integration/provenance.ts @@ -68,6 +68,28 @@ const config = { derives: { kind: "derives" }, } as const; +// A fact kind folded into identity with a non-fact kind by shared id, and a +// second fact asserted `same` with it: a belief close must take the fact out +// of both classes exactly as a delete would, and a reopen must fold it back. +const Mirror = defineNode("Mirror", { + schema: z.object({ label: z.string() }), +}); + +const identityProvenanceGraph = defineGraph({ + id: "provenance_retraction_identity_integration", + nodes: { + Source: { type: Source }, + Fact: { type: Fact }, + Mirror: { type: Mirror }, + Justification: { type: Justification }, + }, + edges: { + premiseOf: { type: premiseOf, from: [Source], to: [Justification] }, + derives: { type: derives, from: [Justification], to: [Fact] }, + }, + identity: { sameIdAcrossKinds: "fold" }, +}); + type ProvenanceStore = Awaited>; type SourceRef = Node; type FactRef = Node; @@ -291,5 +313,72 @@ export function registerProvenanceIntegrationTests( store.nodes.Decision.getById(decision.id), ).resolves.toBeUndefined(); }); + + // Load-bearing: a belief close/reopen runs through the transaction's own + // node delete/revive owners, which carry the identity detach and restore + // fold. Revert check: close with `applyNodeSoftDelete` and reopen with + // `applyNodeResurrect` directly (the pipeline steps without the identity + // hooks) and the closed fact stays in `Mirror:shared`'s class, with no + // `detach` or `restore` transition noted. + it("detaches a closed fact from its identity class and restores it on reopen", async () => { + const store = await context.createHistoryStore(identityProvenanceGraph); + const source = await store.nodes.Source.create( + { label: "source-a", retracted: false }, + { id: "source-a" }, + ); + const peerSource = await store.nodes.Source.create( + { label: "source-b", retracted: false }, + { id: "source-b" }, + ); + const fact = await store.nodes.Fact.create( + { label: "shared" }, + { id: "shared" }, + ); + const peer = await store.nodes.Fact.create( + { label: "peer" }, + { id: "peer" }, + ); + const mirror = await store.nodes.Mirror.create( + { label: "shared" }, + { id: "shared" }, + ); + for (const [premise, derived] of [ + [source, fact], + [peerSource, peer], + ] as const) { + const justification = await store.nodes.Justification.create( + { label: derived.id }, + { id: `justification-${derived.id}` }, + ); + await store.edges.premiseOf.create(premise, justification); + await store.edges.derives.create(justification, derived); + } + await store.identity.assertSame(fact, peer); + const mirrorRef = { kind: "Mirror", id: mirror.id } as const; + const causes = async () => { + const history = await store.identity.transitionsOf(mirrorRef); + return history.transitions.map((transition) => transition.cause); + }; + expect(sortedIds(await store.identity.membersOf(mirrorRef))).toEqual([ + "peer", + "shared", + "shared", + ]); + expect(await causes()).not.toContain("detach"); + + const provenance = createRetractionCapability(store, config); + const report = await provenance.retract(source); + + expect(report.died).toEqual([{ kind: "Fact", id: "shared" }]); + expect(await store.identity.membersOf(mirrorRef)).toEqual([mirrorRef]); + await expect(store.identity.areSame(fact, peer)).resolves.toBe(false); + expect(await causes()).toContain("detach"); + expect(await causes()).not.toContain("restore"); + + await provenance.unRetract(source); + + await expect(store.identity.areSame(fact, mirror)).resolves.toBe(true); + expect(await causes()).toContain("restore"); + }); }); } diff --git a/packages/typegraph/tests/backends/integration/query-hooks.ts b/packages/typegraph/tests/backends/integration/query-hooks.ts index a49f0065e..448304f56 100644 --- a/packages/typegraph/tests/backends/integration/query-hooks.ts +++ b/packages/typegraph/tests/backends/integration/query-hooks.ts @@ -1,13 +1,158 @@ import { describe, expect, it } from "vitest"; +import { z } from "zod"; -import { type QueryHookContext } from "../../../src"; +import { + defineEdge, + defineGraph, + defineNode, + indexSubgraph, + partOf, + type QueryHookContext, +} from "../../../src"; +import { getDialect } from "../../../src/query/dialect"; import { integrationTestGraph } from "./fixtures"; import { type IntegrationTestContext } from "./test-context"; +const HookWhole = defineNode("HookWhole", { + schema: z.object({ name: z.string() }), +}); +const HookPart = defineNode("HookPart", { + schema: z.object({ name: z.string() }), +}); +const hookPartOf = defineEdge("hookPartOf", { schema: z.object({}) }); + +const hookCompositionGraph = defineGraph({ + id: "query_hooks_composition", + nodes: { + HookWhole: { type: HookWhole }, + HookPart: { type: HookPart }, + }, + edges: { + hookPartOf: { + type: hookPartOf, + from: [HookPart], + to: [HookWhole], + cardinality: "one", + }, + }, + ontology: [partOf(HookPart, HookWhole, { via: hookPartOf })], +}); + +/** + * Statements a direct `store.subgraph()` submits: nodes and edges each embed + * the traversal on an inline-CTE engine; a materialized-ids engine fetches the + * closure ids once first. + */ +function directSubgraphStatementCount(context: IntegrationTestContext): number { + const strategy = getDialect(context.getBackend().dialect).capabilities + .subgraphMembershipStrategy; + return strategy === "materialized-ids" ? 3 : 2; +} + +/** + * A composition subgraph reads the `edges` closure members, reads the + * composition closure members, then hydrates nodes and edges: four statements + * on every engine. The root reference names its kind, so no statement looks + * it up. + */ +const COMPOSITION_SUBGRAPH_STATEMENT_COUNT = 4; + +/** + * A subgraph's node and edge fetches run concurrently on some backends, so + * statements finish in no fixed order; pairing ends with starts compares the + * two as multisets keyed by the statement's own operation id. + */ +function inOperationIdOrder( + contexts: readonly QueryHookContext[], +): readonly QueryHookContext[] { + return contexts.toSorted((left, right) => + left.operationId.localeCompare(right.operationId), + ); +} + export function registerQueryHookIntegrationTests( context: IntegrationTestContext, ): void { describe("query hooks", () => { + it("fires once per statement a direct subgraph submits, current and recorded", async () => { + const starts: QueryHookContext[] = []; + const ends: QueryHookContext[] = []; + const hooks = { + onQueryStart: (ctx: QueryHookContext) => { + starts.push(ctx); + }, + onQueryEnd: (ctx: QueryHookContext) => { + ends.push(ctx); + }, + }; + const store = await context.createHistoryStore(integrationTestGraph, { + hooks, + }); + const root = await store.nodes.Person.create({ name: "Hook root" }); + const peer = await store.nodes.Person.create({ name: "Hook peer" }); + await store.edges.knows.create(root, peer, {}); + const pin = await store.recordedNow(); + if (pin === undefined) throw new Error("recorded clock was not written"); + const expected = directSubgraphStatementCount(context); + + starts.length = 0; + ends.length = 0; + const current = await store.subgraph(root, { + edges: ["knows"], + maxDepth: 1, + }); + expect(indexSubgraph(current).node(peer)).toBeDefined(); + expect(starts).toHaveLength(expected); + expect(inOperationIdOrder(ends)).toEqual(inOperationIdOrder(starts)); + + starts.length = 0; + ends.length = 0; + const recorded = await store.asOfRecorded(pin).subgraph(root, { + edges: ["knows"], + maxDepth: 1, + }); + expect(indexSubgraph(recorded).node(peer)).toBeDefined(); + expect(starts).toHaveLength(expected); + expect(inOperationIdOrder(ends)).toEqual(inOperationIdOrder(starts)); + + starts.length = 0; + ends.length = 0; + const recordedPeople = await store + .asOfRecorded(pin) + .query() + .from("Person", "person") + .select((ctx) => ctx.person.id) + .execute(); + expect(recordedPeople).toHaveLength(2); + expect(starts).toHaveLength(1); + expect(inOperationIdOrder(ends)).toEqual(inOperationIdOrder(starts)); + }); + + it("fires once per statement a composition subgraph submits", async () => { + const starts: QueryHookContext[] = []; + const store = await context.createStore(hookCompositionGraph, { + hooks: { + onQueryStart: (ctx) => { + starts.push(ctx); + }, + }, + }); + const whole = await store.nodes.HookWhole.create({ name: "Whole" }); + const part = await store.nodes.HookPart.create({ name: "Part" }); + await store.edges.hookPartOf.create(part, whole, {}); + + starts.length = 0; + const unit = await store.subgraph(whole, { + edges: [], + composition: true, + }); + + expect(unit.nodes.map((node) => node.id).toSorted()).toEqual( + [whole.id, part.id].toSorted(), + ); + expect(starts).toHaveLength(COMPOSITION_SUBGRAPH_STATEMENT_COUNT); + }); + it("reports SQL, parameters, row count, and duration per submitted statement", async () => { const starts: QueryHookContext[] = []; const ends: Readonly<{ diff --git a/packages/typegraph/tests/backends/integration/recursive-delimiter-ids.ts b/packages/typegraph/tests/backends/integration/recursive-delimiter-ids.ts index 683fea024..970dcf7cc 100644 --- a/packages/typegraph/tests/backends/integration/recursive-delimiter-ids.ts +++ b/packages/typegraph/tests/backends/integration/recursive-delimiter-ids.ts @@ -133,7 +133,7 @@ export function registerRecursiveDelimiterIdIntegrationTests( .whereNode("start", (clip) => clip.id.eq(ROOT_ID)) .traverse("linked", "link", { expand: "none" }) .recursive({ maxHops: 20, depth: true, path: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ id: row.media.id, depth: row.media_depth, @@ -158,7 +158,7 @@ export function registerRecursiveDelimiterIdIntegrationTests( .whereNode("show", (node) => node.id.eq(COMPOSITE_SHOW_ID)) .traverse("linked", "link", { expand: "none" }) .recursive({ maxHops: 4, path: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ kind: row.media.kind, id: row.media.id, diff --git a/packages/typegraph/tests/backends/integration/recursive-shared-ids.ts b/packages/typegraph/tests/backends/integration/recursive-shared-ids.ts index d8c1b9715..1b611d0fd 100644 --- a/packages/typegraph/tests/backends/integration/recursive-shared-ids.ts +++ b/packages/typegraph/tests/backends/integration/recursive-shared-ids.ts @@ -128,7 +128,7 @@ export function registerRecursiveSharedIdIntegrationTests( .whereNode("show", (show) => show.id.eq(SHARED_ID)) .traverse("linked", "link", { expand: "none" }) .recursive({ maxHops: 6, depth: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ kind: row.media.kind, id: row.media.id, @@ -149,7 +149,7 @@ export function registerRecursiveSharedIdIntegrationTests( .whereNode("clip", (clip) => clip.id.eq(LEAF_ID)) .traverse("linked", "link", { direction: "in", expand: "none" }) .recursive({ maxHops: 6, depth: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ kind: row.media.kind, id: row.media.id, @@ -184,7 +184,7 @@ export function registerRecursiveSharedIdIntegrationTests( .whereNode("show", (show) => show.id.eq(SHARED_ID)) .traverse("linked", "link", { expand: "none" }) .recursive({ maxHops: 6, depth: true, path: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ kind: row.media.kind, depth: row.media_depth, @@ -211,7 +211,7 @@ export function registerRecursiveSharedIdIntegrationTests( depth: true, path: { format: "qualified", alias: "route" }, }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ kind: row.media.kind, depth: row.media_depth, @@ -254,7 +254,7 @@ export function registerRecursiveSharedIdIntegrationTests( .to("Clip", "clip") .traverse("linked", "secondLink", { expand: "none" }) .recursive({ maxHops: 6, depth: "stageDepth", path: "stagePath" }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => ({ from: row.clip.id, kind: row.media.kind, @@ -306,7 +306,7 @@ export function registerRecursiveSharedIdIntegrationTests( .whereNode("start", (clip) => clip.id.eq(LEAF_ID)) .traverse("linked", "link", { expand: "none" }) .recursive({ maxHops: 2, path: true }) - .to("Media", "media", { includeSubClasses: true }) + .to("Media", "media", { expansion: "subclasses" }) .select((row) => row.media_path) .execute(); expect(multiKind.toSorted()).toEqual([ diff --git a/packages/typegraph/tests/backends/integration/recursive.ts b/packages/typegraph/tests/backends/integration/recursive.ts index 3b6b63c87..7615e1b81 100644 --- a/packages/typegraph/tests/backends/integration/recursive.ts +++ b/packages/typegraph/tests/backends/integration/recursive.ts @@ -1,6 +1,13 @@ import { beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; -import { expr } from "../../../src"; +import { + defineEdge, + defineGraph, + defineNode, + expr, + inverseOf, +} from "../../../src"; import { requireDefined } from "../../../src/utils/presence"; import { seedKnowsChain, @@ -8,6 +15,35 @@ import { } from "./seed-helpers"; import { type IntegrationTestContext } from "./test-context"; +const QpPerson = defineNode("QpPerson", { + schema: z.object({ name: z.string() }), +}); +const qpParentOf = defineEdge("qpParentOf", { + schema: z.object({ weight: z.number() }), +}); +const qpChildOf = defineEdge("qpChildOf", { + schema: z.object({ weight: z.number() }), +}); +const qpSibling = defineEdge("qpSibling", { + schema: z.object({ weight: z.number() }), +}); + +/** + * Inverse and symmetric edge kinds, so the default `expand: "inverse"` + * compiles a mixed-orientation traversal: `qpParentOf` walked as stored and + * `qpChildOf` walked reversed, or `qpSibling` walked both ways. + */ +const qualifiedInverseGraph = defineGraph({ + id: "qualified_inverse_paths", + nodes: { QpPerson: { type: QpPerson } }, + edges: { + qpParentOf: { type: qpParentOf, from: [QpPerson], to: [QpPerson] }, + qpChildOf: { type: qpChildOf, from: [QpPerson], to: [QpPerson] }, + qpSibling: { type: qpSibling, from: [QpPerson], to: [QpPerson] }, + }, + ontology: [inverseOf(qpParentOf, qpChildOf), inverseOf(qpSibling, qpSibling)], +}); + export function registerRecursiveIntegrationTests( context: IntegrationTestContext, ): void { @@ -510,6 +546,159 @@ export function registerRecursiveIntegrationTests( ]); }); + it("labels each hop's direction across an inverse-expanded traversal", async () => { + const store = await context.createStore(qualifiedInverseGraph); + const grandparent = await store.nodes.QpPerson.create({ + name: "grandparent", + }); + const parent = await store.nodes.QpPerson.create({ name: "parent" }); + const child = await store.nodes.QpPerson.create({ name: "child" }); + const parentEdge = await store.edges.qpParentOf.create( + grandparent, + parent, + { weight: 1 }, + ); + const childEdge = await store.edges.qpChildOf.create(child, parent, { + weight: 2, + }); + const expectedRoute = [ + { type: "node", kind: "QpPerson", id: grandparent.id }, + { + type: "edge", + kind: "qpParentOf", + id: parentEdge.id, + direction: "out", + }, + { type: "node", kind: "QpPerson", id: parent.id }, + { type: "edge", kind: "qpChildOf", id: childEdge.id, direction: "in" }, + { type: "node", kind: "QpPerson", id: child.id }, + ]; + + const unfiltered = await store + .query() + .from("QpPerson", "root") + .whereNode("root", (person) => person.id.eq(grandparent.id)) + .traverse("qpParentOf", "edge", { expand: "inverse" }) + .recursive({ maxHops: 2, path: { format: "qualified" } }) + .to("QpPerson", "descendant") + .orderBy("descendant", "name", "asc") + .select((row) => row.descendant_path) + .execute(); + expect(unfiltered).toEqual([expectedRoute, expectedRoute.slice(0, 3)]); + + const filtered = await store + .query() + .from("QpPerson", "root") + .whereNode("root", (person) => person.id.eq(grandparent.id)) + .traverse("qpParentOf", "edge", { expand: "inverse" }) + .whereEdge("edge", (edge) => edge.weight.gte(1)) + .recursive({ maxHops: 2, path: { format: "qualified" } }) + .to("QpPerson", "descendant") + .orderBy("descendant", "name", "asc") + .select((row) => row.descendant_path) + .execute(); + expect(filtered).toEqual([expectedRoute, expectedRoute.slice(0, 3)]); + }); + + it("labels both directions of a symmetric edge kind", async () => { + const store = await context.createStore(qualifiedInverseGraph); + const middle = await store.nodes.QpPerson.create({ name: "middle" }); + const outgoing = await store.nodes.QpPerson.create({ name: "outgoing" }); + const incoming = await store.nodes.QpPerson.create({ name: "incoming" }); + const outgoingEdge = await store.edges.qpSibling.create( + middle, + outgoing, + { weight: 1 }, + ); + const incomingEdge = await store.edges.qpSibling.create( + incoming, + middle, + { weight: 1 }, + ); + const expectedRoutes = [ + [ + { type: "node", kind: "QpPerson", id: middle.id }, + { + type: "edge", + kind: "qpSibling", + id: incomingEdge.id, + direction: "in", + }, + { type: "node", kind: "QpPerson", id: incoming.id }, + ], + [ + { type: "node", kind: "QpPerson", id: middle.id }, + { + type: "edge", + kind: "qpSibling", + id: outgoingEdge.id, + direction: "out", + }, + { type: "node", kind: "QpPerson", id: outgoing.id }, + ], + ]; + + for (const withEdgePredicate of [false, true]) { + const traversal = store + .query() + .from("QpPerson", "root") + .whereNode("root", (person) => person.id.eq(middle.id)) + .traverse("qpSibling", "edge", { expand: "inverse" }); + const rows = await ( + withEdgePredicate ? + traversal.whereEdge("edge", (edge) => edge.weight.eq(1)) + : traversal) + .recursive({ + minHops: 1, + maxHops: 1, + path: { format: "qualified" }, + }) + .to("QpPerson", "peer") + .orderBy("peer", "name", "asc") + .select((row) => row.peer_path) + .execute(); + expect(rows).toEqual(expectedRoutes); + } + }); + + it("returns a self-loop of a symmetric edge kind once, though both orientations read it", async () => { + const store = await context.createStore(qualifiedInverseGraph); + const middle = await store.nodes.QpPerson.create({ name: "middle" }); + const outgoing = await store.nodes.QpPerson.create({ name: "outgoing" }); + await store.edges.qpSibling.create(middle, middle, { weight: 1 }); + await store.edges.qpSibling.create(middle, outgoing, { weight: 1 }); + + // `qpSibling` is its own inverse, so it sits in both the direct and the + // inverse arm of the walk's directed-edge relation, and a self-loop + // reads identically through either. `cyclePolicy: "allow"` keeps the + // revisit of the start node, so a duplicate would surface as a row; + // `maxHops: 2` keeps the walk on the recursive CTE (one hop compiles to + // a plain join). + // + // Walks, one per physical edge and orientation: middle -loop-> middle, + // middle -> outgoing; then from middle again the same two, and from + // outgoing back to middle. + for (const withEdgePredicate of [false, true]) { + const traversal = store + .query() + .from("QpPerson", "root") + .whereNode("root", (person) => person.id.eq(middle.id)) + .traverse("qpSibling", "edge", { expand: "inverse" }); + const rows = await ( + withEdgePredicate ? + traversal.whereEdge("edge", (edge) => edge.weight.eq(1)) + : traversal) + .recursive({ minHops: 1, maxHops: 2, cyclePolicy: "allow" }) + .to("QpPerson", "peer") + .select((row) => row.peer.name) + .execute(); + expect( + rows.toSorted(), + `withEdgePredicate: ${withEdgePredicate}`, + ).toEqual(["middle", "middle", "middle", "outgoing", "outgoing"]); + } + }); + it("includes only the seed when expansion stops at depth zero", async () => { const store = context.getStore(); const root = await store.nodes.Person.create({ diff --git a/packages/typegraph/tests/backends/integration/set-node-mutation.ts b/packages/typegraph/tests/backends/integration/set-node-mutation.ts index fe9949020..50c35d5c1 100644 --- a/packages/typegraph/tests/backends/integration/set-node-mutation.ts +++ b/packages/typegraph/tests/backends/integration/set-node-mutation.ts @@ -688,7 +688,7 @@ export function registerSetNodeMutationIntegrationTests( ).resolves.toMatchObject({ state: "pending" }); }); - it("refuses includeSubClasses candidates before changing any concrete kind", async () => { + it("refuses subclass-expanded candidates and accepts the exact kind", async () => { const content = defineNode("CandidateContent", { schema: z.object({ name: z.string() }), }); @@ -711,25 +711,101 @@ export function registerSetNodeMutationIntegrationTests( const subclassNode = await store.nodes.CandidateArticle.create({ name: "subclass", }); - const candidates = store + const polymorphicCandidates = store + .query() + .from("CandidateContent", "content") + .select((query) => query.content.id); + + const refusal = await store.nodes.CandidateContent.updateWhere({ + candidates: polymorphicCandidates, + patch: { name: "must not change" }, + }).catch((error: unknown) => error); + if (!(refusal instanceof ConfigurationError)) { + throw new TypeError("expected a ConfigurationError refusal", { + cause: refusal, + }); + } + expect(refusal).toMatchObject({ + details: { + code: "SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED", + operation: "updateWhere", + expansion: "subclasses", + }, + }); + expect(refusal.details["candidateKinds"]).toEqual( + expect.arrayContaining(["CandidateArticle", "CandidateContent"]), + ); + expect(refusal.suggestion).toContain('{ expansion: "exact" }'); + await expect( + store.nodes.CandidateContent.getById(baseNode.id), + ).resolves.toMatchObject({ name: "base" }); + await expect( + store.nodes.CandidateArticle.getById(subclassNode.id), + ).resolves.toMatchObject({ name: "subclass" }); + + const exactCandidates = store .query() - .from("CandidateContent", "content", { includeSubClasses: true }) + .from("CandidateContent", "content", { expansion: "exact" }) .select((query) => query.content.id); await expect( store.nodes.CandidateContent.updateWhere({ - candidates, - patch: { name: "must not change" }, + candidates: exactCandidates, + patch: { name: "base updated" }, }), - ).rejects.toThrow("one concrete node kind"); + ).resolves.toEqual({ affectedCount: 1 }); await expect( store.nodes.CandidateContent.getById(baseNode.id), - ).resolves.toMatchObject({ name: "base" }); + ).resolves.toMatchObject({ name: "base updated" }); await expect( store.nodes.CandidateArticle.getById(subclassNode.id), ).resolves.toMatchObject({ name: "subclass" }); }); + it("advises one update per kind when a candidate list names several kinds", async () => { + const invoice = defineNode("CandidateInvoice", { + schema: z.object({ name: z.string() }), + }); + const receipt = defineNode("CandidateReceipt", { + schema: z.object({ name: z.string() }), + }); + const graph = defineGraph({ + id: "update_where_listed_candidates", + nodes: { + CandidateInvoice: { type: invoice }, + CandidateReceipt: { type: receipt }, + }, + edges: {}, + }); + const store = await context.createStore(graph); + const invoiceNode = await store.nodes.CandidateInvoice.create({ + name: "invoice", + }); + const listedCandidates = store + .query() + .from(["CandidateInvoice", "CandidateReceipt"], "document") + .select((query) => query.document.id); + + const refusal = await store.nodes.CandidateInvoice.updateWhere({ + candidates: listedCandidates, + patch: { name: "must not change" }, + }).catch((error: unknown) => error); + if (!(refusal instanceof ConfigurationError)) { + throw new TypeError("expected a ConfigurationError refusal", { + cause: refusal, + }); + } + expect(refusal.details).toMatchObject({ + code: "SET_UPDATE_CANDIDATE_MULTIPLE_KINDS_UNSUPPORTED", + candidateKinds: ["CandidateInvoice", "CandidateReceipt"], + }); + expect(refusal.suggestion).toContain("one updateWhere() per node kind"); + expect(refusal.suggestion).not.toContain("expansion"); + await expect( + store.nodes.CandidateInvoice.getById(invoiceNode.id), + ).resolves.toMatchObject({ name: "invoice" }); + }); + it("refuses parameterized candidate queries before executing the set update", async () => { const store = context.getStore(); const candidates = store diff --git a/packages/typegraph/tests/backends/integration/subgraph.ts b/packages/typegraph/tests/backends/integration/subgraph.ts index 94b440a19..bb99b4a85 100644 --- a/packages/typegraph/tests/backends/integration/subgraph.ts +++ b/packages/typegraph/tests/backends/integration/subgraph.ts @@ -5,9 +5,15 @@ * Runs against both SQLite and PostgreSQL via the integration test suite. */ import { beforeEach, describe, expect, it } from "vitest"; - -import { indexSubgraph } from "../../../src"; -import { ValidationError } from "../../../src/errors"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineNode, + indexSubgraph, +} from "../../../src"; +import { KindNotFoundError, ValidationError } from "../../../src/errors"; import { requireDefined } from "../../../src/utils/presence"; import { type IntegrationTestContext } from "./test-context"; @@ -77,6 +83,16 @@ async function seedSubgraphData(store: SubgraphTestStore): Promise<{ return { alice, bob, charlie, acme }; } +const RwPerson = defineNode("RwPerson", { + schema: z.object({ name: z.string() }), +}); +const rwKnows = defineEdge("rwKnows", { schema: z.object({}) }); +const recordedWindowGraph = defineGraph({ + id: "subgraph_recorded_edge_windows", + nodes: { RwPerson: { type: RwPerson } }, + edges: { rwKnows: { type: rwKnows, from: [RwPerson], to: [RwPerson] } }, +}); + export function registerSubgraphIntegrationTests( context: IntegrationTestContext, ): void { @@ -143,6 +159,36 @@ export function registerSubgraphIntegrationTests( expect(index.outgoing(seeded.alice, "knows")).toHaveLength(1); }); + it("applies edge windows under a recorded-pinned read", async () => { + const history = await context.createHistoryStore(recordedWindowGraph); + const root = await history.nodes.RwPerson.create({ name: "root" }); + const first = await history.nodes.RwPerson.create({ name: "first" }); + const second = await history.nodes.RwPerson.create({ name: "second" }); + await history.edges.rwKnows.create(root, first, {}, { id: "rw-a" }); + await history.edges.rwKnows.create(root, second, {}, { id: "rw-b" }); + const pin = requireDefined(await history.recordedNow()); + await history.edges.rwKnows.create(second, first, {}, { id: "rw-late" }); + + // MUTATION CHECK: drop `recordedIntervalColumns(...)` from the windowed + // edge source's column list in `buildSubgraphEdgeSource` + // (src/store/subgraph.ts) and this read fails with "no such column: + // e.recorded_from" (an undefined-column error on PostgreSQL): the row + // filter over that source narrows by the recorded interval — verified + // and reverted. + const result = await history.asOfRecorded(pin).subgraph(root, { + edges: ["rwKnows"], + maxDepth: 2, + edgeWindows: { + rwKnows: { limit: 1, orderBy: { field: "id", direction: "asc" } }, + }, + }); + + expect(result.nodes.map((node) => node.id).toSorted()).toEqual( + [root.id, first.id].toSorted(), + ); + expect(result.edges.map((edge) => edge.id)).toEqual(["rw-a"]); + }); + it("partitions edge windows independently at later hops", async () => { const store = context.getStore(); const result = await store.subgraph(seeded.alice, { @@ -543,5 +589,51 @@ export function registerSubgraphIntegrationTests( expect(results[3]?.nodes.length).toBe(0); expect(results[3]?.edges).toHaveLength(0); }); + + it("refuses an unregistered edge or node kind on every subgraph surface", async () => { + const store = context.getStore(); + const root = seeded.alice; + const unknownEdge = { edges: ["knowz"] } as never; + const unknownNode = { + edges: ["knows"], + includeKinds: ["Persn"], + } as never; + const surfaces: Record Promise> = { + "store.subgraph": (options) => store.subgraph(root, options), + "tx.subgraph": (options) => + store.transaction((tx) => tx.subgraph(root, options)), + "batchOnce.subgraph": (options) => + store.batchOnce((read) => [read.subgraph(root, options)] as const), + "view.subgraph": (options) => + store.view({ mode: "current" }).subgraph(root, options), + }; + + const refusals: Record = {}; + for (const [surface, read] of Object.entries(surfaces)) { + for (const [label, options] of [ + ["edges", unknownEdge], + ["includeKinds", unknownNode], + ] as const) { + refusals[`${surface} ${label}`] = await Promise.resolve() + .then(() => read(options)) + .then( + () => "answered", + (error: unknown) => + error instanceof KindNotFoundError ? + `${error.entity}:${error.kindName}` + : error, + ); + } + } + + expect(refusals).toEqual( + Object.fromEntries( + Object.keys(surfaces).flatMap((surface) => [ + [`${surface} edges`, "edge:knowz"], + [`${surface} includeKinds`, "node:Persn"], + ]), + ), + ); + }); }); } diff --git a/packages/typegraph/tests/backends/integration/uniqueness-claim-gate.ts b/packages/typegraph/tests/backends/integration/uniqueness-claim-gate.ts new file mode 100644 index 000000000..e784b5c06 --- /dev/null +++ b/packages/typegraph/tests/backends/integration/uniqueness-claim-gate.ts @@ -0,0 +1,1376 @@ +/** + * Cross-backend contract for schema commits that would leave a uniqueness + * constraint unenforced over existing rows. + * + * A schema commit validates no existing row against a unique constraint and + * rebuilds no claim, so every path that publishes a version refuses to add or + * reshape a unique constraint on a populated kind, and to move a + * `kindWithSubClasses` claim axis away from where a populated kind's claims + * sit. Each case states the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createAdapterStoreWithSchema, + defineGraph, + defineGraphExtension, + defineNode, + IncompatibleChangeError, + MigrationError, + type MigrationErrorDetails, + subClassOf, + type UniquenessClaimGate, + UniquenessError, + UnsupportedBackendCapabilityError, +} from "../../../src"; +import { projectBackendWithout } from "../../../src/backend/derive-backend"; +import { + getActiveSchema, + migrateSchema, + rollbackSchema, + type SchemaDiff, +} from "../../../src/schema"; +import { requireDefined } from "../../../src/utils/presence"; +import { type IntegrationTestContext } from "./test-context"; + +const DUPLICATE_EMAIL = "dup@example.com"; + +const Account = defineNode("GateAccount", { + schema: z.object({ email: z.string() }), +}); +const ACCOUNT_EMAIL_UNIQUE = { + name: "gate_account_email", + fields: ["email"], + scope: "kind", + collation: "binary", +} as const; + +/** + * One kind, with or without its unique constraint (optionally widened, or + * narrowed to the rows a `where` predicate selects). + */ +function accountGraph( + id: string, + unique: "none" | "kind" | "caseInsensitive" | "partial", +) { + return defineGraph({ + id, + nodes: { + GateAccount: { + type: Account, + unique: + unique === "none" ? [] + : unique === "kind" ? [ACCOUNT_EMAIL_UNIQUE] + : unique === "partial" ? + [ + { + ...ACCOUNT_EMAIL_UNIQUE, + where: (fields) => requireDefined(fields["email"]).isNotNull(), + }, + ] + : [{ ...ACCOUNT_EMAIL_UNIQUE, collation: "caseInsensitive" }], + }, + }, + edges: {}, + }); +} + +/** + * `GateEmployee` sorts before `GatePerson`, so while the two share a subclass + * component the `kindWithSubClasses` claim axis is `GateEmployee` — including + * for claims a `GatePerson` row writes. + */ +const Person = defineNode("GatePerson", { + schema: z.object({ email: z.string() }), +}); +const Employee = defineNode("GateEmployee", { + schema: z.object({ email: z.string() }), +}); +const HIERARCHY_EMAIL_UNIQUE = { + name: "gate_hierarchy_email", + fields: ["email"], + scope: "kindWithSubClasses", + collation: "binary", +} as const; + +/** + * Keeps the `subClassOf` meta-edge alive on both sides of every transition: + * removing the only relation naming a meta-edge removes the meta-edge too, + * an unrelated `breaking` change that would mask the one under test. + */ +const Bystander = defineNode("GateBystander", { schema: z.object({}) }); +const OtherBystander = defineNode("GateOtherBystander", { + schema: z.object({}), +}); + +function hierarchyGraph(id: string, related: boolean) { + return defineGraph({ + id, + nodes: { + GatePerson: { type: Person, unique: [HIERARCHY_EMAIL_UNIQUE] }, + GateEmployee: { type: Employee, unique: [HIERARCHY_EMAIL_UNIQUE] }, + GateBystander: { type: Bystander }, + GateOtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(related ? [subClassOf(Employee, Person)] : []), + ], + }); +} + +const PERSON_AXIS_MOVED: UniquenessClaimGate = { + cause: "claim-axis-moved", + kind: "GatePerson", + constraintName: "gate_hierarchy_email", + axis: "GatePerson", + lostKinds: ["GateEmployee"], +}; + +/** A host kind an extension subclass can pull the claim axis away from. */ +const Whole = defineNode("GateWhole", { + schema: z.object({ email: z.string() }), +}); +function wholeGraph(id: string) { + return defineGraph({ + id, + nodes: { + GateWhole: { + type: Whole, + unique: [ + { ...HIERARCHY_EMAIL_UNIQUE, name: "gate_whole_email" } as const, + ], + }, + }, + edges: {}, + }); +} +/** `GateAardvark` sorts before `GateWhole`, so it becomes the claim axis. */ +const AARDVARK_EXTENSION = defineGraphExtension({ + nodes: { GateAardvark: { properties: { email: { type: "string" } } } }, + ontology: [{ metaEdge: "subClassOf", from: "GateAardvark", to: "GateWhole" }], +}); + +/** + * `GateAlpha` < `GateMiddle` < `GateSubject`: whichever relations are + * declared, the claim axis is the smallest member of the subject's component. + */ +const Alpha = defineNode("GateAlpha", { + schema: z.object({ email: z.string() }), +}); +const Middle = defineNode("GateMiddle", { + schema: z.object({ email: z.string() }), +}); +const Subject = defineNode("GateSubject", { + schema: z.object({ email: z.string() }), +}); +const CHAIN_EMAIL_UNIQUE = { + ...HIERARCHY_EMAIL_UNIQUE, + name: "gate_chain_email", +} as const; + +function chainGraph( + id: string, + relations: Readonly<{ middle: boolean; alpha: boolean }>, +) { + return defineGraph({ + id, + nodes: { + GateAlpha: { type: Alpha, unique: [CHAIN_EMAIL_UNIQUE] }, + GateMiddle: { type: Middle, unique: [CHAIN_EMAIL_UNIQUE] }, + GateSubject: { type: Subject, unique: [CHAIN_EMAIL_UNIQUE] }, + GateBystander: { type: Bystander }, + GateOtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(relations.middle ? [subClassOf(Middle, Subject)] : []), + ...(relations.alpha ? [subClassOf(Alpha, Subject)] : []), + ], + }); +} + +/** + * Two kinds whose names order differently by UTF-16 code unit and by code + * point: the astral `U+1F600` is stored as the surrogate pair `D83D DE00`, + * which sorts below `U+FF22` by code unit and above it by code point. The + * claim axis is the code-point minimum, so it is `GATE_BMP_KIND`. + */ +const GATE_ASTRAL_KIND = "\u{1F600}GateAstral"; +const GATE_BMP_KIND = "\uFF22GateBmp"; +const AstralKind = defineNode(GATE_ASTRAL_KIND, { + schema: z.object({ email: z.string() }), +}); +const BmpKind = defineNode(GATE_BMP_KIND, { + schema: z.object({ email: z.string() }), +}); +function codePointOrderGraph(id: string, related: boolean) { + return defineGraph({ + id, + nodes: { + [GATE_ASTRAL_KIND]: { + type: AstralKind, + unique: [HIERARCHY_EMAIL_UNIQUE], + }, + [GATE_BMP_KIND]: { type: BmpKind, unique: [HIERARCHY_EMAIL_UNIQUE] }, + GateBystander: { type: Bystander }, + GateOtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(related ? [subClassOf(BmpKind, AstralKind)] : []), + ], + }); +} +/** + * `GateAccount` declared the way a JavaScript caller can write it: the typed + * API requires `scope` and `collation`, so leaving them at their defaults is + * spelled through a cast. `nickname` adds an optional property, the smallest + * change that makes a redeploy publish a new version. + */ +const NicknamedAccount = defineNode("GateAccount", { + schema: z.object({ email: z.string(), nickname: z.string().optional() }), +}); +type DefaultedAccountDeclaration = Readonly<{ + scope?: "kind" | "kindWithSubClasses"; + collation?: "binary" | "caseInsensitive"; +}>; +function defaultedAccountGraph( + id: string, + declaration: DefaultedAccountDeclaration, + nickname: boolean, +) { + return defineGraph({ + id, + nodes: { + GateAccount: { + type: nickname ? NicknamedAccount : Account, + unique: [ + { + name: ACCOUNT_EMAIL_UNIQUE.name, + fields: ["email"], + ...declaration, + }, + ] as unknown as readonly (typeof ACCOUNT_EMAIL_UNIQUE)[], + }, + }, + edges: {}, + }); +} + +/** + * The hierarchy `hierarchyGraph` builds, with a constraint whose scope is + * left out — read as `"kindWithSubClasses"`, what the store has always + * enforced for it. + */ +function defaultedScopeHierarchyGraph(id: string, related: boolean) { + const unique = [ + { name: "gate_person_email", fields: ["email"] }, + ] as unknown as readonly (typeof ACCOUNT_EMAIL_UNIQUE)[]; + return defineGraph({ + id, + nodes: { + GatePerson: { type: Person, unique }, + GateEmployee: { type: Employee, unique }, + GateBystander: { type: Bystander }, + GateOtherBystander: { type: OtherBystander }, + }, + edges: {}, + ontology: [ + subClassOf(Bystander, OtherBystander), + ...(related ? [subClassOf(Employee, Person)] : []), + ], + }); +} + +/** + * `graph` with its unique declarations replaced after `defineGraph` ran, the + * way a `{ ...graph, nodes }` spread builds one: the scope and collation the + * caller left out are never written into the registrations. + */ +function withUndefaultedDeclarations>( + graph: G, + constraintName: string, + kinds: readonly string[], +): G { + const unique = [{ name: constraintName, fields: ["email"] }]; + const nodes = Object.fromEntries( + Object.entries(graph.nodes).map(([kind, registration]) => [ + kind, + kinds.includes(kind) ? { ...registration, unique } : registration, + ]), + ); + return { ...graph, nodes }; +} + +function uniqueConstraintChanges(diff: SchemaDiff): readonly string[] { + return diff.nodes + .map((change) => change.details) + .filter((details) => details.startsWith("Unique constraints changed")); +} + +type EmailCollections = Record< + string, + Readonly<{ create: (props: { email: string }) => Promise }> +>; + +/** A kind a graph can drop with its rows left in the table, then re-add. */ +const Revenant = defineNode("GateRevenant", { + schema: z.object({ email: z.string() }), +}); +const REVENANT_EMAIL_UNIQUE = { + name: "gate_revenant_email", + fields: ["email"], + scope: "kind", + collation: "binary", +} as const; +function revenantGraph(id: string, state: "plain" | "dropped" | "unique") { + return defineGraph({ + id, + nodes: { + GateBystander: { type: Bystander }, + ...(state === "dropped" ? + {} + : { + GateRevenant: { + type: Revenant, + unique: state === "unique" ? [REVENANT_EMAIL_UNIQUE] : [], + }, + }), + }, + edges: {}, + }); +} +const REVENANT_EXTENSION = defineGraphExtension({ + nodes: { + GateRevenant: { + properties: { email: { type: "string" } }, + unique: [{ name: "gate_revenant_email", fields: ["email"] }], + }, + }, +}); + +const Host = defineNode("GateHost", { schema: z.object({}) }); +function hostGraph(id: string) { + return defineGraph({ id, nodes: { GateHost: { type: Host } }, edges: {} }); +} +/** + * `"explicitDefaults"` states the `scope` and `collation` the plain + * declaration leaves implied; `"caseInsensitive"` reshapes it. + */ +function taggedExtension( + unique: boolean | "explicitDefaults" | "caseInsensitive", +) { + const constraint = { name: "gate_tag_label", fields: ["label"] }; + const declared = { + explicitDefaults: { ...constraint, scope: "kind", collation: "binary" }, + caseInsensitive: { ...constraint, collation: "caseInsensitive" }, + } as const; + return defineGraphExtension({ + nodes: { + GateTag: { + properties: { label: { type: "string" } }, + ...(unique === false ? {} + : unique === true ? { unique: [constraint] } + : { unique: [declared[unique]] }), + }, + }, + }); +} + +async function activeVersion( + context: IntegrationTestContext, + id: string, +): Promise { + const active = await getActiveSchema(context.getBackend(), id); + return requireDefined(active, "active schema").version; +} + +function gatedConstraints(error: unknown): readonly UniquenessClaimGate[] { + expect(error).toBeInstanceOf(MigrationError); + const details: MigrationErrorDetails = (error as MigrationError).details; + if (details.reason !== "unique-constraint-requires-empty-kind") { + throw new Error( + `expected unique-constraint-requires-empty-kind, got ${details.reason}`, + ); + } + return details.constraints; +} + +const ACCOUNT_CONSTRAINT_ADDED: UniquenessClaimGate = { + cause: "constraint-added", + kind: "GateAccount", + constraintName: "gate_account_email", + axis: "GateAccount", +}; + +export function registerUniquenessClaimGateIntegrationTests( + context: IntegrationTestContext, +): void { + describe("schema commits that would strand uniqueness enforcement", () => { + it("refuses adding a unique constraint to a populated kind through createStoreWithSchema", async () => { + const id = "uniqueness_gate_add_ensure"; + const store = await context.createStore(accountGraph(id, "none")); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + accountGraph(id, "kind"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ACCOUNT_CONSTRAINT_ADDED]); + expect((error as MigrationError).details).toMatchObject({ + graphId: id, + fromVersion: 1, + toVersion: 2, + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: dropping `assertUniquenessClaimGatesOpen` from the + // tightening preflight's `run` commits version 2 over two rows that + // violate the constraint, and this case fails. + + it("refuses the same addition when the existing rows hold no duplicate", async () => { + const id = "uniqueness_gate_add_clean_rows"; + const store = await context.createStore(accountGraph(id, "none")); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + accountGraph(id, "kind"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + // No claim row is backfilled, so a later duplicate of the OLD value + // would be accepted: duplicate-free rows are refused all the same. + expect(gatedConstraints(error)).toEqual([ACCOUNT_CONSTRAINT_ADDED]); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses the same addition through migrateSchema", async () => { + const id = "uniqueness_gate_add_migrate"; + const store = await context.createStore(accountGraph(id, "none")); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await migrateSchema( + context.getBackend(), + accountGraph(id, "kind"), + 1, + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ACCOUNT_CONSTRAINT_ADDED]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: same as above; `migrateSchema` reaches the gate through + // the one tightening preflight every commit path composes. + + it("refuses reshaping a unique constraint on a populated kind", async () => { + const id = "uniqueness_gate_reshape"; + const store = await context.createStore(accountGraph(id, "kind")); + await store.nodes.GateAccount.create({ email: "Case@example.com" }); + await store.nodes.GateAccount.create({ email: "case@example.com" }); + + const error = await createAdapterStoreWithSchema( + accountGraph(id, "caseInsensitive"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { ...ACCOUNT_CONSTRAINT_ADDED, cause: "constraint-reshaped" }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: making `uniqueConstraintEnforcementChange` return + // `undefined` for a same-named constraint of a different shape commits + // the reshape, and this case fails. + + it("refuses a reshape that changes only the constraint's where predicate on a populated kind", async () => { + const id = "uniqueness_gate_reshape_where"; + const store = await context.createStore(accountGraph(id, "kind")); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + accountGraph(id, "partial"), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { ...ACCOUNT_CONSTRAINT_ADDED, cause: "constraint-reshaped" }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK (verified): leaving `where` out of `enforcedShape` + // (src/schema/unique-constraint-change.ts) reads the reshape as + // unchanged, the commit succeeds, and this case fails. + + it("redeploys a populated kind whose unique constraint leaves scope and collation at their defaults", async () => { + const declarations: readonly DefaultedAccountDeclaration[] = [ + {}, + { collation: "caseInsensitive" }, + { scope: "kind" }, + ]; + for (const [index, declaration] of declarations.entries()) { + const id = `uniqueness_gate_defaults_ensure_${index}`; + const before = await context.createStore( + defaultedAccountGraph(id, declaration, false), + ); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const [store, result] = await createAdapterStoreWithSchema( + defaultedAccountGraph(id, declaration, true), + context.getBackend(), + ); + + if (result.status !== "migrated") { + throw new Error(`expected migrated, got ${result.status}`); + } + expect(uniqueConstraintChanges(result.diff)).toEqual([]); + expect(await activeVersion(context, id)).toBe(2); + await expect( + store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + + const [, redeployed] = await createAdapterStoreWithSchema( + defaultedAccountGraph(id, declaration, true), + context.getBackend(), + ); + expect(redeployed.status).toBe("unchanged"); + } + }); + // MUTATION CHECK: returning the registrations as given from + // `withUniqueConstraintDefaultsWritten` serializes the omitted members + // away while the parser fills them into the stored side, and the diff + // reports a unique-constraint change that did not happen. + + it("redeploys a defaulted unique constraint with its defaults written out", async () => { + const id = "uniqueness_gate_defaults_explicit"; + const before = await context.createStore( + defaultedAccountGraph(id, {}, false), + ); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const [, result] = await createAdapterStoreWithSchema( + defaultedAccountGraph( + id, + { scope: "kindWithSubClasses", collation: "binary" }, + true, + ), + context.getBackend(), + ); + + expect(result.status).toBe("migrated"); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("redeploys a defaulted unique constraint through migrateSchema", async () => { + const id = "uniqueness_gate_defaults_migrate"; + const before = await context.createStore( + defaultedAccountGraph(id, {}, false), + ); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const version = await migrateSchema( + context.getBackend(), + defaultedAccountGraph(id, {}, true), + 1, + ); + + expect(version).toBe(2); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("evolves a graph whose populated kind leaves its unique constraint's defaults unwritten", async () => { + const id = "uniqueness_gate_defaults_evolve"; + const store = await context.createStore( + defaultedAccountGraph(id, {}, false), + ); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const evolved = await store.evolve(taggedExtension(false)); + + expect(await activeVersion(context, id)).toBe(2); + await expect( + evolved.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("refuses a reshape of a defaulted unique constraint on a populated kind", async () => { + const id = "uniqueness_gate_defaults_reshape"; + const before = await context.createStore( + defaultedAccountGraph(id, {}, false), + ); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + defaultedAccountGraph(id, { collation: "caseInsensitive" }, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { ...ACCOUNT_CONSTRAINT_ADDED, cause: "constraint-reshaped" }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses stating scope kind over an omitted scope on a populated kind", async () => { + const id = "uniqueness_gate_defaults_kind_restated"; + const before = await context.createStore( + defaultedAccountGraph(id, {}, false), + ); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + defaultedAccountGraph(id, { scope: "kind" }, true), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { ...ACCOUNT_CONSTRAINT_ADDED, cause: "constraint-reshaped" }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("enforces an omitted scope across the subclass component and gates the axis move a subClassOf removal makes", async () => { + const id = "uniqueness_gate_defaults_component_scope"; + const before = await context.createStore( + defaultedScopeHierarchyGraph(id, true), + ); + await before.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + await expect( + before.nodes.GateEmployee.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + + const error = await createAdapterStoreWithSchema( + defaultedScopeHierarchyGraph(id, false), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { + cause: "claim-axis-moved", + kind: "GatePerson", + constraintName: "gate_person_email", + axis: "GatePerson", + lostKinds: ["GateEmployee"], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: reading an omitted node scope as `"kind"` in + // `NODE_UNIQUE_CONSTRAINT_DEFAULTS` accepts the `GateEmployee` duplicate, + // and lets the removal commit with the `GatePerson` claim still at the + // `GateEmployee` axis. + + it("gates the axis move and reports no phantom change for a graph whose declarations skipped defineGraph", async () => { + const id = "uniqueness_gate_defaults_spread"; + const spread = (related: boolean) => + withUndefaultedDeclarations( + defaultedScopeHierarchyGraph(id, related), + "gate_person_email", + ["GatePerson", "GateEmployee"], + ); + const before = await context.createStore(spread(true)); + await before.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + spread(false), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { + cause: "claim-axis-moved", + kind: "GatePerson", + constraintName: "gate_person_email", + axis: "GatePerson", + lostKinds: ["GateEmployee"], + }, + ]); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: with `serializeUniqueConstraints` serializing a + // declaration as given AND the gate's scope test back to + // `scope === "kindWithSubClasses"`, the subClassOf removal commits over + // the claim at the `GateEmployee` axis. Either one alone still refuses. + + it("enforces a declaration that skipped defineGraph across the subclass component, with binary keys", async () => { + const store = await context.createStore( + withUndefaultedDeclarations( + defaultedScopeHierarchyGraph( + "uniqueness_gate_defaults_enforced", + true, + ), + "gate_person_email", + ["GatePerson", "GateEmployee"], + ), + ); + await store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + // The omitted scope spans the component, so a subclass row collides. + await expect( + store.nodes.GateEmployee.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + // The omitted collation is binary, so a value differing in case does not. + await expect( + store.nodes.GateEmployee.create({ + email: DUPLICATE_EMAIL.toUpperCase(), + }), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): dropping the omitted-scope fallback in + // `uniquenessScopeSpansSubclasses` (src/core/unique-constraint-options.ts) + // admits the subclass row; reading an omitted collation as + // `"caseInsensitive"` in `uniqueCollationFoldsCase` refuses the + // upper-cased one. + + it("migrates a graph whose declarations skipped defineGraph without a phantom unique-constraint change", async () => { + const id = "uniqueness_gate_defaults_spread_diff"; + const spread = (nickname: boolean) => + withUndefaultedDeclarations( + defaultedAccountGraph(id, {}, nickname), + ACCOUNT_EMAIL_UNIQUE.name, + ["GateAccount"], + ); + const before = await context.createStore(spread(false)); + await before.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const [, result] = await createAdapterStoreWithSchema( + spread(true), + context.getBackend(), + ); + + if (result.status !== "migrated") { + throw new Error(`expected migrated, got ${result.status}`); + } + expect(uniqueConstraintChanges(result.diff)).toEqual([]); + }); + // MUTATION CHECK: serializing a declaration as given in + // `serializeUniqueConstraints` diffs the absent scope against the + // parser-filled stored one and reports "Unique constraints changed". + + it("adds a unique constraint to an unpopulated kind and enforces it", async () => { + const id = "uniqueness_gate_add_empty"; + await context.createStore(accountGraph(id, "none")); + + const [store] = await createAdapterStoreWithSchema( + accountGraph(id, "kind"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + await expect( + store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("adds a unique constraint once the kind's rows are deleted", async () => { + const id = "uniqueness_gate_add_emptied"; + const before = await context.createStore(accountGraph(id, "none")); + const first = await before.nodes.GateAccount.create({ + email: DUPLICATE_EMAIL, + }); + const second = await before.nodes.GateAccount.create({ + email: DUPLICATE_EMAIL, + }); + await before.nodes.GateAccount.delete(first.id); + await before.nodes.GateAccount.delete(second.id); + + const [store] = await createAdapterStoreWithSchema( + accountGraph(id, "kind"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + await expect( + store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("drops a unique constraint from a populated kind", async () => { + const id = "uniqueness_gate_drop"; + const store = await context.createStore(accountGraph(id, "kind")); + await store.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + await createAdapterStoreWithSchema( + accountGraph(id, "none"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + }); + + it("refuses rolling back to a version that declares a unique constraint the populated kind lacks", async () => { + const id = "uniqueness_gate_rollback"; + await context.createStore(accountGraph(id, "kind")); + const [relaxed] = await createAdapterStoreWithSchema( + accountGraph(id, "none"), + context.getBackend(), + ); + await relaxed.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + await relaxed.nodes.GateAccount.create({ email: DUPLICATE_EMAIL }); + + const error = await rollbackSchema(context.getBackend(), id, 1).catch( + (error_: unknown) => error_, + ); + + expect(gatedConstraints(error)).toEqual([ACCOUNT_CONSTRAINT_ADDED]); + expect((error as MigrationError).details).toMatchObject({ + fromVersion: 2, + toVersion: 1, + }); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: dropping the gate from the tightening preflight + // reactivates version 1 over two duplicate rows, and this case fails. + + it("rolls back to a version that declares a unique constraint while the kind is empty", async () => { + const id = "uniqueness_gate_rollback_empty"; + await context.createStore(accountGraph(id, "kind")); + await createAdapterStoreWithSchema( + accountGraph(id, "none"), + context.getBackend(), + ); + + await rollbackSchema(context.getBackend(), id, 1); + + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses removing a subClassOf relation that moves a populated kind's claim axis", async () => { + const id = "uniqueness_gate_axis_split"; + const store = await context.createStore(hierarchyGraph(id, true)); + await store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + const error = await createAdapterStoreWithSchema( + hierarchyGraph(id, false), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([PERSON_AXIS_MOVED]); + expect(await activeVersion(context, id)).toBe(1); + // Still fenced under the version that stayed active. + await expect( + store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + // MUTATION CHECK: returning no gate for an unchanged constraint in + // `uniquenessClaimGates` commits the split; `GatePerson` then probes only + // its own axis, misses the claim at `GateEmployee`, and accepts a second + // row with the same email. + + it("refuses the same axis move through migrateSchema", async () => { + const id = "uniqueness_gate_axis_split_migrate"; + const store = await context.createStore(hierarchyGraph(id, true)); + await store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + const error = await migrateSchema( + context.getBackend(), + hierarchyGraph(id, false), + 1, + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([PERSON_AXIS_MOVED]); + expect(await activeVersion(context, id)).toBe(1); + }); + + it("refuses rolling back to a version whose hierarchy moves a populated kind's claim axis", async () => { + const id = "uniqueness_gate_axis_split_rollback"; + await context.createStore(hierarchyGraph(id, false)); + const [merged] = await createAdapterStoreWithSchema( + hierarchyGraph(id, true), + context.getBackend(), + ); + await merged.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + const error = await rollbackSchema(context.getBackend(), id, 1).catch( + (error_: unknown) => error_, + ); + + expect(gatedConstraints(error)).toEqual([PERSON_AXIS_MOVED]); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("removes a subClassOf relation while only the kind that keeps its axis is populated", async () => { + const id = "uniqueness_gate_axis_split_kept"; + const before = await context.createStore(hierarchyGraph(id, true)); + await before.nodes.GateEmployee.create({ email: DUPLICATE_EMAIL }); + + const [store] = await createAdapterStoreWithSchema( + hierarchyGraph(id, false), + context.getBackend(), + ); + + // `GateEmployee` wrote at `GateEmployee` and still probes it. The lost + // member, `GatePerson`, sorts after it, so no claim of its can sit there. + expect(await activeVersion(context, id)).toBe(2); + await expect( + store.nodes.GateEmployee.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("orders the claim axis and the split gate by the same code-point order", async () => { + const id = "uniqueness_gate_axis_code_point_order"; + const before = await context.createStore(codePointOrderGraph(id, true)); + const beforeNodes = before.nodes as unknown as EmailCollections; + await requireDefined(beforeNodes[GATE_BMP_KIND]).create({ + email: DUPLICATE_EMAIL, + }); + + const [store] = await createAdapterStoreWithSchema( + codePointOrderGraph(id, false), + context.getBackend(), + ); + const nodes = store.nodes as unknown as EmailCollections; + + // The BMP kind wrote at its own axis and keeps probing it; the lost + // member sorts after it by code point, so the split commits ungated. + expect(await activeVersion(context, id)).toBe(2); + await expect( + requireDefined(nodes[GATE_BMP_KIND]).create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + await expect( + requireDefined(nodes[GATE_ASTRAL_KIND]).create({ + email: DUPLICATE_EMAIL, + }), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK: folding `uniquenessAxisOfKinds` by UTF-16 code unit + // writes the BMP row at the astral axis, so after the split the BMP + // duplicate is accepted and the astral row is refused. + + it("removes a subClassOf relation while the hierarchy holds no rows", async () => { + const id = "uniqueness_gate_axis_split_empty"; + await context.createStore(hierarchyGraph(id, true)); + + const [store] = await createAdapterStoreWithSchema( + hierarchyGraph(id, false), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + await expect( + store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("refuses a split that drops the older axis after a merge moved the claim axis", async () => { + const id = "uniqueness_gate_merge_then_split"; + const first = await context.createStore( + chainGraph(id, { middle: true, alpha: false }), + ); + // The claim is written at `GateMiddle`, the smallest member. + await first.nodes.GateSubject.create({ email: DUPLICATE_EMAIL }); + const [merged] = await createAdapterStoreWithSchema( + chainGraph(id, { middle: true, alpha: true }), + context.getBackend(), + ); + // The merge moves the axis to `GateAlpha` without hiding the old claim. + expect(await activeVersion(context, id)).toBe(2); + await expect( + merged.nodes.GateSubject.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + + // The split removes `GateMiddle`, where the claim sits, while the + // previous schema's own axis (`GateAlpha`) stays in the component. + const error = await createAdapterStoreWithSchema( + chainGraph(id, { middle: false, alpha: true }), + context.getBackend(), + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { + cause: "claim-axis-moved", + kind: "GateSubject", + constraintName: "gate_chain_email", + axis: "GateAlpha", + lostKinds: ["GateMiddle"], + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + await expect( + merged.nodes.GateSubject.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + // MUTATION CHECK: gating only when the previous schema's own axis leaves + // the proposed component commits version 3 here; `GateSubject` then probes + // `{GateAlpha, GateSubject}`, misses the claim at `GateMiddle`, and accepts + // a second row with the same email. + + it("keeps existing claims fencing after a subClassOf addition merges the axis", async () => { + const id = "uniqueness_gate_axis_merge"; + const before = await context.createStore(hierarchyGraph(id, false)); + await before.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }); + + const [store] = await createAdapterStoreWithSchema( + hierarchyGraph(id, true), + context.getBackend(), + ); + + // The axis moved from `GatePerson` to `GateEmployee`, but the merged + // component still probes `GatePerson`, where the old claim sits. + expect(await activeVersion(context, id)).toBe(2); + await expect( + store.nodes.GatePerson.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + await expect( + store.nodes.GateEmployee.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses removeKinds of the kind a populated hierarchy's claims sit under", async () => { + const id = "uniqueness_gate_remove_kinds"; + const store = await context.createStore(wholeGraph(id)); + const evolved = await store.evolve(AARDVARK_EXTENSION); + await evolved.nodes.GateWhole.create({ email: DUPLICATE_EMAIL }); + + const error = await evolved + .removeKinds(["GateAardvark"]) + .catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { + cause: "claim-axis-moved", + kind: "GateWhole", + constraintName: "gate_whole_email", + axis: "GateWhole", + lostKinds: ["GateAardvark"], + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: dropping the gate lets the removal commit; `GateWhole` + // then probes only itself and accepts a duplicate of the claimed email. + + it("removes that kind while the hierarchy is empty", async () => { + const id = "uniqueness_gate_remove_kinds_empty"; + const store = await context.createStore(wholeGraph(id)); + const evolved = await store.evolve(AARDVARK_EXTENSION); + + await evolved.removeKinds(["GateAardvark"]); + + expect(await activeVersion(context, id)).toBe(3); + }); + + it("refuses adding a unique constraint to a populated extension kind through evolve and an adopted plan", async () => { + const id = "uniqueness_gate_evolve"; + const backend = context.getBackend(); + const [store] = await createAdapterStoreWithSchema( + hostGraph(id), + backend, + ); + const evolved = await store.evolve(taggedExtension(false)); + const tags = evolved.getNodeCollectionOrThrow("GateTag"); + await tags.create({ label: "same" }); + await tags.create({ label: "same" }); + + const evolveError = await evolved + .evolve(taggedExtension(true)) + .catch((error_: unknown) => error_); + expect(evolveError).toBeInstanceOf(IncompatibleChangeError); + expect((evolveError as IncompatibleChangeError).changes).toMatchObject([ + { kind: "GateTag", type: "ADD_UNIQUE_ON_POPULATED" }, + ]); + + // The gate and the extension classifier name the same kind, so the plan + // lists it once, and not as an ontology tightening. + const plan = await evolved.planEvolution(taggedExtension(true)); + if (plan.status !== "change") throw new Error("Expected a change plan."); + expect( + plan.requirements.filter( + (requirement) => + requirement.kind === "require-empty" || + requirement.kind === "ontology-tightening", + ), + ).toEqual([ + { kind: "require-empty", entity: "node", kindName: "GateTag" }, + ]); + // A lane that cannot adopt a schema-write transaction refuses the apply + // before reaching the plan's checks. + const adoptedRefusal = + backend.adoptSchemaWriteTransaction === undefined ? + UnsupportedBackendCapabilityError + : IncompatibleChangeError; + const adoptedError = await backend + .transactionWithNative(async (_target, nativeTx) => + evolved.withEvolvedTransaction(nativeTx, plan, async () => { + await Promise.resolve(); + }), + ) + .catch((error_: unknown) => error_); + expect(adoptedError).toBeInstanceOf(adoptedRefusal); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: dropping the `ADD_UNIQUE_ON_POPULATED` record from + // `classifyUnique` changes both refusals to the gate's `MigrationError` + // (the commit is still refused), and this case fails. + + it("evolves an extension kind's unique constraint to the same declaration with its defaults stated", async () => { + const id = "uniqueness_gate_extension_defaults"; + const [store] = await createAdapterStoreWithSchema( + hostGraph(id), + context.getBackend(), + ); + const evolved = await store.evolve(taggedExtension(true)); + const tags = evolved.getNodeCollectionOrThrow("GateTag"); + await tags.create({ label: "same" }); + + const restated = await evolved.evolve( + taggedExtension("explicitDefaults"), + ); + + expect(await activeVersion(context, id)).toBe(3); + await expect( + restated.getNodeCollectionOrThrow("GateTag").create({ label: "same" }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + // MUTATION CHECK: comparing the declarations as written in + // `uniqueConstraintEnforcementChange` reads the stated defaults as a + // reshape, `classifyUnique` records `ADD_UNIQUE_ON_POPULATED`, and this + // case fails. + + it("refuses reshaping an extension kind's unique constraint on a populated kind", async () => { + const id = "uniqueness_gate_extension_reshape"; + const [store] = await createAdapterStoreWithSchema( + hostGraph(id), + context.getBackend(), + ); + const evolved = await store.evolve(taggedExtension(true)); + await evolved + .getNodeCollectionOrThrow("GateTag") + .create({ label: "same" }); + + const error = await evolved + .evolve(taggedExtension("caseInsensitive")) + .catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(IncompatibleChangeError); + expect((error as IncompatibleChangeError).changes).toMatchObject([ + { + kind: "GateTag", + type: "ADD_UNIQUE_ON_POPULATED", + detail: 'unique constraint "gate_tag_label" reshaped', + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + }); + + it("refuses re-adding a kind with a unique constraint over rows a discarded drop left behind", async () => { + const id = "uniqueness_gate_readd_discarded"; + const backend = context.getBackend(); + const original = await context.createStore(revenantGraph(id, "plain")); + await original.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }); + await migrateSchema(backend, revenantGraph(id, "dropped"), 1, { + discardDroppedKindRows: true, + }); + expect(await activeVersion(context, id)).toBe(2); + + const error = await createAdapterStoreWithSchema( + revenantGraph(id, "unique"), + backend, + ).catch((error_: unknown) => error_); + + expect(gatedConstraints(error)).toEqual([ + { + cause: "constraint-added", + kind: "GateRevenant", + constraintName: "gate_revenant_email", + axis: "GateRevenant", + }, + ]); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: skipping kinds absent from the before-document in + // `declaredKindUniqueConstraints` commits version 3, and the old row, live + // again with no claim, lets `GateRevenant.create` accept a duplicate. + + it("re-adds that kind with a unique constraint once the discarded rows are reclaimed", async () => { + const id = "uniqueness_gate_readd_reclaimed"; + const backend = context.getBackend(); + const original = await context.createStore(revenantGraph(id, "plain")); + await original.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }); + await migrateSchema(backend, revenantGraph(id, "dropped"), 1, { + discardDroppedKindRows: true, + }); + const [dropped] = await createAdapterStoreWithSchema( + revenantGraph(id, "dropped"), + backend, + ); + await dropped.materializeRemovals(); + + const [store] = await createAdapterStoreWithSchema( + revenantGraph(id, "unique"), + backend, + ); + + expect(await activeVersion(context, id)).toBe(3); + await store.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }); + await expect( + store.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("adds a kind with a unique constraint when no rows exist for it", async () => { + const id = "uniqueness_gate_add_new_kind"; + await context.createStore( + defineGraph({ + id, + nodes: { GateBystander: { type: Bystander } }, + edges: {}, + }), + ); + + const [store] = await createAdapterStoreWithSchema( + revenantGraph(id, "unique"), + context.getBackend(), + ); + + expect(await activeVersion(context, id)).toBe(2); + await store.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }); + await expect( + store.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + + it("refuses an extension kind that re-adds discarded rows under a unique constraint, through evolve and an adopted plan", async () => { + const id = "uniqueness_gate_readd_extension"; + const backend = context.getBackend(); + const original = await context.createStore(revenantGraph(id, "plain")); + await original.nodes.GateRevenant.create({ email: DUPLICATE_EMAIL }); + await migrateSchema(backend, revenantGraph(id, "dropped"), 1, { + discardDroppedKindRows: true, + }); + const [store] = await createAdapterStoreWithSchema( + revenantGraph(id, "dropped"), + backend, + ); + + const evolveError = await store + .evolve(REVENANT_EXTENSION) + .catch((error_: unknown) => error_); + expect(gatedConstraints(evolveError)).toMatchObject([ + { cause: "constraint-added", kind: "GateRevenant" }, + ]); + + // The kind is new in the plan, so it is listed as new and not twice. + const plan = await store.planEvolution(REVENANT_EXTENSION); + if (plan.status !== "change") throw new Error("Expected a change plan."); + expect( + plan.requirements.filter( + (requirement) => requirement.kind === "require-empty", + ), + ).toEqual([]); + const adoptedRefusal = + backend.adoptSchemaWriteTransaction === undefined ? + UnsupportedBackendCapabilityError + : MigrationError; + const adoptedError = await backend + .transactionWithNative(async (_target, nativeTx) => + store.withEvolvedTransaction(nativeTx, plan, async () => { + await Promise.resolve(); + }), + ) + .catch((error_: unknown) => error_); + expect(adoptedError).toBeInstanceOf(adoptedRefusal); + expect(adoptedError).toMatchObject( + backend.adoptSchemaWriteTransaction === undefined ? + {} + : { + details: { + reason: "unique-constraint-requires-empty-kind", + constraints: [ + { cause: "constraint-added", kind: "GateRevenant" }, + ], + }, + }, + ); + expect(await activeVersion(context, id)).toBe(2); + }); + // MUTATION CHECK: replacing `assertUniquenessClaimGatesOpen` in the + // tightening preflight with a no-op fails this case (the adopted plan + // commits) along with every other gate case. + + it("counts at most `limit` rows when asked", async () => { + const id = "uniqueness_gate_bounded_count"; + const store = await context.createStore(accountGraph(id, "none")); + for (const email of ["a", "b", "c"]) { + await store.nodes.GateAccount.create({ email }); + } + const backend = context.getBackend(); + + expect( + await backend.countNodesByKind({ graphId: id, kind: "GateAccount" }), + ).toBe(3); + expect( + await backend.countNodesByKind({ + graphId: id, + kind: "GateAccount", + limit: 1, + }), + ).toBe(1); + expect( + await backend.countNodesByKind({ + graphId: id, + kind: "GateAccount", + limit: 10, + }), + ).toBe(3); + }); + // MUTATION CHECK: ignoring `limit` in `buildCountQuery` makes the bounded + // count 3 and this case fails. + + it("refuses with UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND when the backend cannot commit a preflight atomically", async () => { + const id = "uniqueness_gate_capability"; + await context.createStore(accountGraph(id, "none")); + const restrictedBackend = projectBackendWithout(context.getBackend(), [ + "commitSchemaVersionWithPreflight", + ]); + + const error = await createAdapterStoreWithSchema( + accountGraph(id, "kind"), + restrictedBackend, + ).catch((error_: unknown) => error_); + + // Refused even though the kind is empty: without the atomic primitive + // the emptiness probe cannot be bound to the commit it decides. + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(1); + }); + // MUTATION CHECK: returning `undefined` from + // `prepareSchemaTighteningPreflight` for a gate-only transition commits + // through the plain primitive with no probe, and this case fails. + + it("refuses a gated rollback on a backend without setActiveVersionWithPreflight", async () => { + const id = "uniqueness_gate_rollback_capability"; + await context.createStore(accountGraph(id, "kind")); + await createAdapterStoreWithSchema( + accountGraph(id, "none"), + context.getBackend(), + ); + const restrictedBackend = projectBackendWithout(context.getBackend(), [ + "setActiveVersionWithPreflight", + ]); + + const error = await rollbackSchema(restrictedBackend, id, 1).catch( + (error_: unknown) => error_, + ); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "UNIQUE_CONSTRAINT_CHANGE_REQUIRES_ATOMIC_BACKEND", + }); + expect(await activeVersion(context, id)).toBe(2); + }); + }); +} diff --git a/packages/typegraph/tests/backends/integration/write-fence-conformance.ts b/packages/typegraph/tests/backends/integration/write-fence-conformance.ts index 3fa15216a..dcab98d0c 100644 --- a/packages/typegraph/tests/backends/integration/write-fence-conformance.ts +++ b/packages/typegraph/tests/backends/integration/write-fence-conformance.ts @@ -96,7 +96,7 @@ import { } from "../../../src/backend/capabilities/write-fence"; import { assertGraphCommandConvergenceIsolation, - graphCommandCoordinationIsolation, + graphCommandCoordinationAcquisition, mintGraphCommandCoordination, } from "../../../src/backend/command-contract"; import { deriveBackend } from "../../../src/backend/derive-backend"; @@ -510,11 +510,11 @@ export function registerWriteFenceConformanceIntegrationTests( "an advisory-mechanism lock always mints coordination", ); expect( - graphCommandCoordinationIsolation( + graphCommandCoordinationAcquisition( tx.commands, - graphId, coordination, - ), + graphId, + ).isolation, ).toBe("read_committed"); }); } finally { @@ -589,7 +589,7 @@ export function registerWriteFenceConformanceIntegrationTests( const uncertified = mintGraphCommandCoordination( backend.commands, "write-fence-conformance-uncertified", - "unknown", + { isolation: "unknown", fenceRow: "advanced" }, ); expect(() => { assertGraphCommandConvergenceIsolation( @@ -855,11 +855,11 @@ export function registerWriteFenceConformanceIntegrationTests( "a row-mechanism lock always mints coordination", ); expect( - graphCommandCoordinationIsolation( + graphCommandCoordinationAcquisition( tx.commands, - graphId, coordination, - ), + graphId, + ).isolation, ).toBe("read_committed"); }); } finally { @@ -928,11 +928,11 @@ export function registerWriteFenceConformanceIntegrationTests( "a row-mechanism lock always mints coordination", ); expect( - graphCommandCoordinationIsolation( + graphCommandCoordinationAcquisition( tx.commands, - graphId, coordination, - ), + graphId, + ).isolation, ).toBe("read_committed"); }); } finally { diff --git a/packages/typegraph/tests/backends/postgres/composition-cascade-lock-order.test.ts b/packages/typegraph/tests/backends/postgres/composition-cascade-lock-order.test.ts new file mode 100644 index 000000000..8f79fbe92 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/composition-cascade-lock-order.test.ts @@ -0,0 +1,147 @@ +/** + * The order a composition cascade locks its members in, read off the + * statements it sends: one `FOR UPDATE` statement per member kind, kinds in + * code-point order. + * + * Two cascades over overlapping closures must take their member row locks in + * one agreed order or they can deadlock; recording the statements pins that + * order directly instead of racing two deletes and hoping to catch a deadlock. + * The fixture's kinds disagree on every other plausible order: the cascade + * reports the leaf `alpha` before its whole `Zed` (leaf-first), and a + * locale-aware sort puts `alpha` before `Zed`, while code-point order puts + * `Zed` (U+005A) before `alpha` (U+0061). The two `Zed` ids disagree the same + * way inside one kind: the cascade sorts siblings by UTF-16 code unit, which + * puts the emoji (a surrogate pair, U+D83D first) before the fullwidth `z` + * (U+FF5A), while code-point order puts `z` first. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, + type Store, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const Hub = defineNode("Hub", { schema: z.object({}) }); +const Zed = defineNode("Zed", { schema: z.object({}) }); +const Alpha = defineNode("alpha", { schema: z.object({}) }); +const zedOf = defineEdge("zedOf", { schema: z.object({}) }); +const alphaOf = defineEdge("alphaOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-cascade-lock-order", + nodes: { Hub: { type: Hub }, Zed: { type: Zed }, alpha: { type: Alpha } }, + edges: { + zedOf: { type: zedOf, from: [Zed], to: [Hub], cardinality: "one" }, + alphaOf: { type: alphaOf, from: [Alpha], to: [Zed], cardinality: "one" }, + }, + ontology: [ + partOf(Zed, Hub, { via: zedOf }), + partOf(Alpha, Zed, { via: alphaOf }), + ], +}); + +const MEMBER_KINDS = ["Zed", "alpha"] as const; +const ZED_FULLWIDTH = "zed-\uFF5A"; +const ZED_EMOJI = "zed-\u{1F600}"; + +type RecordedStatement = Readonly<{ query: string; params: unknown[] }>; + +const statements: RecordedStatement[] = []; +let pool: Pool | undefined; +let store: Store | undefined; + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const created = new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 2, + }); + await runServerSuiteSetup( + "composition-cascade-lock-order", + [created], + async () => { + await created.query(generatePostgresMigrationSQL()); + pool = created; + const db = drizzle(created, { + logger: { + logQuery(query: string, params: unknown[]): void { + statements.push({ query, params }); + }, + }, + }); + [store] = await createStoreWithSchema(graph, createPostgresBackend(db)); + }, + ); +}); + +afterAll(async () => { + if (pool !== undefined) await pool.end(); +}); + +describe.runIf(process.env["POSTGRES_URL"])( + "composition cascade member lock order (PostgreSQL)", + () => { + // MUTATION CHECK: reverse the comparator of `lockOrder` in + // `lockCascadeMembers` (src/store/operations/node-operations.ts), or drop + // the sort and lock in the cascade's own leaf-first order — the + // statements then read `[["alpha"], ["Zed"]]`; lock each member in its + // own statement and they read `[["Zed"], ["Zed"], ["alpha"], ["alpha"]]`. + // Drop the code-point sort from the backend's `lockNodes` + // (src/backend/drizzle/operation-backend-core.ts) and the `Zed` ids arrive + // emoji first; drop `buildLockNodes`' `ORDER BY` and the statement no + // longer orders the rows it locks. + it("locks one statement per member kind, kinds and ids in code-point order", async () => { + if (store === undefined) throw new Error("PostgreSQL store unavailable"); + const hub = await store.nodes.Hub.create({}); + for (const id of [ZED_FULLWIDTH, ZED_EMOJI]) { + const zed = await store.nodes.Zed.create( + {}, + { id, partOf: { whole: hub } }, + ); + await store.nodes.alpha.create({}, { partOf: { whole: zed } }); + } + + statements.splice(0); + await store.nodes.Hub.delete(hub.id); + + const memberLocks = statements + .filter( + ({ query }) => + query.includes("FOR UPDATE") && query.includes("typegraph_nodes"), + ) + .map(({ query, params }) => ({ + kinds: MEMBER_KINDS.filter((kind) => params.includes(kind)), + zedIds: params.filter( + (parameter) => + parameter === ZED_FULLWIDTH || parameter === ZED_EMOJI, + ), + ordersRows: query.includes("ORDER BY"), + })) + .filter((lock) => lock.kinds.length > 0); + expect(memberLocks.map((lock) => lock.kinds)).toEqual([ + ["Zed"], + ["alpha"], + ]); + expect(memberLocks[0]?.zedIds).toEqual([ZED_FULLWIDTH, ZED_EMOJI]); + expect(memberLocks.every((lock) => lock.ordersRows)).toBe(true); + expect(await store.nodes.Zed.count()).toBe(0); + expect(await store.nodes.alpha.count()).toBe(0); + }); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-claim-fence.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-claim-fence.test.ts index 7959b14c6..0dec9213b 100644 --- a/packages/typegraph/tests/backends/postgres/concurrent-claim-fence.test.ts +++ b/packages/typegraph/tests/backends/postgres/concurrent-claim-fence.test.ts @@ -898,7 +898,7 @@ describe.runIf(process.env["POSTGRES_URL"])( "refuses an import edge whose cardinality-one axis a store create took inside its claim window", { timeout: CONTENTION_TIMEOUT_MS }, async () => { - // C3. Both legs probe `countEdgesFrom` and both read zero, because + // C3. Both legs probe `countEdgesAtEndpoint` and both read zero, because // neither row is committed yet; the edges primary key is // `(graph_id, id)` and the two ids differ, so nothing at the database // level collides. The claim row is the entire fence. diff --git a/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade-endpoint.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade-endpoint.test.ts new file mode 100644 index 000000000..e28504e7c --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade-endpoint.test.ts @@ -0,0 +1,392 @@ +/** + * A composition whole's delete cascades to its parts, and each part it deletes + * owes the same pairing a direct delete of that part takes (issue #795): an + * edge written concurrently onto a cascaded part must never stay live on it. + * + * The cascade reads every part's connected edges while it plans, then + * tombstones the parts. A soft delete's tombstone is an `UPDATE` that changes + * no key, so it does not conflict with the `FOR KEY SHARE` an edge write takes + * on its endpoints; only the cascade's own `FOR UPDATE` on each part does. + * Without it both orders leave a live edge on a deleted part: + * + * - **insert first**: the cascade's connected-edge read cannot see the + * uncommitted edge, and its tombstone does not wait for it. + * - **delete first**: the edge write's liveness read sees the committed, live + * part and commits after the cascade. + * + * READ COMMITTED only: the higher-isolation residual is the one + * `concurrent-edge-endpoint-delete.test.ts` pins for every delete. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + EndpointNotFoundError, + partOf, + type Store, + type TransactionContext, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { requireDefined } from "../../../src/utils/presence"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; +/** How long the second writer may take to either settle or park on a lock. */ +const SETTLE_OR_BLOCK_DEADLINE_MS = 5000; +const POLL_INTERVAL_MS = 10; + +const Folder = defineNode("Folder", { schema: z.object({ name: z.string() }) }); +const Note = defineNode("Note", { schema: z.object({ name: z.string() }) }); +const Reader = defineNode("Reader", { schema: z.object({ name: z.string() }) }); +const noteOf = defineEdge("noteOf", { schema: z.object({}) }); +/** An unconstrained edge: its create takes no per-graph lock. */ +const cites = defineEdge("cites", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-composition-cascade-endpoint", + nodes: { + Folder: { type: Folder }, + Note: { type: Note, onDelete: "cascade" }, + Reader: { type: Reader }, + }, + edges: { + noteOf: { type: noteOf, from: [Note], to: [Folder], cardinality: "one" }, + cites: { type: cites, from: [Reader], to: [Note] }, + }, + ontology: [partOf(Note, Folder, { via: noteOf })], +}); + +type GraphStore = Store; +type WriteSurface = Pick, "edges" | "nodes">; + +const FOLDER = "folder"; +const NOTE = "note"; +const READER = "reader"; + +const folder = { kind: "Folder", id: asNodeId(FOLDER) } as const; +const note = { kind: "Note", id: asNodeId(NOTE) } as const; +const reader = { kind: "Reader", id: asNodeId(READER) } as const; + +type WholeDelete = Readonly<{ + name: string; + run: (surface: WriteSurface) => Promise; +}>; + +const WHOLE_DELETES: readonly WholeDelete[] = [ + { + name: "delete", + run: (surface) => surface.nodes.Folder.delete(folder.id), + }, + { + name: "hardDelete", + run: (surface) => surface.nodes.Folder.hardDelete(folder.id), + }, + { + name: "bulkDelete", + run: (surface) => surface.nodes.Folder.bulkDelete([folder.id]), + }, +]; + +function createCite(surface: WriteSurface): Promise { + return surface.edges.cites.create(reader, note, {}); +} + +let deletePool: Pool | undefined; +let edgePool: Pool | undefined; +let probePool: Pool | undefined; +let stores: + Readonly<{ deleteStore: GraphStore; edgeStore: GraphStore }> | undefined; + +function requireLive(): Readonly<{ + deleteStore: GraphStore; + edgeStore: GraphStore; + probe: Pool; +}> { + if (stores === undefined || probePool === undefined) { + throw new Error( + "concurrent-composition-cascade-endpoint: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { ...stores, probe: probePool }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [createPool(), createPool(), createPool()] as const; + await runServerSuiteSetup( + "concurrent-composition-cascade-endpoint", + pools, + async () => { + const [first, second, probe] = pools; + await probe.query(generatePostgresMigrationSQL()); + deletePool = first; + edgePool = second; + probePool = probe; + const [deleteStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(first)), + ); + const [edgeStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(second)), + ); + stores = { deleteStore, edgeStore }; + }, + ); +}); + +afterAll(async () => { + for (const pool of [deletePool, edgePool, probePool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (probePool === undefined) return; + await probePool.query( + "TRUNCATE typegraph_edge_claims, typegraph_node_uniques, typegraph_edges, typegraph_nodes", + ); + const { deleteStore: setup } = requireLive(); + await setup.nodes.Folder.create({ name: "Folder" }, { id: FOLDER }); + await setup.nodes.Note.create( + { name: "Note" }, + { id: NOTE, partOf: { whole: folder } }, + ); + await setup.nodes.Reader.create({ name: "Reader" }, { id: READER }); +}); + +type Deferred = Readonly<{ promise: Promise; resolve: () => void }>; + +function deferred(): Deferred { + const handle: { resolve?: () => void } = {}; + const promise = new Promise((settle) => { + handle.resolve = settle; + }); + return { + promise, + resolve: () => { + requireDefined(handle.resolve)(); + }, + }; +} + +type Outcome = Readonly< + { status: "ok" } | { status: "error"; error: unknown; name: string } +>; + +async function settle(operation: Promise): Promise { + try { + await operation; + return { status: "ok" }; + } catch (error) { + return { + status: "error", + error, + name: error instanceof Error ? error.name : String(error), + }; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Whether the second writer settled on its own or parked on a lock held by the + * first writer's open transaction. Only the two stores' connections run in this + * suite's database, so any lock waiter is the second writer. + */ +async function settledOrBlocked( + probe: Pool, + operation: Promise, +): Promise<"settled" | "blocked"> { + const state = { settled: false }; + void operation.then(() => { + state.settled = true; + }); + const deadline = Date.now() + SETTLE_OR_BLOCK_DEADLINE_MS; + while (Date.now() < deadline) { + if (state.settled) return "settled"; + const waiters = await probe.query<{ waiters: number }>( + `SELECT count(*)::int AS waiters FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock'`, + ); + if ((waiters.rows[0]?.waiters ?? 0) > 0) return "blocked"; + await sleep(POLL_INTERVAL_MS); + } + throw new Error( + "The second writer neither settled nor waited on a lock before the deadline.", + ); +} + +/** Live edges whose source or target node is tombstoned or gone. */ +async function liveEdgesOnDeletedEndpoints(probe: Pool): Promise { + const result = await probe.query<{ dangling: number }>( + `SELECT count(*)::int AS dangling + FROM typegraph_edges AS e + LEFT JOIN typegraph_nodes AS f + ON f.graph_id = e.graph_id AND f.kind = e.from_kind AND f.id = e.from_id + LEFT JOIN typegraph_nodes AS t + ON t.graph_id = e.graph_id AND t.kind = e.to_kind AND t.id = e.to_id + WHERE e.deleted_at IS NULL + AND (f.id IS NULL OR f.deleted_at IS NOT NULL + OR t.id IS NULL OR t.deleted_at IS NOT NULL)`, + ); + return result.rows[0]?.dangling ?? 0; +} + +async function noteIsLive(probe: Pool): Promise { + const result = await probe.query<{ live: boolean }>( + `SELECT EXISTS ( + SELECT 1 FROM typegraph_nodes + WHERE kind = 'Note' AND id = $1 AND deleted_at IS NULL + ) AS live`, + [NOTE], + ); + return result.rows[0]?.live === true; +} + +type Verdict = Readonly<{ + second: "settled" | "blocked"; + delete: string; + edge: string; + dangling: number; + noteLive: boolean; +}>; + +function describeOutcome(outcome: Outcome): string { + return outcome.status === "ok" ? "ok" : outcome.name; +} + +async function verdictOf( + probe: Pool, + second: "settled" | "blocked", + deleteOutcome: Outcome, + edgeOutcome: Outcome, +): Promise { + return { + second, + delete: describeOutcome(deleteOutcome), + edge: describeOutcome(edgeOutcome), + dangling: await liveEdgesOnDeletedEndpoints(probe), + noteLive: await noteIsLive(probe), + }; +} + +/** + * The whole's delete runs inside a transaction held open on a gate; the edge + * write starts on the other connection; then the delete commits. + */ +async function raceDeleteFirst(wholeDelete: WholeDelete): Promise { + const live = requireLive(); + const deleted = deferred(); + const release = deferred(); + const deleteOperation = settle( + live.deleteStore.transaction(async (tx) => { + await wholeDelete.run(tx); + deleted.resolve(); + await release.promise; + }), + ); + await Promise.race([deleted.promise, deleteOperation]); + const edgeOperation = settle(createCite(live.edgeStore)); + const second = await settledOrBlocked(live.probe, edgeOperation); + release.resolve(); + const [deleteOutcome, edgeOutcome] = await Promise.all([ + deleteOperation, + edgeOperation, + ]); + return verdictOf(live.probe, second, deleteOutcome, edgeOutcome); +} + +/** + * The edge write runs inside a transaction held open on a gate; the whole's + * delete starts on the other connection; then the edge write commits. + */ +async function raceInsertFirst(wholeDelete: WholeDelete): Promise { + const live = requireLive(); + const inserted = deferred(); + const release = deferred(); + const edgeOperation = settle( + live.edgeStore.transaction(async (tx) => { + await createCite(tx); + inserted.resolve(); + await release.promise; + }), + ); + await Promise.race([inserted.promise, edgeOperation]); + const deleteOperation = settle( + live.deleteStore.transaction((tx) => wholeDelete.run(tx)), + ); + const second = await settledOrBlocked(live.probe, deleteOperation); + release.resolve(); + const [edgeOutcome, deleteOutcome] = await Promise.all([ + edgeOperation, + deleteOperation, + ]); + return verdictOf(live.probe, second, deleteOutcome, edgeOutcome); +} + +describe.runIf(process.env["POSTGRES_URL"])( + "a composition cascade and a concurrent edge onto a cascaded part", + () => { + // MUTATION CHECK: drop the `lockCascadeMembers` call from + // `planCascadingNodeDelete` (src/store/operations/node-operations.ts). + // Five of the six cases then leave the edge live on the deleted note + // (`dangling: 1`). Only hard delete-first still holds without it: the + // part's row `DELETE` itself conflicts with the edge write's endpoint + // lock. + for (const wholeDelete of WHOLE_DELETES) { + it( + `${wholeDelete.name}, delete first: the edge write waits and refuses the deleted part`, + async () => { + expect(await raceDeleteFirst(wholeDelete)).toEqual({ + second: "blocked", + delete: "ok", + edge: EndpointNotFoundError.name, + dangling: 0, + noteLive: false, + }); + }, + CASE_TIMEOUT_MS, + ); + + it( + `${wholeDelete.name}, insert first: the cascade waits and removes the committed edge`, + async () => { + expect(await raceInsertFirst(wholeDelete)).toEqual({ + second: "blocked", + delete: "ok", + edge: "ok", + dangling: 0, + noteLive: false, + }); + }, + CASE_TIMEOUT_MS, + ); + } + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade.test.ts new file mode 100644 index 000000000..243e04337 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-composition-cascade.test.ts @@ -0,0 +1,618 @@ +/** + * A composition whole's delete cascade against a second, genuinely concurrent + * PostgreSQL connection. + * + * The cascade plans from reads no claim row backs, so two things can go wrong + * that no single-connection lane can show: + * + * - **A stale plan.** A session whose snapshot predates its wait on the + * per-graph write fence does not see a part the fence holder just attached. + * The attach and the delete both advance the graph's fence row, so such a + * session fails there with a serialization failure, before the closure is + * read. + * - **A stale pre-image.** An ordinary property update takes no per-graph + * lock. The delete takes the node's delete row lock before it reads the + * node's connected edges — a directly deleted node and every cascade member + * alike — so the update waits for the delete and then finds the node gone, + * and the delete releases the uniqueness entries of the row it locked. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + defineEdge, + defineGraph, + defineNode, + NodeDeletePlanMismatchError, + type NodeId, + NodeNotFoundError, + partOf, + TransactionConflictError, +} from "../../../src"; +import { deriveBackend } from "../../../src/backend/derive-backend"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { requireDefined } from "../../../src/utils/presence"; +import { isSerializationFailure } from "../../../src/utils/sql-errors"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CONTENTION_TIMEOUT_MS = 20_000; +const FENCE_WAIT_POLL_MS = 20; + +const Show = defineNode("Show", { schema: z.object({}) }); +const Clip = defineNode("Clip", { schema: z.object({ slug: z.string() }) }); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-composition-cascade", + nodes: { + Show: { type: Show }, + Clip: { + type: Clip, + unique: [ + { + name: "clip_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + clipOf: { type: clipOf, from: [Clip], to: [Show], cardinality: "one" }, + }, + ontology: [partOf(Clip, Show, { via: clipOf, existence: "required" })], +}); + +let firstPool: Pool | undefined; +let secondPool: Pool | undefined; +let firstDb: NodePgDatabase | undefined; +let secondDb: NodePgDatabase | undefined; + +function requirePostgres(): Readonly<{ + first: NodePgDatabase; + second: NodePgDatabase; + observer: Pool; +}> { + if ( + firstDb === undefined || + secondDb === undefined || + firstPool === undefined + ) { + throw new Error( + "concurrent-composition-cascade: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { first: firstDb, second: secondDb, observer: firstPool }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const first = createPool(); + const second = createPool(); + await runServerSuiteSetup( + "concurrent-composition-cascade", + [first, second], + async () => { + await first.query(generatePostgresMigrationSQL()); + await second.query("SELECT 1"); + firstPool = first; + secondPool = second; + firstDb = drizzle(first); + secondDb = drizzle(second); + }, + ); +}); + +afterAll(async () => { + if (firstPool !== undefined) await firstPool.end(); + if (secondPool !== undefined) await secondPool.end(); +}); + +beforeEach(async () => { + if (firstPool === undefined) return; + await firstPool.query( + "TRUNCATE typegraph_edges, typegraph_nodes, typegraph_node_uniques", + ); +}); + +type Gate = Readonly<{ opened: Promise; open: () => void }>; + +function createGate(): Gate { + const gate: { open?: () => void } = {}; + const opened = new Promise((resolve) => { + gate.open = resolve; + }); + return { opened, open: requireDefined(gate.open) }; +} + +/** + * Resolves once some session of THIS database is parked waiting for an + * advisory lock. `pg_locks` is cluster-wide, so the database filter keeps a + * suite running against another database from answering for this one. + */ +async function untilSessionWaitsOnFence(observer: Pool): Promise { + for (;;) { + const { rows } = await observer.query<{ waiting: string }>( + `SELECT count(*) AS waiting + FROM pg_locks + WHERE locktype = 'advisory' + AND NOT granted + AND database = ( + SELECT oid FROM pg_database WHERE datname = current_database() + )`, + ); + if (Number(rows[0]?.waiting) > 0) return; + await new Promise((resolve) => setTimeout(resolve, FENCE_WAIT_POLL_MS)); + } +} + +/** + * Whether `operation` settled on its own or parked waiting on a row lock of + * THIS database: a tuple or transaction lock some session waits for. + */ +async function settledOrWaitingOnRowLock( + observer: Pool, + operation: Promise, +): Promise<"settled" | "waiting"> { + const state = { settled: false }; + void operation.then( + () => { + state.settled = true; + }, + () => { + state.settled = true; + }, + ); + for (;;) { + if (state.settled) return "settled"; + const { rows } = await observer.query<{ waiting: string }>( + `SELECT count(*) AS waiting + FROM pg_locks + WHERE locktype IN ('tuple', 'transactionid') + AND NOT granted + AND pid IN ( + SELECT pid FROM pg_stat_activity WHERE datname = current_database() + )`, + ); + if (Number(rows[0]?.waiting) > 0) return "waiting"; + await new Promise((resolve) => setTimeout(resolve, FENCE_WAIT_POLL_MS)); + } +} + +/** + * A store whose deletes park at the delete-behavior judge's first connected + * edge read: after the delete (and a cascade's plan) has read the node rows, + * before any row is written. Only the timing is injected. + */ +function createPausedDeleter(db: NodePgDatabase): Readonly<{ + deleter: ReturnType>; + planned: Gate; + resume: Gate; +}> { + const backend = createPostgresBackend(db); + const planned = createGate(); + const resume = createGate(); + const pausedBackend = deriveBackend(backend, { + transaction: (fn, options) => + backend.transaction( + (tx) => + fn( + deriveBackend(tx, { + findEdgesConnectedTo: async (params) => { + planned.open(); + await resume.opened; + return tx.findEdgesConnectedTo(params); + }, + }), + ), + options, + ), + }); + return { deleter: createStore(graph, pausedBackend), planned, resume }; +} + +/** + * A part deleted by a writer that takes no per-graph lock, between the + * cascade's closure walk and its member locks: the walk still finds the + * part through its uncommitted edge tombstone, and the cascade waits on + * the part's row lock until that delete commits. + */ +async function raceCascadeAgainstPartDelete( + deleteWhole: ( + deleter: ReturnType>, + showId: NodeId, + ) => Promise, +): Promise { + const live = requirePostgres(); + const deleter = createStore(graph, createPostgresBackend(live.first)); + const partDeleter = createStore(graph, createPostgresBackend(live.second)); + const show = await deleter.nodes.Show.create({}, { id: "show" }); + await deleter.nodes.Clip.create( + { slug: "c0" }, + { id: "clip0", partOf: { whole: show } }, + ); + const clip1 = await deleter.nodes.Clip.create( + { slug: "c1" }, + { id: "clip1", partOf: { whole: show } }, + ); + + const deletedPart = createGate(); + const release = createGate(); + const partDeletion = partDeleter.transaction(async (tx) => { + await tx.nodes.Clip.delete(clip1.id); + deletedPart.open(); + await release.opened; + }); + await deletedPart.opened; + + const wholeDeletion = deleteWhole(deleter, show.id); + const wholeOutcome = wholeDeletion.then( + (result) => result, + (error: unknown) => error, + ); + expect(await settledOrWaitingOnRowLock(live.observer, wholeDeletion)).toBe( + "waiting", + ); + release.open(); + await partDeletion; + return wholeOutcome; +} + +describe.runIf(process.env["POSTGRES_URL"])( + "composition cascade against a concurrent writer (PostgreSQL)", + () => { + it.each(["repeatable_read", "serializable"] as const)( + "fails a %s whole delete that waited on the fence while a part was attached", + { timeout: CONTENTION_TIMEOUT_MS }, + async (isolationLevel) => { + const live = requirePostgres(); + const deleter = createStore(graph, createPostgresBackend(live.first)); + const attacher = createStore(graph, createPostgresBackend(live.second)); + const show = await deleter.nodes.Show.create({}, { id: "show" }); + + const attached = createGate(); + const release = createGate(); + let clipId: NodeId | undefined; + const attach = attacher.transaction(async (tx) => { + const clip = await tx.nodes.Clip.create( + { slug: "c1" }, + { id: "clip", partOf: { whole: show } }, + ); + clipId = clip.id; + attached.open(); + await release.opened; + }); + await attached.opened; + + const deletion = deleter.transaction( + (tx) => tx.nodes.Show.delete(show.id), + { isolationLevel }, + ); + // Observed before any await below can let it reject unhandled. + const deletionOutcome = deletion.then( + () => "committed" as const, + (error: unknown) => error, + ); + await untilSessionWaitsOnFence(live.observer); + release.open(); + await attach; + + // MUTATION CHECK: acquiring the fence without advancing its fence + // row (`acquireKeyedWithIsolation` in `acquireRecordedGraphWriteLock`) + // turns this into a `COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT` + // refusal, and additionally answering `true` for a snapshot isolation + // in `observesPostFenceCommits` lets the delete commit, leaving the + // show tombstoned under a live required clip — verified and reverted. + const outcome = await deletionOutcome; + expect(outcome).toBeInstanceOf(TransactionConflictError); + expect(isSerializationFailure(outcome)).toBe(true); + expect(await deleter.nodes.Show.getById(show.id)).toBeDefined(); + expect( + await deleter.nodes.Clip.getById(requireDefined(clipId)), + ).toBeDefined(); + expect(await deleter.verifyConstraintFences()).toEqual([]); + }, + ); + + it( + "holds the per-graph write lock from a transaction's planDelete to its delete, so no part is attached in between", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const deleter = createStore(graph, createPostgresBackend(live.first)); + const attacher = createStore(graph, createPostgresBackend(live.second)); + const show = await deleter.nodes.Show.create({}, { id: "show" }); + await deleter.nodes.Clip.create( + { slug: "c0" }, + { id: "clip0", partOf: { whole: show } }, + ); + + const planned = createGate(); + const proceed = createGate(); + const deletion = deleter.transactionWithReceipt(async (tx) => { + const plan = await tx.nodes.Show.planDelete(show.id); + planned.open(); + await proceed.opened; + await tx.nodes.Show.delete(show.id); + return requireDefined(plan); + }); + await planned.opened; + + const attach = attacher.nodes.Clip.create( + { slug: "c1" }, + { id: "clip1", partOf: { whole: show } }, + ); + const attachOutcome = attach.then( + () => "committed" as const, + (error: unknown) => error, + ); + // MUTATION CHECK: plan with `uncapturedGraphWriteLock()` on the + // transactional branch of `executeNodeDeletePlan` + // (src/store/operations/node-operations.ts) and the attach commits + // here instead of waiting, so the race answers "attach finished" — + // verified and reverted. + const firstToSettle = await Promise.race([ + untilSessionWaitsOnFence(live.observer).then( + () => "attach waiting" as const, + ), + attachOutcome.then(() => "attach finished" as const), + ]); + expect(firstToSettle).toBe("attach waiting"); + + proceed.open(); + const { result: plan, receipt } = await deletion; + expect(plan.cascadedParts.map((part) => part.id)).toEqual(["clip0"]); + expect(receipt.cascadedParts).toEqual(plan.cascadedParts); + // The attach ran only after the delete committed, and found its whole + // gone. + expect(await attachOutcome).toBeInstanceOf(Error); + expect( + await deleter.nodes.Clip.getById("clip1" as never), + ).toBeUndefined(); + }, + ); + + it( + "refuses a guarded delete when a part is attached by a fence holder the delete waited on", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const deleter = createStore(graph, createPostgresBackend(live.first)); + const attacher = createStore(graph, createPostgresBackend(live.second)); + const show = await deleter.nodes.Show.create({}, { id: "show" }); + await deleter.nodes.Clip.create( + { slug: "c0" }, + { id: "clip0", partOf: { whole: show } }, + ); + const plan = requireDefined( + await deleter.nodes.Show.planDelete(show.id), + ); + + const attached = createGate(); + const release = createGate(); + const attach = attacher.transaction(async (tx) => { + await tx.nodes.Clip.create( + { slug: "c1" }, + { id: "clip1", partOf: { whole: show } }, + ); + attached.open(); + await release.opened; + }); + await attached.opened; + + const deletionOutcome = deleter.nodes.Show.delete(show.id, { + expect: plan, + }).then( + () => "committed" as const, + (error: unknown) => error, + ); + await untilSessionWaitsOnFence(live.observer); + release.open(); + await attach; + + // MUTATION CHECK: compare against an unlocked + // `planLiveNodeDelete(..., uncapturedGraphWriteLock())` read right + // after the gate in `executeNodeDelete` + // (src/store/operations/node-operations.ts), instead of the plan read + // in the frame, and the delete commits, cascading through clip1, the + // part attached after the preview — verified and reverted. + const outcome = await deletionOutcome; + expect(outcome).toBeInstanceOf(NodeDeletePlanMismatchError); + expect((outcome as NodeDeletePlanMismatchError).details).toMatchObject({ + situation: "changed", + addedParts: [ + { + kind: "Clip", + id: "clip1", + whole: { kind: "Show", id: "show" }, + viaEdgeKind: "clipOf", + }, + ], + droppedParts: [], + }); + expect(await deleter.nodes.Show.getById(show.id)).toBeDefined(); + expect( + await deleter.nodes.Clip.getById("clip0" as never), + ).toBeDefined(); + expect( + await deleter.nodes.Clip.getById("clip1" as never), + ).toBeDefined(); + }, + ); + + it( + "refuses a guarded whole delete whose expected part was deleted while the cascade waited on its lock", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + // MUTATION CHECK: read the members' liveness lock-free + // (`readCascadeMemberRows`) in `planCascadingNodeDelete` + // (src/store/operations/node-operations.ts) and lock them afterwards + // — the delete then commits against the stale plan instead of + // refusing, and reports clip1 as cascaded although its own delete + // removed it. + const outcome = await raceCascadeAgainstPartDelete( + async (deleter, showId) => { + const plan = requireDefined( + await deleter.nodes.Show.planDelete(showId), + ); + expect(plan.cascadedParts.map((part) => part.id)).toEqual([ + "clip0", + "clip1", + ]); + return deleter.nodes.Show.delete(showId, { expect: plan }); + }, + ); + expect(outcome).toBeInstanceOf(NodeDeletePlanMismatchError); + expect((outcome as NodeDeletePlanMismatchError).details).toMatchObject({ + situation: "changed", + addedParts: [], + droppedParts: [ + { + kind: "Clip", + id: "clip1", + whole: { kind: "Show", id: "show" }, + viaEdgeKind: "clipOf", + }, + ], + }); + const observer = createStore( + graph, + createPostgresBackend(requirePostgres().first), + ); + expect( + await observer.nodes.Show.getById("show" as never), + ).toBeDefined(); + expect( + await observer.nodes.Clip.getById("clip0" as never), + ).toBeDefined(); + }, + ); + + it( + "an unguarded whole delete that waited on a concurrently deleted part's lock reports and deletes only the parts left", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + // The end-to-end outcome only: the locked liveness read and the + // receipt's derivation from the members actually deleted each produce + // it on their own (the guarded-delete case above pins the read). + const outcome = await raceCascadeAgainstPartDelete((deleter, showId) => + deleter.transactionWithReceipt((tx) => tx.nodes.Show.delete(showId)), + ); + const { receipt } = outcome as Awaited< + ReturnType< + ReturnType< + typeof createStore + >["transactionWithReceipt"] + > + >; + expect(receipt.cascadedParts.map((part) => part.id)).toEqual(["clip0"]); + const observer = createStore( + graph, + createPostgresBackend(requirePostgres().first), + ); + expect( + await observer.nodes.Show.getById("show" as never), + ).toBeUndefined(); + expect( + await observer.nodes.Clip.getById("clip0" as never), + ).toBeUndefined(); + expect(await observer.verifyConstraintFences()).toEqual([]); + }, + ); + + it( + "makes a concurrent update of a cascade member wait for the delete, then find the member gone", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const { deleter, planned, resume } = createPausedDeleter(live.first); + const updater = createStore(graph, createPostgresBackend(live.second)); + const show = await updater.nodes.Show.create({}, { id: "show" }); + const other = await updater.nodes.Show.create({}, { id: "other" }); + const clip = await updater.nodes.Clip.create( + { slug: "old" }, + { id: "clip", partOf: { whole: show } }, + ); + + // Parked after the cascade planned and locked its members, before + // any row is written. + const deletion = deleter.nodes.Show.delete(show.id); + await planned.opened; + const update = updater.nodes.Clip.update(clip.id, { slug: "new" }); + + // MUTATION CHECK: drop the `lockCascadeMembers` call from + // `planCascadingNodeDelete` (src/store/operations/node-operations.ts) + // — the update then commits while the delete is parked, instead of + // waiting on the member's delete lock. + expect(await settledOrWaitingOnRowLock(live.observer, update)).toBe( + "waiting", + ); + resume.open(); + await deletion; + await expect(update).rejects.toBeInstanceOf(NodeNotFoundError); + + expect(await updater.nodes.Clip.getById(clip.id)).toBeUndefined(); + await expect( + updater.nodes.Clip.create( + { slug: "old" }, + { id: "clip2", partOf: { whole: other } }, + ), + ).resolves.toMatchObject({ id: "clip2" }); + }, + ); + + it( + "makes a concurrent update of a directly deleted node wait for the delete, then find it gone", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const { deleter, planned, resume } = createPausedDeleter(live.first); + const updater = createStore(graph, createPostgresBackend(live.second)); + const show = await updater.nodes.Show.create({}, { id: "show" }); + const clip = await updater.nodes.Clip.create( + { slug: "old" }, + { id: "clip", partOf: { whole: show } }, + ); + + // Parked after the delete locked and read its row, before the + // tombstone. + const deletion = deleter.nodes.Clip.delete(clip.id); + await planned.opened; + const update = updater.nodes.Clip.update(clip.id, { slug: "new" }); + + expect(await settledOrWaitingOnRowLock(live.observer, update)).toBe( + "waiting", + ); + resume.open(); + await deletion; + await expect(update).rejects.toBeInstanceOf(NodeNotFoundError); + + expect(await updater.nodes.Clip.getById(clip.id)).toBeUndefined(); + await expect( + updater.nodes.Clip.create( + { slug: "old" }, + { id: "clip2", partOf: { whole: show } }, + ), + ).resolves.toMatchObject({ id: "clip2" }); + }, + ); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-composition-detach-fence.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-composition-detach-fence.test.ts new file mode 100644 index 000000000..a01ceb11c --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-composition-detach-fence.test.ts @@ -0,0 +1,227 @@ +/** + * A detach's "is this required part still live" verdict holds against a + * concurrent restore, on a second, genuinely concurrent PostgreSQL connection. + * + * The verdict reads the part row, which no claim row records, so it is a + * lock-only decision. At `REPEATABLE READ` and `SERIALIZABLE`, a detach whose + * snapshot was pinned before a restore committed would otherwise read the part + * as still tombstoned and separate it from its whole; it must fail at the + * fence instead. + * + * On this backend the detach plan's own first statement takes the per-graph + * fence and advances the graph's fence row, so the stale snapshot fails right + * there. The verdict's own freshness gate only decides on a port whose fence + * advances no fence row (`composition-existence-fresh-snapshot-pglite.test.ts`). + * + * The fixture part is tombstoned directly while its composition edge to the + * live whole survives, as a write outside the store's write path leaves it. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, + type Store, + TransactionConflictError, + type TransactionContext, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; + +const Show = defineNode("Show", { schema: z.object({}) }); +const Clip = defineNode("Clip", { schema: z.object({}) }); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-composition-detach-fence", + nodes: { Show: { type: Show }, Clip: { type: Clip } }, + edges: { + // `oneActive`, so ending the window is a detach too. + clipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + }, + ontology: [partOf(Clip, Show, { via: clipOf, existence: "required" })], +}); + +type GraphStore = Store; +type WriteSurface = Pick, "edges">; + +const SHOW = "show"; +const CLIP = "clip"; +const edgeId = asEdgeId("clip-of"); + +const DETACHES: readonly Readonly<{ + name: string; + run: (surface: WriteSurface) => Promise; +}>[] = [ + { + name: "edge delete", + run: (surface) => surface.edges.clipOf.delete(edgeId), + }, + { + name: "edge hardDelete", + run: (surface) => surface.edges.clipOf.hardDelete(edgeId), + }, + { + name: "edge bulkDelete", + run: (surface) => surface.edges.clipOf.bulkDelete([edgeId]), + }, + { + name: "edge window end", + run: (surface) => + surface.edges.clipOf.update( + edgeId, + {}, + { validTo: new Date().toISOString() }, + ), + }, +]; + +const ISOLATION_LEVELS = ["repeatable_read", "serializable"] as const; + +let restorePool: Pool | undefined; +let detachPool: Pool | undefined; +let stores: + Readonly<{ restoreStore: GraphStore; detachStore: GraphStore }> | undefined; + +function requireLive(): Readonly<{ + restoreStore: GraphStore; + detachStore: GraphStore; + observer: Pool; +}> { + if (stores === undefined || restorePool === undefined) { + throw new Error( + "concurrent-composition-detach-fence: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { ...stores, observer: restorePool }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [createPool(), createPool()] as const; + await runServerSuiteSetup( + "concurrent-composition-detach-fence", + pools, + async () => { + const [first, second] = pools; + await first.query(generatePostgresMigrationSQL()); + restorePool = first; + detachPool = second; + const [restoreStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(first)), + ); + const [detachStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(second)), + ); + stores = { restoreStore, detachStore }; + }, + ); +}); + +afterAll(async () => { + for (const pool of [restorePool, detachPool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (restorePool === undefined) return; + const { restoreStore, observer } = requireLive(); + await observer.query( + "TRUNCATE typegraph_edge_claims, typegraph_node_uniques, typegraph_edges, typegraph_nodes", + ); + await restoreStore.nodes.Show.create({}, { id: SHOW }); + await restoreStore.nodes.Clip.create( + {}, + { id: CLIP, partOf: { whole: { kind: "Show", id: SHOW } } }, + ); + const renamed = await observer.query( + "UPDATE typegraph_edges SET id = $1 WHERE kind = 'clipOf' AND from_id = $2", + [edgeId, CLIP], + ); + const tombstoned = await observer.query( + "UPDATE typegraph_nodes SET deleted_at = now() WHERE kind = 'Clip' AND id = $1", + [CLIP], + ); + if (renamed.rowCount !== 1 || tombstoned.rowCount !== 1) { + throw new Error( + "concurrent-composition-detach-fence: the tombstoned-part fixture did not apply.", + ); + } +}); + +describe.runIf(process.env["POSTGRES_URL"])( + "a required part's detach against a concurrent restore (PostgreSQL)", + () => { + // MUTATION CHECK: make `edgeDetachNeedsConstraintFence` + // (src/store/constraints.ts) answer `undefined` — every detach then + // commits from its stale snapshot and leaves the restored clip with no + // whole. + for (const isolationLevel of ISOLATION_LEVELS) { + for (const detach of DETACHES) { + it( + `${detach.name} fails a ${isolationLevel} snapshot that predates the restore`, + async () => { + const live = requireLive(); + const outcome = await live.detachStore + .transaction( + async (tx) => { + // Pins the snapshot before the restore commits. + await tx.nodes.Show.getById(SHOW as never); + await live.restoreStore.nodes.Clip.upsertById(CLIP, {}); + await detach.run(tx); + }, + { isolationLevel }, + ) + .then( + () => "committed" as const, + (error: unknown) => error, + ); + + expect(outcome).toBeInstanceOf(TransactionConflictError); + expect( + await live.restoreStore.nodes.Clip.getById(CLIP as never), + ).toBeDefined(); + expect( + await live.restoreStore.edges.clipOf.getById(edgeId), + ).toMatchObject({ id: edgeId }); + expect(await live.restoreStore.verifyConstraintFences()).toEqual( + [], + ); + }, + CASE_TIMEOUT_MS, + ); + } + } + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-composition-required-part.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-composition-required-part.test.ts new file mode 100644 index 000000000..2a0e4fdfa --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-composition-required-part.test.ts @@ -0,0 +1,420 @@ +/** + * Restoring a tombstoned required part and separating it from its whole, + * against a second, genuinely concurrent PostgreSQL connection. + * + * Both decisions read what no claim row records: a restore reads the part's + * surviving composition edge and its whole, and a detach (an edge delete, a + * hard delete, a window end, or the whole's own delete) reads whether the part + * is live. On a default PostgreSQL store neither read takes a row lock that + * the other write conflicts with, so without the per-graph write fence each + * writer judges the committed state the other is about to change, both + * commit, and a live required part is left with no live whole. + * + * The fixture is a part whose tombstone outlived its whole's attachment: the + * part row is tombstoned directly, as a write outside the store's write path + * (or a provenance currency close) leaves it, and its composition edge to the + * live whole survives. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + asNodeId, + CompositionExistenceError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, + type Store, + type TransactionContext, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { requireDefined } from "../../../src/utils/presence"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; +/** How long the second writer may take to either settle or park on a lock. */ +const SETTLE_OR_BLOCK_DEADLINE_MS = 5000; +const POLL_INTERVAL_MS = 10; + +const Show = defineNode("Show", { schema: z.object({ name: z.string() }) }); +const Clip = defineNode("Clip", { schema: z.object({ name: z.string() }) }); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-composition-required-part", + nodes: { + Show: { type: Show }, + Clip: { type: Clip }, + }, + edges: { + // `oneActive`, so ending the window is a detach too. + clipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + }, + ontology: [partOf(Clip, Show, { via: clipOf, existence: "required" })], +}); + +type GraphStore = Store; +type WriteSurface = Pick, "edges" | "nodes">; + +const SHOW = "show"; +const CLIP = "clip"; +const EDGE = "clip-of"; + +const show = { kind: "Show", id: asNodeId(SHOW) } as const; +const edgeId = asEdgeId(EDGE); + +type Write = Readonly<{ + name: string; + run: (surface: WriteSurface) => Promise; +}>; + +const RESTORES: readonly Write[] = [ + { + name: "upsertById", + run: (surface) => surface.nodes.Clip.upsertById(CLIP, { name: "Back" }), + }, + { + name: "bulkUpsertById", + run: (surface) => + surface.nodes.Clip.bulkUpsertById([ + { id: CLIP, props: { name: "Back" } }, + ]), + }, + { + name: "bulkReplaceById", + run: (surface) => + surface.nodes.Clip.bulkReplaceById([ + { id: CLIP, props: { name: "Back" } }, + ]), + }, +]; + +const DETACHES: readonly Readonly[] = [ + { + name: "edge delete", + separatesByEdge: true, + run: (surface) => surface.edges.clipOf.delete(edgeId), + }, + { + name: "edge hardDelete", + separatesByEdge: true, + run: (surface) => surface.edges.clipOf.hardDelete(edgeId), + }, + { + name: "edge bulkDelete", + separatesByEdge: true, + run: (surface) => surface.edges.clipOf.bulkDelete([edgeId]), + }, + { + name: "edge window end", + separatesByEdge: true, + run: (surface) => + surface.edges.clipOf.update( + edgeId, + {}, + { validTo: new Date().toISOString() }, + ), + }, + { + name: "whole delete", + separatesByEdge: false, + run: (surface) => surface.nodes.Show.delete(show.id), + }, +]; + +let restorePool: Pool | undefined; +let detachPool: Pool | undefined; +let probePool: Pool | undefined; +let stores: + Readonly<{ restoreStore: GraphStore; detachStore: GraphStore }> | undefined; + +function requireLive(): Readonly<{ + restoreStore: GraphStore; + detachStore: GraphStore; + probe: Pool; +}> { + if (stores === undefined || probePool === undefined) { + throw new Error( + "concurrent-composition-required-part: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { ...stores, probe: probePool }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [createPool(), createPool(), createPool()] as const; + await runServerSuiteSetup( + "concurrent-composition-required-part", + pools, + async () => { + const [first, second, probe] = pools; + await probe.query(generatePostgresMigrationSQL()); + restorePool = first; + detachPool = second; + probePool = probe; + const [restoreStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(first)), + ); + const [detachStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(second)), + ); + stores = { restoreStore, detachStore }; + }, + ); +}); + +afterAll(async () => { + for (const pool of [restorePool, detachPool, probePool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (probePool === undefined) return; + await probePool.query( + "TRUNCATE typegraph_edge_claims, typegraph_node_uniques, typegraph_edges, typegraph_nodes", + ); + const { restoreStore: setup, probe } = requireLive(); + await setup.nodes.Show.create({ name: "Show" }, { id: SHOW }); + await setup.nodes.Clip.create( + { name: "Clip" }, + { id: CLIP, partOf: { whole: show } }, + ); + const attached = await probe.query( + "UPDATE typegraph_edges SET id = $1 WHERE kind = 'clipOf' AND from_id = $2", + [EDGE, CLIP], + ); + const tombstoned = await probe.query( + "UPDATE typegraph_nodes SET deleted_at = now() WHERE kind = 'Clip' AND id = $1", + [CLIP], + ); + if (attached.rowCount !== 1 || tombstoned.rowCount !== 1) { + throw new Error( + "concurrent-composition-required-part: the tombstoned-part fixture did not apply.", + ); + } +}); + +type Deferred = Readonly<{ promise: Promise; resolve: () => void }>; + +function deferred(): Deferred { + const handle: { resolve?: () => void } = {}; + const promise = new Promise((settle) => { + handle.resolve = settle; + }); + return { + promise, + resolve: () => { + requireDefined(handle.resolve)(); + }, + }; +} + +type Outcome = Readonly< + { status: "ok" } | { status: "error"; error: unknown; name: string } +>; + +async function settle(operation: Promise): Promise { + try { + await operation; + return { status: "ok" }; + } catch (error) { + return { + status: "error", + error, + name: error instanceof Error ? error.name : String(error), + }; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Whether the second writer settled on its own or parked on a lock held by the + * first writer's open transaction. Only the two stores' connections run in this + * suite's database, so any lock waiter is the second writer. + */ +async function settledOrBlocked( + probe: Pool, + operation: Promise, +): Promise<"settled" | "blocked"> { + const state = { settled: false }; + void operation.then(() => { + state.settled = true; + }); + const deadline = Date.now() + SETTLE_OR_BLOCK_DEADLINE_MS; + while (Date.now() < deadline) { + if (state.settled) return "settled"; + const waiters = await probe.query<{ waiters: number }>( + `SELECT count(*)::int AS waiters FROM pg_stat_activity + WHERE datname = current_database() + AND pid <> pg_backend_pid() + AND wait_event_type = 'Lock'`, + ); + if ((waiters.rows[0]?.waiters ?? 0) > 0) return "blocked"; + await sleep(POLL_INTERVAL_MS); + } + throw new Error( + "The second writer neither settled nor waited on a lock before the deadline.", + ); +} + +type Verdict = Readonly<{ + second: "settled" | "blocked"; + restore: string; + detach: string; + violations: readonly string[]; +}>; + +function describeOutcome(outcome: Outcome): string { + return outcome.status === "ok" ? "ok" : outcome.name; +} + +async function verdictOf( + second: "settled" | "blocked", + restoreOutcome: Outcome, + detachOutcome: Outcome, +): Promise { + const violations = await requireLive().restoreStore.verifyConstraintFences(); + return { + second, + restore: describeOutcome(restoreOutcome), + detach: describeOutcome(detachOutcome), + violations: violations.map((violation) => violation.family), + }; +} + +/** + * `first` runs inside a transaction held open on a gate; `second` starts on the + * other connection; then `first` commits. + */ +async function race( + first: Readonly<{ store: GraphStore; write: Write }>, + second: Readonly<{ store: GraphStore; write: Write }>, +): Promise< + Readonly<{ + second: "settled" | "blocked"; + first: Outcome; + secondOutcome: Outcome; + }> +> { + const live = requireLive(); + const wrote = deferred(); + const release = deferred(); + const firstOperation = settle( + first.store.transaction(async (tx) => { + await first.write.run(tx); + wrote.resolve(); + await release.promise; + }), + ); + await Promise.race([wrote.promise, firstOperation]); + const secondOperation = settle( + second.store.transaction((tx) => second.write.run(tx)), + ); + const waited = await settledOrBlocked(live.probe, secondOperation); + release.resolve(); + const [firstOutcome, secondOutcome] = await Promise.all([ + firstOperation, + secondOperation, + ]); + return { second: waited, first: firstOutcome, secondOutcome }; +} + +async function raceRestoreFirst( + restore: Write, + detach: Write, +): Promise { + const live = requireLive(); + const outcome = await race( + { store: live.restoreStore, write: restore }, + { store: live.detachStore, write: detach }, + ); + return verdictOf(outcome.second, outcome.first, outcome.secondOutcome); +} + +async function raceDetachFirst( + restore: Write, + detach: Write, +): Promise { + const live = requireLive(); + const outcome = await race( + { store: live.detachStore, write: detach }, + { store: live.restoreStore, write: restore }, + ); + return verdictOf(outcome.second, outcome.secondOutcome, outcome.first); +} + +describe.runIf(process.env["POSTGRES_URL"])( + "restoring a required part against a concurrent detach (PostgreSQL)", + () => { + // MUTATION CHECK: answer `undefined` from + // `nodeRestoreNeedsConstraintFence` and `edgeDetachNeedsConstraintFence` + // (src/store/constraints.ts). Every edge-detach case then lets both + // writers commit without waiting (`second: "settled"`) and leaves the + // restored clip with no live whole (`violations: + // ["compositionExistence"]`); the whole-delete cases do the same, since + // the whole's fence alone does not exclude an unfenced restore. + for (const restore of RESTORES) { + for (const detach of DETACHES) { + it( + `${restore.name} first, ${detach.name} second: the detach waits and sees the restored part`, + async () => { + expect(await raceRestoreFirst(restore, detach)).toEqual({ + second: "blocked", + restore: "ok", + // A whole's delete cascades to the restored part; an edge + // detach of a live required part is refused. + detach: + detach.separatesByEdge ? CompositionExistenceError.name : "ok", + violations: [], + }); + }, + CASE_TIMEOUT_MS, + ); + + it( + `${detach.name} first, ${restore.name} second: the restore waits and refuses the detached part`, + async () => { + expect(await raceDetachFirst(restore, detach)).toEqual({ + second: "blocked", + restore: CompositionExistenceError.name, + detach: "ok", + violations: [], + }); + }, + CASE_TIMEOUT_MS, + ); + } + } + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-composition-restore-fence.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-composition-restore-fence.test.ts new file mode 100644 index 000000000..a2e9f09aa --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-composition-restore-fence.test.ts @@ -0,0 +1,256 @@ +/** + * A required part's restore verdict holds against a concurrent detach however + * the restoring frame's plan was named, on a second, genuinely concurrent + * PostgreSQL connection. + * + * A frame fenced for another constraint (a shared-scope unique) takes the + * per-graph write fence without advancing the graph's fence row. At + * `REPEATABLE READ`, a restore whose snapshot was pinned before a detach + * committed must still fail at the fence rather than restore the part from + * the stale view of its edge. + * + * The fixture part is tombstoned directly while their composition edges to + * the live whole survive, as a write outside the store's write path (or a + * provenance currency close) leaves it. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, + type Store, + subClassOf, + TransactionConflictError, + type TransactionContext, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; + +const Show = defineNode("Show", { schema: z.object({}) }); +const Clip = defineNode("Clip", { schema: z.object({ name: z.string() }) }); +const SpecialClip = defineNode("SpecialClip", { + schema: z.object({ name: z.string() }), +}); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); + +/** Spans `Clip` and `SpecialClip`, so a `Clip` update names `nodeUniquenessScope`. */ +const CLIP_NAME_UNIQUE = { + name: "clip_name", + fields: ["name"], + scope: "kindWithSubClasses", + collation: "binary", +} as const; + +const graph = defineGraph({ + id: "concurrent-composition-restore-fence", + nodes: { + Show: { type: Show }, + Clip: { type: Clip, unique: [CLIP_NAME_UNIQUE] }, + SpecialClip: { type: SpecialClip, unique: [CLIP_NAME_UNIQUE] }, + }, + edges: { + clipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + }, + ontology: [ + subClassOf(SpecialClip, Clip), + partOf(Clip, Show, { via: clipOf, existence: "required" }), + ], +}); + +type GraphStore = Store; +type WriteSurface = Pick, "nodes">; + +const show = { kind: "Show", id: "show" } as const; + +let restorePool: Pool | undefined; +let detachPool: Pool | undefined; +let stores: + Readonly<{ restoreStore: GraphStore; detachStore: GraphStore }> | undefined; + +function requireLive(): Readonly<{ + restoreStore: GraphStore; + detachStore: GraphStore; + observer: Pool; +}> { + if (stores === undefined || restorePool === undefined) { + throw new Error( + "concurrent-composition-restore-fence: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { ...stores, observer: restorePool }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [createPool(), createPool()] as const; + await runServerSuiteSetup( + "concurrent-composition-restore-fence", + pools, + async () => { + const [first, second] = pools; + await first.query(generatePostgresMigrationSQL()); + restorePool = first; + detachPool = second; + const [restoreStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(first)), + ); + const [detachStore] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(second)), + ); + stores = { restoreStore, detachStore }; + }, + ); +}); + +afterAll(async () => { + for (const pool of [restorePool, detachPool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (restorePool === undefined) return; + await restorePool.query( + "TRUNCATE typegraph_edge_claims, typegraph_node_uniques, typegraph_edges, typegraph_nodes", + ); + await requireLive().restoreStore.nodes.Show.create({}, { id: show.id }); +}); + +/** + * Creates parts attached to the show, renames each part's composition edge to + * `-of`, then tombstones the parts (and releases their unique entries) + * while the edges stay live. + */ +async function seedTombstonedParts( + create: (id: string) => Promise, + ids: readonly string[], +): Promise { + const { observer } = requireLive(); + for (const id of ids) { + await create(id); + await observer.query( + "UPDATE typegraph_edges SET id = $1 || '-of' WHERE from_id = $1", + [id], + ); + } + const tombstoned = await observer.query( + "UPDATE typegraph_nodes SET deleted_at = now() WHERE id = ANY($1)", + [ids], + ); + // A soft delete releases the node's unique entries with it. + await observer.query( + "UPDATE typegraph_node_uniques SET deleted_at = now() WHERE node_id = ANY($1)", + [ids], + ); + if (tombstoned.rowCount !== ids.length) { + throw new Error( + "concurrent-composition-restore-fence: the tombstoned-part fixture did not apply.", + ); + } +} + +const SCOPED_RESTORES: readonly Readonly<{ + name: string; + run: (surface: WriteSurface) => Promise; +}>[] = [ + { + name: "upsertById", + run: (surface) => surface.nodes.Clip.upsertById("clip", { name: "back" }), + }, + { + name: "bulkUpsertById", + run: (surface) => + surface.nodes.Clip.bulkUpsertById([ + { id: "clip", props: { name: "back" } }, + ]), + }, + { + name: "bulkReplaceById", + run: (surface) => + surface.nodes.Clip.bulkReplaceById([ + { id: "clip", props: { name: "back" } }, + ]), + }, +]; + +describe.runIf(process.env["POSTGRES_URL"])( + "a required part's restore against a concurrent detach (PostgreSQL)", + () => { + // MUTATION CHECK: drop the `fenceCompositionExistenceDecision` call from + // `assertRestoredRequiredPartsAttached` + // (src/store/operations/composition-create.ts) AND list the update probe + // before the restore probe in `nodeUpdateConstraintProbes` + // (src/store/operations/node-operations.ts) — every case then commits the + // restore from its stale snapshot and leaves the clip with no live whole. + for (const restore of SCOPED_RESTORES) { + it( + `${restore.name} of a part whose kind declares a shared-scope unique fails a repeatable_read snapshot that predates the detach`, + async () => { + const live = requireLive(); + await seedTombstonedParts( + (id) => + live.restoreStore.nodes.Clip.create( + { name: "c" }, + { id, partOf: { whole: show } }, + ), + ["clip"], + ); + + const outcome = await live.restoreStore + .transaction( + async (tx) => { + // Pins the snapshot before the detach commits. + await tx.nodes.Show.getById(show.id as never); + await live.detachStore.edges.clipOf.delete( + asEdgeId("clip-of"), + ); + await restore.run(tx); + }, + { isolationLevel: "repeatable_read" }, + ) + .then( + () => "committed" as const, + (error: unknown) => error, + ); + + expect(outcome).toBeInstanceOf(TransactionConflictError); + expect( + await live.restoreStore.nodes.Clip.getById("clip" as never), + ).toBeUndefined(); + expect(await live.restoreStore.verifyConstraintFences()).toEqual([]); + }, + CASE_TIMEOUT_MS, + ); + } + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-constraint-fence.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-constraint-fence.test.ts index 452be54d7..1e17d7da4 100644 --- a/packages/typegraph/tests/backends/postgres/concurrent-constraint-fence.test.ts +++ b/packages/typegraph/tests/backends/postgres/concurrent-constraint-fence.test.ts @@ -18,6 +18,10 @@ * rows that can never collide. * - **Disjointness**: a predicate over `(graph_id, id)` ACROSS kinds, while the * nodes primary key is `(graph_id, kind, id)`. + * - **A composition `partOf` postcondition**: `getOrCreateByConstraint` + * decides what an incumbent whole MEANS from a read, and two callers naming + * different wholes for one already-existing part each read "no whole yet". + * The refusal is the application's, not a key's. * * SQLite has always been safe here (`BEGIN IMMEDIATE` admits one writer at a * time) and PostgreSQL was safe only with history or revision tracking on, @@ -30,20 +34,18 @@ * `tests/constraint-write-fence.test.ts` pins the MECHANISM on PGlite — that * the lock is taken, before the probe, and NOT taken by unconstrained writes. * It cannot pin the outcome: PGlite is single-connection and serial, so two - * writers can never actually overlap there. This suite is the other half. It - * runs two independent connections against one database and asserts only - * OUTCOMES — how many callers succeeded and what the loser was told — never - * timing or ordering, because which of the two wins the lock is genuinely - * arbitrary and asserting on it would make the suite flaky by construction. + * writers can never actually overlap there. This suite is the other half: two + * independent connections against one database, with the interleaving FORCED + * by hold-and-gate (`./held-transaction-race.ts`). The holder writes inside a + * transaction it keeps open; the contender starts on the other pool and must be + * observed parked on a lock the holder took before the holder commits. Which + * writer wins is therefore fixed by construction, and each case asserts the + * contender's exact refusal. * - * Against the pre-fix code every case here fails nondeterministically: both - * writers commit, leaving two edges / two live cardinality-`one` edges / two - * sibling rows sharing a unique key / two disjoint-kind rows sharing an id. - * - * Each concurrent pair carries an explicit timeout. A blocked - * `pg_advisory_xact_lock` waits indefinitely, so a lock-order regression would - * otherwise stall the run rather than report; the timeout turns a hang into a - * failure. + * Against the pre-fix code every constrained case here fails + * deterministically: the contender settles while the holder is still open, and + * both commit — two edges / two live cardinality-`one` edges / two sibling rows + * sharing a unique key / two disjoint-kind rows sharing an id. * * Skipped automatically when `POSTGRES_URL` is unset. */ @@ -54,12 +56,15 @@ import { z } from "zod"; import { CardinalityError, + CompositionExistenceError, createStore, defineEdge, defineGraph, defineNode, DisjointError, disjointWith, + partOf, + type Store, subClassOf, UniquenessError, } from "../../../src"; @@ -69,12 +74,22 @@ import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; import { createPostgresBackend } from "../../../src/backend/postgres"; import type { GraphBackend, GraphCommand } from "../../../src/backend/types"; import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { + type HeldRace, + poolApplicationName, + raceAgainstHeldTransaction, + requireError, + requireValue, + summarizeRace, +} from "./held-transaction-race"; import { runServerSuiteSetup } from "./server-suite-setup"; const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); -/** Each concurrent pair: one writer holds the fence while the other waits. */ -const CONTENTION_TIMEOUT_MS = 20_000; +const CASE_TIMEOUT_MS = 20_000; + +const HOLDER_APPLICATION = "concurrent-constraint-fence:holder"; +const CONTENDER_APPLICATION = "concurrent-constraint-fence:contender"; const Person = defineNode("Person", { schema: z.object({ name: z.string() }), @@ -94,10 +109,24 @@ const Contractor = defineNode("Contractor", { schema: z.object({ name: z.string(), email: z.string() }), }); +/** A composition whole, and the part that holds at most one of them. */ +const Shelf = defineNode("Shelf", { schema: z.object({}) }); +const Book = defineNode("Book", { schema: z.object({ slug: z.string() }) }); + +/** The key two racing `getOrCreateByConstraint` calls match the same part on. */ +const BOOK_SLUG_UNIQUE = { + name: "book_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; + /** Cardinality `many`: only `getOrCreateByEndpoints` convergence is at stake. */ const knows = defineEdge("knows", { schema: z.object({ since: z.string() }) }); /** Cardinality `one`: an application count probe with no key behind it. */ const reportsTo = defineEdge("reportsTo", { schema: z.object({}) }); +/** The realizing edge of the `partOf(Book, Shelf)` pair. */ +const shelvedIn = defineEdge("shelvedIn", { schema: z.object({}) }); /** * Declared on every kind in the hierarchy, which is what makes the probe span @@ -118,6 +147,8 @@ const graph = defineGraph({ Worker: { type: Worker, unique: [STAFF_EMAIL_UNIQUE] }, Employee: { type: Employee, unique: [STAFF_EMAIL_UNIQUE] }, Contractor: { type: Contractor, unique: [STAFF_EMAIL_UNIQUE] }, + Shelf: { type: Shelf }, + Book: { type: Book, unique: [BOOK_SLUG_UNIQUE] }, }, edges: { knows: { type: knows, from: [Person], to: [Person] }, @@ -127,28 +158,38 @@ const graph = defineGraph({ to: [Person], cardinality: "one", }, + shelvedIn: { + type: shelvedIn, + from: [Book], + to: [Shelf], + cardinality: "one", + }, }, ontology: [ subClassOf(Employee, Worker), subClassOf(Contractor, Worker), disjointWith(Person, Company), + partOf(Book, Shelf, { via: shelvedIn }), ], }); +type GraphStore = Store; + /** - * TWO pools, not one with a larger `max`. + * Separate pools, not one with a larger `max`. * - * The losing writer parks inside `pg_advisory_xact_lock` while holding its + * The contender parks inside `pg_advisory_xact_lock` while holding its * connection, and it must be impossible for that wait to sit behind the - * winner's own connection in a shared checkout queue — that would be a + * holder's own connection in a shared checkout queue — that would be a * self-deadlock of the test harness rather than a product result. Separate - * pools make the two writers independent by construction. + * pools make the two writers independent by construction. The probe pool + * observes the wait. */ -let firstPool: Pool | undefined; -let secondPool: Pool | undefined; -let firstDb: NodePgDatabase | undefined; -let secondDb: NodePgDatabase | undefined; -let isPostgresAvailable = false; +let holderPool: Pool | undefined; +let contenderPool: Pool | undefined; +let probePool: Pool | undefined; +let holderDb: NodePgDatabase | undefined; +let contenderDb: NodePgDatabase | undefined; /** * The suite is gated on `POSTGRES_URL`, and its setup now FAILS rather than @@ -158,84 +199,93 @@ let isPostgresAvailable = false; * the setup fix exists to remove. */ function requirePostgres(): Readonly<{ - first: NodePgDatabase; - second: NodePgDatabase; + holder: NodePgDatabase; + contender: NodePgDatabase; + probe: Pool; }> { - if (!isPostgresAvailable || firstDb === undefined || secondDb === undefined) { + if ( + holderDb === undefined || + contenderDb === undefined || + probePool === undefined + ) { throw new Error( "concurrent-constraint-fence: PostgreSQL connections are unavailable after setup reported success.", ); } - return { first: firstDb, second: secondDb }; + return { holder: holderDb, contender: contenderDb, probe: probePool }; } -function createPool(): Pool { +function createPool(applicationName?: string): Pool { return new Pool({ connectionString: TEST_DATABASE_URL, connectionTimeoutMillis: 5000, max: 4, + ...(applicationName === undefined ? + {} + : poolApplicationName(applicationName)), }); } beforeAll(async () => { if (!process.env["POSTGRES_URL"]) return; - const first = createPool(); - const second = createPool(); - await runServerSuiteSetup( - "concurrent-constraint-fence", - [first, second], - async () => { - await first.query("SELECT 1"); - await second.query("SELECT 1"); - await first.query(generatePostgresMigrationSQL()); - firstPool = first; - secondPool = second; - firstDb = drizzle(first); - secondDb = drizzle(second); - isPostgresAvailable = true; - }, - ); + const pools = [ + createPool(HOLDER_APPLICATION), + createPool(CONTENDER_APPLICATION), + createPool(), + ] as const; + await runServerSuiteSetup("concurrent-constraint-fence", pools, async () => { + const [holder, contender, probe] = pools; + await holder.query("SELECT 1"); + await contender.query("SELECT 1"); + await probe.query(generatePostgresMigrationSQL()); + holderPool = holder; + contenderPool = contender; + probePool = probe; + holderDb = drizzle(holder); + contenderDb = drizzle(contender); + }); }); afterAll(async () => { - if (firstPool !== undefined) await firstPool.end(); - if (secondPool !== undefined) await secondPool.end(); + for (const pool of [holderPool, contenderPool, probePool]) { + if (pool !== undefined) await pool.end(); + } }); beforeEach(async () => { - if (firstPool === undefined) return; - await firstPool.query( - "TRUNCATE typegraph_node_uniques, typegraph_edges, typegraph_nodes", + if (probePool === undefined) return; + // `typegraph_edge_claims` included: the cardinality-`one` and composition + // cases both reserve a claim row, and a row left behind would refuse the + // same write on the next run against this persistent per-suite database. + await probePool.query( + "TRUNCATE typegraph_node_uniques, typegraph_edge_claims, typegraph_edges, typegraph_nodes", ); }); -type Settled = Readonly<{ - fulfilled: readonly T[]; - rejected: readonly unknown[]; -}>; - -/** - * Splits a settled pair into winners and losers. The assertions are always - * about the COUNTS and the loser's error TYPE — never about which of the two - * stores won, which is arbitrary and would make the case flaky. - */ -function partitionSettled( - results: readonly PromiseSettledResult[], -): Settled { +/** The holder's and contender's stores, each on its own pool. */ +function createRacingStores( + live: ReturnType, +): Readonly<{ holderStore: GraphStore; contenderStore: GraphStore }> { return { - fulfilled: results - .filter((result): result is PromiseFulfilledResult => { - return result.status === "fulfilled"; - }) - .map((result) => result.value), - rejected: results - .filter((result): result is PromiseRejectedResult => { - return result.status === "rejected"; - }) - .map((result): unknown => result.reason), + holderStore: createStore(graph, createPostgresBackend(live.holder)), + contenderStore: createStore(graph, createPostgresBackend(live.contender)), }; } +function raceOnLivePools( + live: ReturnType, + holder: (holdOpen: () => Promise) => Promise, + contender: () => Promise, +): Promise> { + return raceAgainstHeldTransaction({ + probe: live.probe, + holderApplication: HOLDER_APPLICATION, + contenderApplication: CONTENDER_APPLICATION, + holder, + contender, + }); +} + /** Records the transaction-scoped managed plans without changing capability truth. */ function recordManagedPlans( backend: GraphBackend, @@ -268,11 +318,11 @@ describe.runIf(process.env["POSTGRES_URL"])( "constrained writes under genuine contention (PostgreSQL)", () => { it( - "converges two concurrent getOrCreateByEndpoints onto ONE edge", - { timeout: CONTENTION_TIMEOUT_MS }, + "converges two getOrCreateByEndpoints onto ONE edge, the second waiting on the fence", + { timeout: CASE_TIMEOUT_MS }, async () => { const live = requirePostgres(); - const setup = createStore(graph, createPostgresBackend(live.first)); + const setup = createStore(graph, createPostgresBackend(live.holder)); const alice = await setup.nodes.Person.create( { name: "Alice" }, { id: "alice" }, @@ -281,44 +331,49 @@ describe.runIf(process.env["POSTGRES_URL"])( { name: "Bob" }, { id: "bob" }, ); - - const storeA = createStore(graph, createPostgresBackend(live.first)); - const storeB = createStore(graph, createPostgresBackend(live.second)); + const { holderStore, contenderStore } = createRacingStores(live); // Identical props, so both callers name the SAME match key: converging // on it is the whole contract, and differing props would be two // legitimately different edges under `matchOn`. - const [first, second] = await Promise.all([ - storeA.edges.knows.getOrCreateByEndpoints(alice, bob, { - since: "2024", - }), - storeB.edges.knows.getOrCreateByEndpoints(alice, bob, { - since: "2024", - }), - ]); - - // Both callers hold the same edge... - expect(first.edge.id).toBe(second.edge.id); - // ...exactly one of them made it... - expect( - [first.action, second.action].filter( - (action) => action === "created", - ), - ).toHaveLength(1); - expect( - [first.action, second.action].filter((action) => action === "found"), - ).toHaveLength(1); - // ...and the relation holds one edge, not two. + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + const result = await tx.edges.knows.getOrCreateByEndpoints( + alice, + bob, + { since: "2024" }, + ); + await holdOpen(); + return result; + }), + () => + contenderStore.edges.knows.getOrCreateByEndpoints(alice, bob, { + since: "2024", + }), + ); + + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: "ok", + }); + const created = requireValue(race.holder); + const found = requireValue(race.contender); + expect(created.action).toBe("created"); + expect(found.action).toBe("found"); + expect(found.edge.id).toBe(created.edge.id); expect(await setup.edges.knows.findFrom(alice)).toHaveLength(1); }, ); it( - "admits exactly one of two concurrent cardinality-one creates", - { timeout: CONTENTION_TIMEOUT_MS }, + "refuses the second of two cardinality-one creates", + { timeout: CASE_TIMEOUT_MS }, async () => { const live = requirePostgres(); - const setup = createStore(graph, createPostgresBackend(live.first)); + const setup = createStore(graph, createPostgresBackend(live.holder)); const alice = await setup.nodes.Person.create( { name: "Alice" }, { id: "alice" }, @@ -331,69 +386,83 @@ describe.runIf(process.env["POSTGRES_URL"])( { name: "Carol" }, { id: "carol" }, ); - - const storeA = createStore(graph, createPostgresBackend(live.first)); - const storeB = createStore(graph, createPostgresBackend(live.second)); + const { holderStore, contenderStore } = createRacingStores(live); // Same source, different targets: `one` allows at most one edge of this // kind FROM alice, so the two are in direct conflict. - const { fulfilled, rejected } = partitionSettled( - await Promise.allSettled([ - storeA.edges.reportsTo.create(alice, bob, {}), - storeB.edges.reportsTo.create(alice, carol, {}), - ]), + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.reportsTo.create(alice, bob, {}); + await holdOpen(); + }), + () => contenderStore.edges.reportsTo.create(alice, carol, {}), ); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(rejected[0]).toBeInstanceOf(CardinalityError); - expect(await setup.edges.reportsTo.findFrom(alice)).toHaveLength(1); + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: CardinalityError.name, + }); + const survivors = await setup.edges.reportsTo.findFrom(alice); + expect(survivors.map((edge) => edge.toId)).toEqual([bob.id]); }, ); it( - "admits exactly one of two concurrent sibling-kind inserts sharing a scoped unique key", - { timeout: CONTENTION_TIMEOUT_MS }, + "refuses the second of two sibling-kind inserts sharing a scoped unique key", + { timeout: CASE_TIMEOUT_MS }, async () => { const live = requirePostgres(); - const plansA: GraphCommand[] = []; - const plansB: GraphCommand[] = []; - const storeA = createStore( + const holderPlans: GraphCommand[] = []; + const contenderPlans: GraphCommand[] = []; + const holderStore = createStore( graph, - recordManagedPlans(createPostgresBackend(live.first), plansA), + recordManagedPlans(createPostgresBackend(live.holder), holderPlans), ); - const storeB = createStore( + const contenderStore = createStore( graph, - recordManagedPlans(createPostgresBackend(live.second), plansB), + recordManagedPlans( + createPostgresBackend(live.contender), + contenderPlans, + ), ); // Employee and Contractor are SIBLINGS under Worker. Their uniques rows // are keyed by their own kinds, so the sidecar's primary key cannot // refuse the second — only the serialized probe can. - const { fulfilled, rejected } = partitionSettled( - await Promise.allSettled([ - storeA.nodes.Employee.create({ - name: "Employee", - email: "shared@example.com", + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.nodes.Employee.create({ + name: "Employee", + email: "shared@example.com", + }); + await holdOpen(); }), - storeB.nodes.Contractor.create({ + () => + contenderStore.nodes.Contractor.create({ name: "Contractor", email: "shared@example.com", }), - ]), ); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(rejected[0]).toBeInstanceOf(UniquenessError); - expect((rejected[0] as UniquenessError).details.constraintName).toBe( + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: UniquenessError.name, + }); + const refusal = requireError(race.contender); + expect(refusal).toBeInstanceOf(UniquenessError); + expect((refusal as UniquenessError).details.constraintName).toBe( "staff_email", ); - const employees = await storeA.nodes.Employee.find(); - const contractors = await storeA.nodes.Contractor.find(); - expect(employees.length + contractors.length).toBe(1); - const commands = [...plansA, ...plansB]; + expect(await holderStore.nodes.Employee.find()).toHaveLength(1); + expect(await holderStore.nodes.Contractor.find()).toEqual([]); + const commands = [...holderPlans, ...contenderPlans]; expect(commands).toHaveLength(2); for (const command of commands) { expect(command.kind).toBe("node.create"); @@ -407,50 +476,125 @@ describe.runIf(process.env["POSTGRES_URL"])( ); it( - "admits exactly one of two concurrent disjoint-kind creates sharing an id", - { timeout: CONTENTION_TIMEOUT_MS }, + "refuses the second of two disjoint-kind creates sharing an id", + { timeout: CASE_TIMEOUT_MS }, async () => { const live = requirePostgres(); - const storeA = createStore(graph, createPostgresBackend(live.first)); - const storeB = createStore(graph, createPostgresBackend(live.second)); + const { holderStore, contenderStore } = createRacingStores(live); // `(graph_id, kind, id)` makes these two DIFFERENT rows, so the nodes // primary key is structurally incapable of refusing the second. - const { fulfilled, rejected } = partitionSettled( - await Promise.allSettled([ - storeA.nodes.Person.create({ name: "Person" }, { id: "shared-id" }), - storeB.nodes.Company.create( + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.nodes.Person.create( + { name: "Person" }, + { id: "shared-id" }, + ); + await holdOpen(); + }), + () => + contenderStore.nodes.Company.create( { name: "Company" }, { id: "shared-id" }, ), - ]), ); - expect(fulfilled).toHaveLength(1); - expect(rejected).toHaveLength(1); - expect(rejected[0]).toBeInstanceOf(DisjointError); - - const people = await storeA.nodes.Person.find(); - const companies = await storeA.nodes.Company.find(); - expect(people.length + companies.length).toBe(1); + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: DisjointError.name, + }); + expect(await holderStore.nodes.Person.find()).toHaveLength(1); + expect(await holderStore.nodes.Company.find()).toEqual([]); }, ); it( - "lets two concurrent UNCONSTRAINED writes both succeed", - { timeout: CONTENTION_TIMEOUT_MS }, + "refuses the second of two partOf postconditions naming DIFFERENT wholes", + { timeout: CASE_TIMEOUT_MS }, async () => { - // The control, on OUTCOMES only: the fence must not turn independent - // writes into failures. `knows` is cardinality `many` and a plain - // `create` runs no probe, so both writers must simply succeed. + // The composition counterpart of the cases above, and the half the + // in-process simulation (`tests/composition-attachment-fence.test.ts`) + // cannot reach: that file forces the interleaving by running the + // competing write inside the loser's own read, on one connection. Here + // two independent connections overlap, which is the only way to see + // what the engine actually admits. // - // This case deliberately does NOT claim the lock is absent — nothing - // here observes lock acquisition, and two successes are equally - // consistent with a lock that was taken and released. The deterministic - // absence assertion is `tests/constraint-write-fence.test.ts`, which - // counts the statements themselves on PGlite. + // Both callers match the SAME already-existing, UNATTACHED part, so + // neither creates a row and each one's lookup can legitimately observe + // "no whole yet". Nothing in the schema can refuse the second: the + // decision is "what does the incumbent whole mean", which only a + // verdict re-read under the per-graph fence can answer. + const live = requirePostgres(); + const setup = createStore(graph, createPostgresBackend(live.holder)); + const shelfA = await setup.nodes.Shelf.create({}, { id: "shelf-a" }); + const shelfB = await setup.nodes.Shelf.create({}, { id: "shelf-b" }); + await setup.nodes.Book.create({ slug: "contested" }, { id: "book" }); + const { holderStore, contenderStore } = createRacingStores(live); + + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.nodes.Book.getOrCreateByConstraint( + "book_slug", + { slug: "contested" }, + { partOf: { whole: { kind: "Shelf", id: shelfA.id } } }, + ); + await holdOpen(); + }), + () => + contenderStore.nodes.Book.getOrCreateByConstraint( + "book_slug", + { slug: "contested" }, + { partOf: { whole: { kind: "Shelf", id: shelfB.id } } }, + ), + ); + + // One attachment, one refusal — never two attachments, and never a + // silent move that leaves the contender reporting success. + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: CompositionExistenceError.name, + }); + const refusal = requireError(race.contender); + expect(refusal).toBeInstanceOf(CompositionExistenceError); + const details = (refusal as CompositionExistenceError).details; + expect(details.situation).toBe("existing"); + expect(details.requestedVia).toBe("shelvedIn"); + expect(details.currentWhole).toEqual({ kind: "Shelf", id: shelfA.id }); + expect(details.requestedWhole?.id).toBe(shelfB.id); + + // The part holds the holder's whole, through exactly one edge, with no + // retired window: a move would leave two rows, one of them ended. + const edges = await setup.edges.shelvedIn.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect( + edges.map((edge) => ({ + toId: edge.toId, + validTo: edge.meta.validTo, + })), + ).toEqual([{ toId: shelfA.id, validTo: undefined }]); + }, + ); + + it( + "lets two UNCONSTRAINED writes both succeed without waiting on each other", + { timeout: CASE_TIMEOUT_MS }, + async () => { + // The control: the fence must not turn independent writes into + // failures, nor serialize them. `knows` is cardinality `many` and a + // plain `create` runs no probe, so the contender commits while the + // holder's transaction is still open. The statement-level absence + // assertion is `tests/constraint-write-fence.test.ts`, which counts + // the statements themselves on PGlite. const live = requirePostgres(); - const setup = createStore(graph, createPostgresBackend(live.first)); + const setup = createStore(graph, createPostgresBackend(live.holder)); const alice = await setup.nodes.Person.create( { name: "Alice" }, { id: "alice" }, @@ -463,19 +607,23 @@ describe.runIf(process.env["POSTGRES_URL"])( { name: "Carol" }, { id: "carol" }, ); - - const storeA = createStore(graph, createPostgresBackend(live.first)); - const storeB = createStore(graph, createPostgresBackend(live.second)); - - const { fulfilled, rejected } = partitionSettled( - await Promise.allSettled([ - storeA.edges.knows.create(alice, bob, { since: "a" }), - storeB.edges.knows.create(alice, carol, { since: "b" }), - ]), + const { holderStore, contenderStore } = createRacingStores(live); + + const race = await raceOnLivePools( + live, + (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.knows.create(alice, bob, { since: "a" }); + await holdOpen(); + }), + () => contenderStore.edges.knows.create(alice, carol, { since: "b" }), ); - expect(rejected).toEqual([]); - expect(fulfilled).toHaveLength(2); + expect(summarizeRace(race)).toEqual({ + contenderState: "settled", + holder: "ok", + contender: "ok", + }); expect(await setup.edges.knows.findFrom(alice)).toHaveLength(2); }, ); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-edge-acyclicity.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-edge-acyclicity.test.ts new file mode 100644 index 000000000..dc0f052dc --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-edge-acyclicity.test.ts @@ -0,0 +1,341 @@ +/** + * Edge acyclicity (`acyclic: true`) under GENUINE contention on a real PostgreSQL + * server: two independent connections each try to close the SAME two-node + * cycle from opposite ends. + * + * The reachability probe (`src/store/recursive-cte.ts`'s + * `buildEdgeAcyclicityProbe`) is an APPLICATION probe with no database key + * behind it — the edges table is unique on `(graph_id, id)` only, and a cycle + * is a predicate over the whole relation's current population, not over any + * single row. Nothing stops both of two concurrent writers from probing "no + * cycle yet", both getting a clean answer, and both committing — unless the + * probe and the insert it guards run under one per-graph mutual exclusion. + * `edgeWriteNeedsConstraintFence` reporting `"edgeAcyclicity"` for a + * `cardinality: "many", acyclic: true` edge kind is what makes that fence + * apply here (`CONSTRAINT_FENCE_BACKING.edgeAcyclicity === "lockOnly"`). + * + * ## Why this suite exists alongside the in-process ones + * + * `tests/constraint-write-fence.test.ts` pins the MECHANISM on PGlite — that + * the per-graph lock is taken, before the reachability probe, for an acyclic + * edge create. It cannot pin the OUTCOME: PGlite is single-connection and + * serial, so two writers can never actually overlap there. This suite is the + * other half: two independent connections against one database, with the + * interleaving FORCED by hold-and-gate (`./held-transaction-race.ts`). The + * holder writes inside a transaction it keeps open; the contender starts on + * the other pool and must be observed parked on the holder's lock before the + * holder commits. + * + * Against the pre-fix code (drop `"edgeAcyclicity"` from + * `edgeWriteNeedsConstraintFence`'s acyclic arm) the contender's probe runs + * while the holder is still open, reads "no cycle", and commits: the case + * fails deterministically on `contenderState: "settled"` and a live two-edge + * cycle `a -> b -> a`. + * + * ## A genuine engine cutoff mid-probe + * + * The in-process suite (`tests/edge-acyclicity.test.ts`) proves + * `EdgeAcyclicityIndeterminateError` is reported when the backend's + * `execute` throws a recognized cut-short code, but it SIMULATES that + * throw — it never actually asks a real engine to abandon a running + * statement. This is the one test that does: a third connection holds + * `ACCESS EXCLUSIVE` on the edges table, and the acyclic create runs under + * `SET LOCAL statement_timeout` in its own transaction. The reachability probe + * is the create's first statement to read the edges table, so it parks on that + * lock until PostgreSQL cancels it (`57014 query_canceled`) — however fast or + * loaded the machine is. Asserts `EdgeAcyclicityIndeterminateError` and that + * no row was written; `isStatementCutShortError` classifying `57014` is what + * makes the write path report indeterminate rather than "no cycle" or a raw + * driver error. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { sql } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStore, + createStore, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + EdgeAcyclicityIndeterminateError, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { + poolApplicationName, + raceAgainstHeldTransaction, + summarizeRace, +} from "./held-transaction-race"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; +/** + * The cut-short case's statement budget. Generous for the fence statement + * that precedes the probe; the probe itself waits on a held table lock, so + * no budget is ever enough for it. + */ +const PROBE_STATEMENT_TIMEOUT_MS = 1000; + +const HOLDER_APPLICATION = "concurrent-edge-acyclicity:holder"; +const CONTENDER_APPLICATION = "concurrent-edge-acyclicity:contender"; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); + +/** `cardinality: "many", acyclic: true`: the common acyclic case. */ +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-edge-acyclicity", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + }, +}); + +/** + * Separate pools, not one with a larger `max`. The contender parks inside + * `pg_advisory_xact_lock` while holding its connection, and it must be + * impossible for that wait to sit behind the holder's own connection in a + * shared checkout queue — see `concurrent-constraint-fence.test.ts`'s + * identical rationale. The probe pool observes the wait and holds the table + * lock in the cut-short case. + */ +let holderPool: Pool | undefined; +let contenderPool: Pool | undefined; +let probePool: Pool | undefined; +let holderDb: NodePgDatabase | undefined; +let contenderDb: NodePgDatabase | undefined; + +function requirePostgres(): Readonly<{ + holder: NodePgDatabase; + contender: NodePgDatabase; + probe: Pool; +}> { + if ( + holderDb === undefined || + contenderDb === undefined || + probePool === undefined + ) { + throw new Error( + "concurrent-edge-acyclicity: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { holder: holderDb, contender: contenderDb, probe: probePool }; +} + +function createPool(applicationName?: string): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + ...(applicationName === undefined ? + {} + : poolApplicationName(applicationName)), + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [ + createPool(HOLDER_APPLICATION), + createPool(CONTENDER_APPLICATION), + createPool(), + ] as const; + await runServerSuiteSetup("concurrent-edge-acyclicity", pools, async () => { + const [holder, contender, probe] = pools; + await holder.query("SELECT 1"); + await contender.query("SELECT 1"); + await probe.query(generatePostgresMigrationSQL()); + holderPool = holder; + contenderPool = contender; + probePool = probe; + holderDb = drizzle(holder); + contenderDb = drizzle(contender); + }); +}); + +afterAll(async () => { + for (const pool of [holderPool, contenderPool, probePool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (probePool === undefined) return; + await probePool.query("TRUNCATE typegraph_edges, typegraph_nodes"); +}); + +describe.runIf(process.env["POSTGRES_URL"])( + "edge acyclicity under genuine contention (PostgreSQL)", + () => { + it( + "refuses the second of two creates that close the SAME two-node cycle from opposite ends", + { timeout: CASE_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const setup = createStore(graph, createPostgresBackend(live.holder)); + const a = await setup.nodes.Task.create({ name: "a" }, { id: "a" }); + const b = await setup.nodes.Task.create({ name: "b" }, { id: "b" }); + + const holderStore = createStore( + graph, + createPostgresBackend(live.holder), + ); + const contenderStore = createStore( + graph, + createPostgresBackend(live.contender), + ); + + // The contender's own probe would see no cycle in the population it + // can read while the holder is open. Only the per-graph fence keeps + // that probe from running until the holder has committed. + const race = await raceAgainstHeldTransaction({ + probe: live.probe, + holderApplication: HOLDER_APPLICATION, + contenderApplication: CONTENDER_APPLICATION, + holder: (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.dependsOn.create(a, b); + await holdOpen(); + }), + contender: () => contenderStore.edges.dependsOn.create(b, a), + }); + + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: EdgeAcyclicityError.name, + }); + + // The surviving graph has exactly the holder's edge and no cycle: the + // contender's refusal left nothing behind for a third writer to trip + // over. + expect(await setup.edges.dependsOn.findFrom(a)).toHaveLength(1); + expect(await setup.edges.dependsOn.find({})).toHaveLength(1); + await expect( + setup + .verifyConstraintFences() + .then((violations) => + violations.filter( + (violation) => violation.family === "edgeAcyclicity", + ), + ), + ).resolves.toEqual([]); + }, + ); + + it( + "lets two creates that do NOT close a cycle both commit, the second after waiting on the fence", + { timeout: CASE_TIMEOUT_MS }, + async () => { + // The control: the fence must not turn independent acyclic writes into + // failures just because they share the lock. Both take it, so the + // contender still waits — and then commits. + const live = requirePostgres(); + const setup = createStore(graph, createPostgresBackend(live.holder)); + const a = await setup.nodes.Task.create({ name: "a" }, { id: "a" }); + const b = await setup.nodes.Task.create({ name: "b" }, { id: "b" }); + const c = await setup.nodes.Task.create({ name: "c" }, { id: "c" }); + + const holderStore = createStore( + graph, + createPostgresBackend(live.holder), + ); + const contenderStore = createStore( + graph, + createPostgresBackend(live.contender), + ); + + const race = await raceAgainstHeldTransaction({ + probe: live.probe, + holderApplication: HOLDER_APPLICATION, + contenderApplication: CONTENDER_APPLICATION, + holder: (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.dependsOn.create(a, b); + await holdOpen(); + }), + contender: () => contenderStore.edges.dependsOn.create(a, c), + }); + + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: "ok", + }); + expect(await setup.edges.dependsOn.findFrom(a)).toHaveLength(2); + }, + ); + + it( + "a statement_timeout cutoff mid-probe reports EdgeAcyclicityIndeterminateError and writes nothing", + { timeout: CASE_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const setup = createStore(graph, createPostgresBackend(live.holder)); + const head = await setup.nodes.Task.create( + { name: "head" }, + { id: "head" }, + ); + const tail = await setup.nodes.Task.create( + { name: "tail" }, + { id: "tail" }, + ); + await setup.edges.dependsOn.create(head, tail); + + // `tail -> head` closes a cycle, so a probe that ran to completion + // would refuse it with `EdgeAcyclicityError`; only a cut-short probe + // reports indeterminate. + const adapterStore = createAdapterStore( + graph, + createPostgresBackend(live.holder), + ); + const tableLock = await live.probe.connect(); + try { + await tableLock.query("BEGIN"); + await tableLock.query( + "LOCK TABLE typegraph_edges IN ACCESS EXCLUSIVE MODE", + ); + + await expect( + live.holder.transaction(async (sqlTx) => { + await sqlTx.execute( + sql.raw( + `SET LOCAL statement_timeout = '${String(PROBE_STATEMENT_TIMEOUT_MS)}ms'`, + ), + ); + await adapterStore + .withTransaction(sqlTx) + .edges.dependsOn.create(tail, head); + }), + ).rejects.toThrow(EdgeAcyclicityIndeterminateError); + } finally { + await tableLock.query("ROLLBACK"); + tableLock.release(); + } + + // No row was written: the caller's transaction rolled back on the + // thrown error, and the closing edge never lands. + expect( + await setup.edges.dependsOn.find({ from: tail, to: head }), + ).toEqual([]); + expect(await setup.edges.dependsOn.find({})).toHaveLength(1); + }, + ); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-identity-history.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-identity-history.test.ts new file mode 100644 index 000000000..d4660e790 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-identity-history.test.ts @@ -0,0 +1,207 @@ +/** + * Concurrent identity writers on a `history: true` store: a second session's + * assertion or retraction waits on the first session's open identity + * transaction (observed as a lock wait on the server, not inferred from + * timing), and once both commit the transition log records them in revision + * order and `replay` agrees with `asOfRecorded` membership at every step. + */ +import { + createStoreWithSchema, + defineGraph, + defineNode, +} from "@nicia-ai/typegraph"; +import { createPostgresBackend } from "@nicia-ai/typegraph/adapters/drizzle/postgres"; +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool, type PoolClient } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { + asRecordedInstant, + recordedInstantRevision, +} from "../../../src/core/temporal"; +import { requireDefined } from "../../../src/utils/presence"; +import { + createGate, + raceTimeout, + TIMEOUT_SENTINEL, +} from "../../concurrency-utils"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const databaseUrl = await provisionPostgresTestDatabase(import.meta.url); + +const Person = defineNode("Person", { schema: z.object({}) }); + +const LOCK_WAIT_DEADLINE_MS = 10_000; +const LOCK_WAIT_POLL_MS = 10; + +let pool: Pool | undefined; +let probe: Pool | undefined; + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const candidate = new Pool({ connectionString: databaseUrl, max: 2 }); + const observer = new Pool({ connectionString: databaseUrl, max: 1 }); + await runServerSuiteSetup( + "concurrent identity history", + [candidate, observer], + async () => { + await candidate.query(generatePostgresMigrationSQL()); + }, + ); + pool = candidate; + probe = observer; +}); + +afterAll(async () => { + await pool?.end(); + await probe?.end(); +}); + +async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>( + "SELECT pg_backend_pid() AS pid", + ); + return requireDefined(result.rows[0]).pid; +} + +/** Resolves once session `pid` waits on a lock; fails if `operation` settles first. */ +async function expectLockWait( + pid: number, + operation: Promise, +): Promise { + const state = { settled: false }; + void operation.then( + () => { + state.settled = true; + }, + () => { + state.settled = true; + }, + ); + const deadline = Date.now() + LOCK_WAIT_DEADLINE_MS; + while (Date.now() < deadline) { + expect(state.settled, "the second writer did not wait").toBe(false); + const waiting = await requireDefined(probe).query<{ waiting: boolean }>( + `SELECT wait_event_type = 'Lock' AS waiting + FROM pg_stat_activity WHERE pid = $1`, + [pid], + ); + if (waiting.rows[0]?.waiting === true) return; + await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_POLL_MS)); + } + throw new Error( + `Session ${String(pid)} never waited on a lock within ${String(LOCK_WAIT_DEADLINE_MS)} ms.`, + ); +} + +async function twoSessions(id: string) { + const activePool = requireDefined(pool); + const first = await activePool.connect(); + const second = await activePool.connect(); + const release = () => { + first.release(); + second.release(); + }; + try { + const graph = defineGraph({ + id, + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, + }); + const [holder] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(first)), + { history: true }, + ); + const [waiter] = await createStoreWithSchema( + graph, + createPostgresBackend(drizzle(second)), + { history: true }, + ); + return { holder, waiter, waiterPid: await backendPid(second), release }; + } catch (error) { + release(); + throw error; + } +} + +function ref(id: string) { + return { kind: "Person" as const, id }; +} + +describe.runIf(process.env["POSTGRES_URL"])( + "concurrent identity writers with history", + () => { + it.each(["assertSame", "retract"] as const)( + "orders a waiting %s after the open identity transaction, and replay matches asOfRecorded", + async (waitingWrite) => { + const { holder, waiter, waiterPid, release } = await twoSessions( + `concurrent_identity_history_${waitingWrite}`, + ); + const inside = createGate(); + const commit = createGate(); + let holding: Promise | undefined; + let waiting: Promise | undefined; + try { + for (const id of ["a", "b", "c"]) { + await holder.nodes.Person.create({}, { id }); + } + const seeded = await holder.identity.assertSame(ref("b"), ref("c")); + + holding = holder.transaction(async (tx) => { + await tx.identity.assertSame(ref("a"), ref("b")); + inside.open(); + await commit.opened; + }); + expect(await raceTimeout(inside.opened, 5000)).not.toBe( + TIMEOUT_SENTINEL, + ); + waiting = + waitingWrite === "assertSame" ? + waiter.identity.assertSame(ref("c"), ref("a")) + : waiter.identity.retractAssertion(seeded.assertion.id); + await expectLockWait(waiterPid, waiting); + commit.open(); + await holding; + await waiting; + + const history = await holder.identity.transitionsOf(ref("a")); + const revisions = history.transitions.map((transition) => + recordedInstantRevision(asRecordedInstant(transition.recorded)), + ); + console.info("transition revisions", history.transitions); + expect(revisions).toEqual( + revisions.toSorted((left, right) => left - right), + ); + + const replay = await holder.identity.replay(ref("a")); + expect(replay.steps.length).toBeGreaterThanOrEqual( + waitingWrite === "retract" ? 2 : 1, + ); + for (const step of replay.steps) { + const members = await holder + .asOfRecorded(asRecordedInstant(step.transition.recorded)) + .identity.membersOf(ref("a")); + expect( + step.after.map((member) => member.id).toSorted(), + `replay step at ${step.transition.recorded}`, + ).toEqual(members.map((member) => member.id).toSorted()); + } + const current = await holder.identity.membersOf(ref("a")); + const finalMembers = current.map((member) => member.id).toSorted(); + expect(finalMembers).toEqual( + waitingWrite === "retract" ? ["a", "b"] : ["a", "b", "c"], + ); + } finally { + commit.open(); + await Promise.allSettled([holding, waiting]); + release(); + } + }, + ); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/concurrent-target-cardinality-claim.test.ts b/packages/typegraph/tests/backends/postgres/concurrent-target-cardinality-claim.test.ts new file mode 100644 index 000000000..0324168dc --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/concurrent-target-cardinality-claim.test.ts @@ -0,0 +1,312 @@ +/** + * Issue #610: target-side edge cardinality under + * GENUINE contention on a real PostgreSQL server. + * + * `tests/backends/postgres/concurrent-constraint-fence.test.ts` already pins + * this for the SOURCE axis ("refuses the second of two cardinality-one + * creates") — two writers racing `reportsTo.create(alice, bob)` / + * `reportsTo.create(alice, carol)`, same source, different targets. This file + * is the mirror case the source-only suite cannot exercise: two writers racing + * a DIFFERENT source against the SAME target, on a `targetCardinality: "one"` + * kind. The target-axis claim (`edgeCardinalityAxisReferences`'s `"target"` + * arm) is what makes this decidable at all — without it, both writers' + * `countEdgesAtEndpoint` probes read zero and both commit. + * + * The interleaving is forced by hold-and-gate (`./held-transaction-race.ts`): + * the holder writes inside a transaction it keeps open, and the contender must + * be observed parked on a lock the holder took before the holder commits. + * + * Store built with history and revision tracking DISABLED (`createStore` + * with no options), as the issue requires: the fence must hold on the + * DEFAULT store shape, not only the history-tracked one. + * + * Skipped automatically when `POSTGRES_URL` is unset. + */ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CardinalityError, + createStore, + defineEdge, + defineGraph, + defineNode, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { expectOnlyClaimsOf, readEdgeClaimRows } from "../../edge-claim-rows"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { + poolApplicationName, + raceAgainstHeldTransaction, + requireError, + summarizeRace, +} from "./held-transaction-race"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const CASE_TIMEOUT_MS = 20_000; + +const HOLDER_APPLICATION = "concurrent-target-cardinality-claim:holder"; +const CONTENDER_APPLICATION = "concurrent-target-cardinality-claim:contender"; + +const Person = defineNode("CtcPerson", { + schema: z.object({ name: z.string() }), +}); +const Target = defineNode("CtcTarget", { + schema: z.object({ name: z.string() }), +}); + +/** Target-only constrained: no source-side bound at all. */ +const assignedTo = defineEdge("ctcAssignedTo", { schema: z.object({}) }); +/** Both axes constrained, for the residue case. */ +const ownsBoth = defineEdge("ctcOwnsBoth", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "concurrent-target-cardinality-claim", + nodes: { + CtcPerson: { type: Person }, + CtcTarget: { type: Target }, + }, + edges: { + ctcAssignedTo: { + type: assignedTo, + from: [Person], + to: [Target], + targetCardinality: "one", + }, + ctcOwnsBoth: { + type: ownsBoth, + from: [Person], + to: [Target], + cardinality: "one", + targetCardinality: "one", + }, + }, +}); + +/** + * Separate pools, not one with a larger `max` — see + * `concurrent-constraint-fence.test.ts` for why a shared pool would let the + * contender's wait queue behind the holder's own connection, turning a product + * result into a self-deadlock of the harness. The probe pool observes the + * wait. + */ +let holderPool: Pool | undefined; +let contenderPool: Pool | undefined; +let probePool: Pool | undefined; +let holderDb: NodePgDatabase | undefined; +let contenderDb: NodePgDatabase | undefined; + +function requirePostgres(): Readonly<{ + holder: NodePgDatabase; + contender: NodePgDatabase; + probe: Pool; +}> { + if ( + holderDb === undefined || + contenderDb === undefined || + probePool === undefined + ) { + throw new Error( + "concurrent-target-cardinality-claim: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { holder: holderDb, contender: contenderDb, probe: probePool }; +} + +function createPool(applicationName?: string): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + ...(applicationName === undefined ? + {} + : poolApplicationName(applicationName)), + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const pools = [ + createPool(HOLDER_APPLICATION), + createPool(CONTENDER_APPLICATION), + createPool(), + ] as const; + await runServerSuiteSetup( + "concurrent-target-cardinality-claim", + pools, + async () => { + const [holder, contender, probe] = pools; + await holder.query("SELECT 1"); + await contender.query("SELECT 1"); + await probe.query(generatePostgresMigrationSQL()); + holderPool = holder; + contenderPool = contender; + probePool = probe; + holderDb = drizzle(holder); + contenderDb = drizzle(contender); + }, + ); +}); + +afterAll(async () => { + for (const pool of [holderPool, contenderPool, probePool]) { + if (pool !== undefined) await pool.end(); + } +}); + +beforeEach(async () => { + if (probePool === undefined) return; + await probePool.query( + "TRUNCATE typegraph_edge_claims, typegraph_edges, typegraph_nodes", + ); +}); + +describe.runIf(process.env["POSTGRES_URL"])( + "target-side edge cardinality under genuine contention (PostgreSQL)", + () => { + // MUTATION CHECK: drop the `"target"` arm from + // `edgeCardinalityAxisReferences` (`src/store/claims/edge-claims.ts`). + // The target-only case then takes no claim and no fence, so the contender + // commits while the holder is still open (`contenderState: "settled"`, + // two edges into the target). The two-axis case still waits on its + // source-axis fence, but nothing refuses the second target edge. + it( + "refuses the second of two creates from different sources onto the same target", + { timeout: CASE_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const setup = createStore(graph, createPostgresBackend(live.holder)); + const alice = await setup.nodes.CtcPerson.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await setup.nodes.CtcPerson.create( + { name: "Bob" }, + { id: "bob" }, + ); + const target = await setup.nodes.CtcTarget.create( + { name: "Target" }, + { id: "target" }, + ); + + const holderStore = createStore( + graph, + createPostgresBackend(live.holder), + ); + const contenderStore = createStore( + graph, + createPostgresBackend(live.contender), + ); + + // Different sources, same target: `targetCardinality: "one"` allows + // at most one edge of this kind INTO `target`, so the two are in + // direct conflict on the axis this suite exists to fence. + const race = await raceAgainstHeldTransaction({ + probe: live.probe, + holderApplication: HOLDER_APPLICATION, + contenderApplication: CONTENDER_APPLICATION, + holder: (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.ctcAssignedTo.create(alice, target, {}); + await holdOpen(); + }), + contender: () => + contenderStore.edges.ctcAssignedTo.create(bob, target, {}), + }); + + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: CardinalityError.name, + }); + const refusal = requireError(race.contender); + expect(refusal).toBeInstanceOf(CardinalityError); + expect((refusal as CardinalityError).details.direction).toBe("target"); + + const survivors = await setup.edges.ctcAssignedTo.findTo(target); + expect(survivors.map((edge) => edge.fromId)).toEqual([alice.id]); + }, + ); + + it( + "leaves the holder's row intact when the contender conflicts on a two-axis kind", + { timeout: CASE_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const setupBackend = createPostgresBackend(live.holder); + const setup = createStore(graph, setupBackend); + const alice = await setup.nodes.CtcPerson.create( + { name: "Alice" }, + { id: "alice-both" }, + ); + const bob = await setup.nodes.CtcPerson.create( + { name: "Bob" }, + { id: "bob-both" }, + ); + const target = await setup.nodes.CtcTarget.create( + { name: "Target Both" }, + { id: "target-both" }, + ); + + const holderStore = createStore( + graph, + createPostgresBackend(live.holder), + ); + const contenderStore = createStore( + graph, + createPostgresBackend(live.contender), + ); + + // Both axes constrained: alice and bob are different sources, but + // both race for the SAME target — the target axis is what decides + // this pair, same as the single-axis case above. + const race = await raceAgainstHeldTransaction({ + probe: live.probe, + holderApplication: HOLDER_APPLICATION, + contenderApplication: CONTENDER_APPLICATION, + holder: (holdOpen) => + holderStore.transaction(async (tx) => { + await tx.edges.ctcOwnsBoth.create(alice, target, {}); + await holdOpen(); + }), + contender: () => + contenderStore.edges.ctcOwnsBoth.create(bob, target, {}), + }); + + expect(summarizeRace(race)).toEqual({ + contenderState: "blocked", + holder: "ok", + contender: CardinalityError.name, + }); + + // The holder's row is intact — not just present, but the ONLY row, + // and the contender left no residue (a half-claimed source axis, a + // dangling row) behind its refusal. + const survivors = await setup.edges.ctcOwnsBoth.findTo(target); + expect(survivors.map((edge) => edge.fromId)).toEqual([alice.id]); + // The claim relation holds exactly the holder's two axis rows: the + // contender's source axis is not left claimed. + expectOnlyClaimsOf( + await readEdgeClaimRows(setupBackend, graph.id), + graph.id, + { cardinality: "one", targetCardinality: "one" }, + survivors, + ); + // The contender's own source axis is free again: a fresh, + // otherwise-valid create from bob still succeeds. + const otherTarget = await setup.nodes.CtcTarget.create( + { name: "Other Target" }, + { id: "other-target-both" }, + ); + await expect( + setup.edges.ctcOwnsBoth.create(bob, otherTarget, {}), + ).resolves.toBeDefined(); + }, + ); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/fulltext-gin-language.test.ts b/packages/typegraph/tests/backends/postgres/fulltext-gin-language.test.ts index 70ce7e7f6..526fce7de 100644 --- a/packages/typegraph/tests/backends/postgres/fulltext-gin-language.test.ts +++ b/packages/typegraph/tests/backends/postgres/fulltext-gin-language.test.ts @@ -224,9 +224,12 @@ describe("fulltext GIN index usage (constant declared language)", () => { const backend = createPostgresBackend(drizzle(activePool)); const [store] = await createStoreWithSchema(graph, backend); + // Supertype queries are polymorphic by default, so the single-language + // alias must narrow to the base kind explicitly; the mixed case below is + // what the default now produces. const single = store .query() - .from("GinArticle", "d") + .from("GinArticle", "d", { expansion: "exact" }) .whereNode("d", (document) => document.$fulltext.matches("signal", 10)) .select((sel) => ({ id: sel.d.id })) .toSQL(); @@ -237,7 +240,7 @@ describe("fulltext GIN index usage (constant declared language)", () => { // to the per-row column — a constant would mis-parse one kind. const mixed = store .query() - .from("GinArticle", "d", { includeSubClasses: true }) + .from("GinArticle", "d", { expansion: "subclasses" }) .whereNode("d", (document) => document.$fulltext.matches("signal", 10)) .select((sel) => ({ id: sel.d.id })) .toSQL(); diff --git a/packages/typegraph/tests/backends/postgres/held-transaction-race.ts b/packages/typegraph/tests/backends/postgres/held-transaction-race.ts new file mode 100644 index 000000000..15724d65f --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/held-transaction-race.ts @@ -0,0 +1,220 @@ +/** + * The one idiom for a deterministic two-writer race on a server PostgreSQL + * suite: hold-and-gate. + * + * The HOLDER runs its write inside a transaction and then parks on a gate, + * keeping that transaction — and every lock its write took — open. The + * CONTENDER starts its competing write on another pool. A probe connection + * polls `pg_stat_activity` until the contender either settles on its own or is + * observed waiting on a lock the holder's connection holds; only then does the + * gate open and the holder commit. + * + * The interleaving is therefore forced, never hoped for: which writer wins is + * fixed by construction, and whether the contender had to wait is itself an + * observed fact a case can assert. A fence that is not taken shows up as a + * contender that settles while the holder is still open — not as a race that + * happens to pass. There is no sleep standing in for synchronization: the + * poll interval only paces the probe, and the deadline turns a contender that + * neither settles nor waits into a loud failure. + * + * Holder and contender are told apart by `application_name`, which each + * suite sets per pool (see {@link poolApplicationName}). + */ +import { type Pool, type PoolConfig } from "pg"; + +import { requireDefined } from "../../../src/utils/presence"; + +/** How long the contender may take to either settle or park on a lock. */ +const SETTLE_OR_BLOCK_DEADLINE_MS = 5000; +const POLL_INTERVAL_MS = 10; + +export type Outcome = Readonly< + { status: "ok"; value: T } | { status: "error"; error: unknown } +>; + +/** Whether the contender settled on its own or parked on the holder's lock. */ +type ContenderState = "settled" | "blocked"; + +export type HeldRace = Readonly<{ + contenderState: ContenderState; + holder: Outcome; + contender: Outcome; +}>; + +/** A race reduced to what a case asserts in one `toEqual`. */ +export type RaceSummary = Readonly<{ + contenderState: ContenderState; + holder: string; + contender: string; +}>; + +type Gate = Readonly<{ promise: Promise; open: () => void }>; + +function createGate(): Gate { + const handle: { open?: () => void } = {}; + const promise = new Promise((resolve) => { + handle.open = resolve; + }); + return { + promise, + open: () => { + requireDefined(handle.open)(); + }, + }; +} + +async function settle(operation: Promise): Promise> { + try { + return { status: "ok", value: await operation }; + } catch (error) { + return { status: "error", error }; + } +} + +function pause(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** + * Pool options tagging every connection with `applicationName`, which is how + * the probe tells the holder's and the contender's backends apart. + */ +export function poolApplicationName( + applicationName: string, +): Pick { + return { application_name: applicationName }; +} + +/** + * Whether a backend of `contenderApplication` is waiting on a lock that a + * backend of `holderApplication` holds. `pg_blocking_pids` names the actual + * blocker, so a wait on anything else does not count as the race being + * engaged. + */ +async function contenderIsBlockedByHolder( + probe: Pool, + contenderApplication: string, + holderApplication: string, +): Promise { + const result = await probe.query<{ blocked: boolean }>( + `SELECT EXISTS ( + SELECT 1 + FROM pg_stat_activity AS waiter + JOIN pg_stat_activity AS blocker + ON blocker.pid = ANY (pg_blocking_pids(waiter.pid)) + WHERE waiter.datname = current_database() + AND waiter.application_name = $1 + AND waiter.wait_event_type = 'Lock' + AND blocker.application_name = $2 + ) AS blocked`, + [contenderApplication, holderApplication], + ); + return result.rows[0]?.blocked === true; +} + +async function settledOrBlocked( + probe: Pool, + contenderApplication: string, + holderApplication: string, + contender: Promise, +): Promise { + const state = { settled: false }; + void contender.then(() => { + state.settled = true; + }); + const deadline = Date.now() + SETTLE_OR_BLOCK_DEADLINE_MS; + while (Date.now() < deadline) { + if (state.settled) return "settled"; + if ( + await contenderIsBlockedByHolder( + probe, + contenderApplication, + holderApplication, + ) + ) { + return "blocked"; + } + await pause(POLL_INTERVAL_MS); + } + throw new Error( + `The contender (application_name ${contenderApplication}) neither settled nor waited on a lock held by ${holderApplication} within ${String(SETTLE_OR_BLOCK_DEADLINE_MS)}ms.`, + ); +} + +/** + * Runs `holder` until it calls `holdOpen()` — after its write, inside its + * transaction — then starts `contender` and waits until the contender settles + * or is observed blocked by the holder, and only then lets the holder's + * transaction finish. + * + * Both writers are always awaited to completion before this returns or + * throws, so no transaction outlives the case. + */ +export async function raceAgainstHeldTransaction( + options: Readonly<{ + probe: Pool; + holderApplication: string; + contenderApplication: string; + holder: (holdOpen: () => Promise) => Promise; + contender: () => Promise; + }>, +): Promise> { + const written = createGate(); + const release = createGate(); + const holder = settle( + options.holder(() => { + written.open(); + return release.promise; + }), + ); + await Promise.race([written.promise, holder]); + const contender = settle(options.contender()); + const observation = await settle( + settledOrBlocked( + options.probe, + options.contenderApplication, + options.holderApplication, + contender, + ), + ); + release.open(); + const holderOutcome = await holder; + const contenderOutcome = await contender; + if (observation.status === "error") throw observation.error; + return { + contenderState: observation.value, + holder: holderOutcome, + contender: contenderOutcome, + }; +} + +function outcomeLabel(outcome: Outcome): string { + if (outcome.status === "ok") return "ok"; + return outcome.error instanceof Error ? + outcome.error.name + : String(outcome.error); +} + +export function summarizeRace(race: HeldRace): RaceSummary { + return { + contenderState: race.contenderState, + holder: outcomeLabel(race.holder), + contender: outcomeLabel(race.contender), + }; +} + +/** The value of an outcome a case already asserted succeeded. */ +export function requireValue(outcome: Outcome): T { + if (outcome.status === "error") { + throw new Error("Expected the write to succeed", { cause: outcome.error }); + } + return outcome.value; +} + +/** The error of an outcome a case already asserted failed. */ +export function requireError(outcome: Outcome): unknown { + if (outcome.status === "ok") { + throw new Error("Expected the write to fail, but it succeeded"); + } + return outcome.error; +} diff --git a/packages/typegraph/tests/backends/postgres/managed-tables.ts b/packages/typegraph/tests/backends/postgres/managed-tables.ts new file mode 100644 index 000000000..159ee6076 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/managed-tables.ts @@ -0,0 +1,16 @@ +import { resolveGraphRelationNames } from "../../../src/backend/graph-relations"; + +/** + * Every graph-scoped relation a per-test reset clears, taken from the graph + * relation inventory so a relation added to the schema cannot silently leak + * rows into the next test. The deployment-shared relations (the installation + * marker, graph templates, fences) are outside the inventory and stay in place. + */ +const RESETTABLE_TABLE_NAMES: readonly string[] = Object.values( + resolveGraphRelationNames(undefined), +); + +/** A single TRUNCATE over every resettable graph-scoped relation. */ +export const TRUNCATE_RESETTABLE_TABLES_SQL = `TRUNCATE ${RESETTABLE_TABLE_NAMES.map( + (name) => `"${name}"`, +).join(", ")} CASCADE`; diff --git a/packages/typegraph/tests/backends/postgres/merge-callback-concurrency.test.ts b/packages/typegraph/tests/backends/postgres/merge-callback-concurrency.test.ts index 8febc24c6..f542ecf09 100644 --- a/packages/typegraph/tests/backends/postgres/merge-callback-concurrency.test.ts +++ b/packages/typegraph/tests/backends/postgres/merge-callback-concurrency.test.ts @@ -17,7 +17,7 @@ import { unwrap, } from "@nicia-ai/typegraph/graph-merge"; import { drizzle } from "drizzle-orm/node-postgres"; -import { Pool } from "pg"; +import { Pool, type PoolClient } from "pg"; import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; import { z } from "zod"; @@ -37,24 +37,82 @@ const Resource = defineNode("Resource", { schema: z.object({ owner: z.string() }), }); let pool: Pool | undefined; +/** A connection outside both stores, for observing what their sessions wait on. */ +let probe: Pool | undefined; beforeAll(async () => { if (!process.env["POSTGRES_URL"]) return; const candidate = new Pool({ connectionString: databaseUrl, max: 2 }); - await runServerSuiteSetup("merge callbacks", [candidate], async () => { - await candidate.query(generatePostgresMigrationSQL()); - }); + const observer = new Pool({ connectionString: databaseUrl, max: 1 }); + await runServerSuiteSetup( + "merge callbacks", + [candidate, observer], + async () => { + await candidate.query(generatePostgresMigrationSQL()); + }, + ); pool = candidate; + probe = observer; }); afterAll(async () => { await pool?.end(); + await probe?.end(); }); +const LOCK_WAIT_DEADLINE_MS = 10_000; +const LOCK_WAIT_POLL_MS = 10; + +/** + * Resolves once the session `pid` is parked on a heavyweight lock, and fails + * if `operation` settles first or the deadline passes: "blocked" is read off + * the server's own wait state, never inferred from a promise staying pending + * for a while. + */ +async function expectLockWait( + pid: number, + operation: Promise, +): Promise { + const state = { settled: false }; + void operation.then( + () => { + state.settled = true; + }, + () => { + state.settled = true; + }, + ); + const deadline = Date.now() + LOCK_WAIT_DEADLINE_MS; + while (Date.now() < deadline) { + expect(state.settled, "the operation settled instead of waiting").toBe( + false, + ); + const waiting = await requireDefined(probe).query<{ waiting: boolean }>( + `SELECT wait_event_type = 'Lock' AS waiting + FROM pg_stat_activity WHERE pid = $1`, + [pid], + ); + if (waiting.rows[0]?.waiting === true) return; + await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_POLL_MS)); + } + throw new Error( + `Session ${String(pid)} never waited on a lock within ${String(LOCK_WAIT_DEADLINE_MS)} ms.`, + ); +} + +async function backendPid(client: PoolClient): Promise { + const result = await client.query<{ pid: number }>( + "SELECT pg_backend_pid() AS pid", + ); + return requireDefined(result.rows[0]).pid; +} + async function fixture(id: string, history: boolean) { const activePool = requireDefined(pool); const first = await activePool.connect(); const second = await activePool.connect(); + const targetPid = await backendPid(first); + const writerPid = await backendPid(second); const local = createLocalSqliteBackend(); const graph = defineGraph({ id, @@ -89,6 +147,8 @@ async function fixture(id: string, history: boolean) { writer, resource, plan, + targetPid, + writerPid, close: async () => { await local.backend.close(); first.release(); @@ -147,10 +207,8 @@ describe.runIf(process.env["POSTGRES_URL"])( ); for (const history of [false, true]) { it(`holds the fence before application reads against an ordinary conditional writer (history=${history})`, async () => { - const { target, writer, resource, plan, close } = await fixture( - `callbacks_winner_${history}`, - history, - ); + const { target, writer, writerPid, resource, plan, close } = + await fixture(`callbacks_winner_${history}`, history); const checked = createGate(); const release = createGate(); const applying = applyMergePlan(target, plan, { @@ -173,7 +231,7 @@ describe.runIf(process.env["POSTGRES_URL"])( expected: { owner: "unclaimed" }, patch: { owner: "writer" }, }); - expect(await raceTimeout(competing, 200)).toBe(TIMEOUT_SENTINEL); + await expectLockWait(writerPid, competing); release.open(); unwrap(await applying); expect(await competing).toBe(false); @@ -187,10 +245,8 @@ describe.runIf(process.env["POSTGRES_URL"])( }); it(`refuses a plan made stale by an ordinary writer before invoking checks (history=${history})`, async () => { - const { target, writer, resource, plan, close } = await fixture( - `callbacks_loser_${history}`, - history, - ); + const { target, writer, targetPid, resource, plan, close } = + await fixture(`callbacks_loser_${history}`, history); const written = createGate(); const release = createGate(); const writing = writer.transaction(async (tx) => { @@ -205,7 +261,7 @@ describe.runIf(process.env["POSTGRES_URL"])( TIMEOUT_SENTINEL, ); applying = applyMergePlan(target, plan, { beforeApply }); - expect(await raceTimeout(applying, 200)).toBe(TIMEOUT_SENTINEL); + await expectLockWait(targetPid, applying); expect(beforeApply).not.toHaveBeenCalled(); release.open(); await writing; diff --git a/packages/typegraph/tests/backends/postgres/migrate-recorded-time.test.ts b/packages/typegraph/tests/backends/postgres/migrate-recorded-time.test.ts index 9158d0171..644408a9b 100644 --- a/packages/typegraph/tests/backends/postgres/migrate-recorded-time.test.ts +++ b/packages/typegraph/tests/backends/postgres/migrate-recorded-time.test.ts @@ -13,7 +13,7 @@ import { createPostgresTables, } from "../../../src/backend/postgres"; import { type RecordedTableNames } from "../../../src/backend/types"; -import { shortHash } from "../../../src/query/dialect/vector-strategy"; +import { shortHash } from "../../../src/utils/hash"; import { requireDefined } from "../../../src/utils/presence"; import { provisionPostgresTestDatabase } from "../../postgres-test-database"; diff --git a/packages/typegraph/tests/backends/postgres/mixed-isolation-fence.test.ts b/packages/typegraph/tests/backends/postgres/mixed-isolation-fence.test.ts new file mode 100644 index 000000000..bcc9ec407 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/mixed-isolation-fence.test.ts @@ -0,0 +1,647 @@ +/** + * Lock-only fence decisions under MIXED transaction isolation on a real + * PostgreSQL server. + * + * A reachability probe, a composition-cycle probe and a merge's re-validation + * are all reads with no claim row behind them: the per-graph write fence is + * the only thing that makes them true at commit. A lock alone cannot do that + * for a session whose snapshot predates the fence — a `SERIALIZABLE` or + * `REPEATABLE READ` transaction that read anything before a concurrent + * `READ COMMITTED` writer committed keeps reading the graph as it was, however + * long it then waits for the lock, and PostgreSQL's serialization-failure + * detection only arbitrates serializable transactions against EACH OTHER. + * + * Every case here drives that exact sequence deterministically, with two real + * connections and no timing: the stale session takes its snapshot, the + * read-committed writer commits one half of a cycle, and only then does the + * stale session attempt the other half. Every writer that makes such a + * decision advances the graph's fence row, so the stale session fails on that + * row with a serialization failure instead of deciding on the graph it can no + * longer see. + */ +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStore, + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + partOf, + type Store, + TransactionConflictError, +} from "../../../src"; +import { deriveBackend } from "../../../src/backend/derive-backend"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../../../src/backend/postgres"; +import { createLocalSqliteBackend } from "../../../src/backend/sqlite/local"; +import type { + GraphBackend, + TransactionOptions, +} from "../../../src/backend/types"; +import { branch } from "../../../src/graph-merge/branch"; +import { BaseVersionMismatchError } from "../../../src/graph-merge/errors"; +import { merge } from "../../../src/graph-merge/merge"; +import { isErr, unwrap } from "../../../src/graph-merge/result"; +import { asBranchId } from "../../../src/graph-merge/types"; +import { sql as fragment } from "../../../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../../../src/query/sql-intent"; +import { requireDefined } from "../../../src/utils/presence"; +import { isSerializationFailure } from "../../../src/utils/sql-errors"; +import { createGate } from "../../concurrency-utils"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const STALE_ISOLATION_LEVELS = ["serializable", "repeatable_read"] as const; +type StaleIsolationLevel = (typeof STALE_ISOLATION_LEVELS)[number]; + +/** A writer holds the fence while the stale session parks on it. */ +const CONTENTION_TIMEOUT_MS = 20_000; +const FENCE_WAIT_POLL_MS = 20; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +/** A standalone `acyclic: true` relation: one edge kind, no composition. */ +const acyclicGraph = defineGraph({ + id: "mixed-isolation-acyclic", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + }, +}); + +const assignedTo = defineEdge("assignedTo", { schema: z.object({}) }); +const blocks = defineEdge("blocks", { schema: z.object({}) }); + +/** + * An acyclic relation beside constraints that name another fence reason: + * `assignedTo` is fenced for its cardinality alone and decides nothing under + * the lock, and `blocks` is acyclic but names its cardinality. + */ +const layeredGraph = defineGraph({ + id: "mixed-isolation-layered", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { type: dependsOn, from: [Task], to: [Task], acyclic: true }, + assignedTo: { + type: assignedTo, + from: [Task], + to: [Task], + cardinality: "one", + }, + blocks: { + type: blocks, + from: [Task], + to: [Task], + cardinality: "one", + acyclic: true, + }, + }, +}); + +const Assembly = defineNode("Assembly", { + schema: z.object({ name: z.string() }), +}); +const containedIn = defineEdge("containedIn", { schema: z.object({}) }); + +/** A reflexive composition: an assembly is part of at most one assembly. */ +const compositionGraph = defineGraph({ + id: "mixed-isolation-composition", + nodes: { Assembly: { type: Assembly } }, + edges: { + containedIn: { + type: containedIn, + from: [Assembly], + to: [Assembly], + cardinality: "one", + }, + }, + ontology: [ + partOf(Assembly, Assembly, { via: containedIn, partSide: "from" }), + ], +}); + +let stalePool: Pool | undefined; +let writerPool: Pool | undefined; +let staleDb: NodePgDatabase | undefined; +let writerDb: NodePgDatabase | undefined; + +function requirePostgres(): Readonly<{ + stale: NodePgDatabase; + writer: NodePgDatabase; +}> { + if (staleDb === undefined || writerDb === undefined) { + throw new Error( + "mixed-isolation-fence: PostgreSQL connections are unavailable after setup reported success.", + ); + } + return { stale: staleDb, writer: writerDb }; +} + +function createPool(): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 4, + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const stale = createPool(); + const writer = createPool(); + await runServerSuiteSetup( + "mixed-isolation-fence", + [stale, writer], + async () => { + await stale.query(generatePostgresMigrationSQL()); + await writer.query("SELECT 1"); + stalePool = stale; + writerPool = writer; + staleDb = drizzle(stale); + writerDb = drizzle(writer); + }, + ); +}); + +afterAll(async () => { + if (stalePool !== undefined) await stalePool.end(); + if (writerPool !== undefined) await writerPool.end(); +}); + +beforeEach(async () => { + if (stalePool === undefined) return; + await stalePool.query( + `TRUNCATE typegraph_edges, typegraph_nodes, typegraph_node_uniques, + typegraph_schema_versions, typegraph_recorded_clock, + typegraph_revision_origins`, + ); +}); + +async function cycleViolations( + store: Pick, "verifyConstraintFences">, +): Promise { + const violations = await store.verifyConstraintFences(); + return violations.filter( + (violation) => violation.family === "edgeAcyclicity", + ); +} + +/** + * The failure a caller-owned transaction sees when its snapshot predates the + * fence: typed, and still classified as a serialization failure so a caller's + * own retry loop recognizes it. + */ +function expectStaleSnapshotConflict(error: unknown): void { + expect(error).toBeInstanceOf(TransactionConflictError); + expect(isSerializationFailure(error)).toBe(true); +} + +/** + * Resolves once some session of THIS database is parked waiting for an + * advisory lock. `pg_locks` is cluster-wide, so the database filter keeps a + * suite running against another database from answering for this one. + */ +async function untilSessionWaitsOnFence(observer: Pool): Promise { + for (;;) { + const { rows } = await observer.query<{ waiting: string }>( + `SELECT count(*) AS waiting + FROM pg_locks + WHERE locktype = 'advisory' + AND NOT granted + AND database = ( + SELECT oid FROM pg_database WHERE datname = current_database() + )`, + ); + if (Number(rows[0]?.waiting) > 0) return; + await new Promise((resolve) => setTimeout(resolve, FENCE_WAIT_POLL_MS)); + } +} + +/** + * A backend whose every transaction runs at `isolationLevel`, counting the + * transactions it opens — a deployment whose `default_transaction_isolation` + * is a snapshot isolation, with the store none the wiser. + */ +function atIsolation( + backend: GraphBackend, + isolationLevel: StaleIsolationLevel, +): Readonly<{ backend: GraphBackend; opened: () => number }> { + let opened = 0; + return { + backend: deriveBackend(backend, { + transaction: (run, options) => { + opened += 1; + return backend.transaction(run, { ...options, isolationLevel }); + }, + }), + opened: () => opened, + }; +} + +/** + * The merge commit of a store without recorded capture asks for + * `SERIALIZABLE`. This pins the level under test onto that one transaction + * and leaves every other transaction the merge opens as it asked for it. + */ +function mergeCommitOptionsAt( + isolationLevel: StaleIsolationLevel, + options: TransactionOptions | undefined, +): TransactionOptions | undefined { + return options?.isolationLevel === "serializable" ? + { ...options, isolationLevel } + : options; +} + +async function rejection(work: Promise): Promise { + try { + await work; + } catch (error) { + return error; + } + throw new Error("Expected the stale session's write to be refused."); +} + +describe.runIf(process.env["POSTGRES_URL"])( + "lock-only fence decisions under mixed isolation (PostgreSQL)", + () => { + describe.each(STALE_ISOLATION_LEVELS)( + "a %s session whose snapshot predates a read-committed writer", + (isolationLevel: StaleIsolationLevel) => { + it("cannot close a cycle in a standalone acyclic edge kind", async () => { + const live = requirePostgres(); + const staleStore = createStore( + acyclicGraph, + createPostgresBackend(live.stale), + ); + const writerStore = createStore( + acyclicGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + const error = await rejection( + staleStore.transaction( + async (tx) => { + // Any read pins the snapshot for the rest of the transaction. + await tx.nodes.Task.getById(a.id); + await writerStore.edges.dependsOn.create(a, b); + await tx.edges.dependsOn.create(b, a); + }, + { isolationLevel }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }); + + it("cannot close a cycle through an edge kind whose named constraint is its cardinality", async () => { + const live = requirePostgres(); + // Neither frame names acyclicity, so neither takes the fence in its + // deciding form: the acyclicity probe itself advances the fence row + // on both sides. + const staleStore = createStore( + layeredGraph, + createPostgresBackend(live.stale), + ); + const writerStore = createStore( + layeredGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + const error = await rejection( + staleStore.transaction( + async (tx) => { + await tx.nodes.Task.getById(a.id); + await writerStore.edges.blocks.create(a, b); + await tx.edges.blocks.create(b, a); + }, + { isolationLevel }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.blocks.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }); + + it("cannot close a cycle when each transaction took the fence for a write that decided nothing", async () => { + const live = requirePostgres(); + const staleStore = createStore( + layeredGraph, + createPostgresBackend(live.stale), + ); + const writerStore = createStore( + layeredGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + const error = await rejection( + staleStore.transaction( + async (tx) => { + await tx.nodes.Task.getById(a.id); + // The writer's fence is taken by its cardinality write, with + // no fence-row advance; its acyclic write advances the row + // behind the lock the transaction already holds. + await writerStore.transaction(async (writerTx) => { + await writerTx.edges.assignedTo.create(a, b); + await writerTx.edges.dependsOn.create(a, b); + }); + // Likewise here: the cardinality write takes the fence and + // proceeds, and the acyclic write is the one refused. + await tx.edges.assignedTo.create(b, a); + await tx.edges.dependsOn.create(b, a); + }, + { isolationLevel }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await writerStore.edges.assignedTo.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }); + + it("cannot close a cycle when both writers take the fused schema + graph fence", async () => { + const live = requirePostgres(); + // Schema-managed stores: a write that owes both fences takes them + // in ONE statement, which must advance the same fence row the + // portable acquisition does. + const [staleStore] = await createStoreWithSchema( + acyclicGraph, + createPostgresBackend(live.stale), + ); + const [writerStore] = await createStoreWithSchema( + acyclicGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + const error = await rejection( + staleStore.transaction( + async (tx) => { + await tx.nodes.Task.getById(a.id); + await writerStore.edges.dependsOn.create(a, b); + await tx.edges.dependsOn.create(b, a); + }, + { isolationLevel }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }); + + it("cannot close a composition cycle", async () => { + const live = requirePostgres(); + const staleStore = createStore( + compositionGraph, + createPostgresBackend(live.stale), + ); + const writerStore = createStore( + compositionGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Assembly.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Assembly.create( + { name: "b" }, + { id: "b" }, + ); + + const error = await rejection( + staleStore.transaction( + async (tx) => { + await tx.nodes.Assembly.getById(a.id); + await writerStore.edges.containedIn.create(a, b); + await tx.edges.containedIn.create(b, a); + }, + { isolationLevel }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.containedIn.find({})).toHaveLength(1); + expect(await writerStore.verifyConstraintFences()).toEqual([]); + }); + + it("is told so with a typed conflict when the caller adopted its own transaction", async () => { + const live = requirePostgres(); + const staleStore = createAdapterStore( + acyclicGraph, + createPostgresBackend(live.stale), + ); + const writerStore = createStore( + acyclicGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + // The driver's own transaction, adopted: nothing between the fence + // and the caller translates the failure, so the fence itself must. + const error = await rejection( + live.stale.transaction( + async (externalTransaction) => { + const tx = staleStore.withTransaction(externalTransaction); + await tx.nodes.Task.getById(a.id); + await writerStore.edges.dependsOn.create(a, b); + await tx.edges.dependsOn.create(b, a); + }, + { + isolationLevel: + isolationLevel === "serializable" ? "serializable" : ( + "repeatable read" + ), + }, + ), + ); + + expectStaleSnapshotConflict(error); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }); + + it( + "is replayed whole when the store owns the transaction, and then sees the winner", + { timeout: CONTENTION_TIMEOUT_MS }, + async () => { + const live = requirePostgres(); + const stale = atIsolation( + createPostgresBackend(live.stale), + isolationLevel, + ); + const staleStore = createStore(acyclicGraph, stale.backend); + const writerStore = createStore( + acyclicGraph, + createPostgresBackend(live.writer), + ); + const a = await writerStore.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await writerStore.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + + const holding = createGate(); + const release = createGate(); + const winner = writerStore.transaction(async (tx) => { + await tx.edges.dependsOn.create(a, b); + holding.open(); + await release.opened; + }); + await holding.opened; + + // The store-owned write takes its snapshot in the statement that + // then parks on the fence, so it is stale the moment the winner + // commits. + const openedBefore = stale.opened(); + const closing = rejection(staleStore.edges.dependsOn.create(b, a)); + await untilSessionWaitsOnFence(requireDefined(stalePool)); + release.open(); + await winner; + + // The first attempt fails on the fence row; the replay opens a + // new transaction, reads the winner's edge and refuses the cycle. + expect(await closing).toBeInstanceOf(EdgeAcyclicityError); + expect(stale.opened() - openedBefore).toBe(2); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + }, + ); + + it("cannot commit a merge that closes a cycle", async () => { + const live = requirePostgres(); + const local = createLocalSqliteBackend(); + const targetBackend = createPostgresBackend(live.stale); + // Takes the merge commit's snapshot, then lets the read-committed + // writer commit before the merge reads or writes anything. + let afterSnapshot: (() => Promise) | undefined; + let commitsOpened = 0; + const staleBackend = deriveBackend(targetBackend, { + transaction: (run, options) => { + const isMergeCommit = options?.isolationLevel === "serializable"; + if (isMergeCommit) commitsOpened += 1; + return targetBackend.transaction( + async (transaction) => { + const interleave = afterSnapshot; + if (interleave !== undefined && isMergeCommit) { + afterSnapshot = undefined; + await transaction.execute( + asCompiledRowsSql(fragment`SELECT 1`), + ); + await interleave(); + } + return run(transaction); + }, + mergeCommitOptionsAt(isolationLevel, options), + ); + }, + }); + try { + const [target] = await createStoreWithSchema( + acyclicGraph, + staleBackend, + ); + const [writerStore] = await createStoreWithSchema( + acyclicGraph, + createPostgresBackend(live.writer), + ); + const a = await target.nodes.Task.create( + { name: "a" }, + { id: "a" }, + ); + const b = await target.nodes.Task.create( + { name: "b" }, + { id: "b" }, + ); + const source = unwrap( + await branch(target, () => Promise.resolve(local.backend), { + id: asBranchId("closes-the-cycle"), + }), + ); + await source.store.edges.dependsOn.create(b, a); + + afterSnapshot = async () => { + await writerStore.edges.dependsOn.create(a, b); + }; + const commitsBefore = commitsOpened; + const merged = await merge(target, [source]); + + expect(afterSnapshot).toBeUndefined(); + // The stale commit fails on the fence row and is replayed in a + // new transaction, which reads the writer's edge and refuses the + // plan as stale. A refusal that never took the fence — an + // isolation the fence does not support — opens one transaction + // and is not this error. + expect(commitsOpened - commitsBefore).toBe(2); + expect(isErr(merged) ? merged.error : undefined).toBeInstanceOf( + BaseVersionMismatchError, + ); + expect(await writerStore.edges.dependsOn.find({})).toHaveLength(1); + expect(await cycleViolations(writerStore)).toEqual([]); + } finally { + await local.backend.close(); + } + }); + }, + ); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/pglite-backend.test.ts b/packages/typegraph/tests/backends/postgres/pglite-backend.test.ts index 0c926cb68..515e00baf 100644 --- a/packages/typegraph/tests/backends/postgres/pglite-backend.test.ts +++ b/packages/typegraph/tests/backends/postgres/pglite-backend.test.ts @@ -120,6 +120,8 @@ const CLONE_TABLE_NAMES = { recordedIdentityAssertions: "clone_recorded_identity_assertions", identityClosure: "clone_identity_closure", identitySeparation: "clone_identity_separation", + identityTransitions: "clone_identity_transitions", + identityTransitionRetention: "clone_identity_transition_retention", uniques: "clone_node_uniques", edgeClaims: "clone_edge_claims", baseSchemaVersions: "clone_base_schema_versions", diff --git a/packages/typegraph/tests/backends/postgres/pglite-edge-acyclicity-query-plan.test.ts b/packages/typegraph/tests/backends/postgres/pglite-edge-acyclicity-query-plan.test.ts new file mode 100644 index 000000000..d6cb68e24 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/pglite-edge-acyclicity-query-plan.test.ts @@ -0,0 +1,409 @@ +/** + * PostgreSQL-dialect twin of `tests/backends/sqlite/edge-acyclicity-query-plan.test.ts` + * (see that file's docblock for the full defect description). Runs against + * PGlite — no live PostgreSQL server, per the test-suite rule — but PGlite is a + * real Postgres query planner and executor, so a plain `EXPLAIN` (no + * `ANALYZE`: this asserts the PLAN, not measured row counts) genuinely + * exercises the same index-choice logic a production PostgreSQL server + * would apply to the identical statement. + * + * **The defect (`MATERIALIZE candidates`, SQLite query-flattener rule 17d) + * is SQLite-specific.** Verified empirically before writing these + * assertions: rendering the OLD compound-`candidates` shape for PostgreSQL + * and comparing `EXPLAIN` against the new direct-join shape shows + * PostgreSQL's planner already flattens the old compound CTE into the same + * index-backed access either way. So the PRIMARY, mutation-provable + * assertion on this engine is structural: the compiled SQL for the + * `"proposed"` and `"relation"` seed forms never contains a `candidates` CTE + * at all — that IS what reverting `buildProbeBodyDirect` reintroduces, + * regardless of what plan PostgreSQL then picks for it. + * + * The plan-level assertions are the genuine backstop against the + * complexity-class regression the fix exists to remove: a per-recursion-round + * scan of the WHOLE relevant edge-kind partition (a `Bitmap Heap Scan` + * driven only by `typegraph_edges_kind_idx`, or a `Seq Scan`) instead of a + * seek CORRELATED to the current frontier node (an `Index Cond` that + * references the worktable alias, e.g. `from_id = a_1.node_id`). At the + * small fixture size this suite used to run at (60 nodes, a few hundred + * edges) PostgreSQL's planner reasonably prefers the kind-partition scan — + * it is cheaper at that scale — so the fixtures below are sized (a few + * hundred nodes, several thousand edges) until the planner actually picks + * the correlated seek; asserting on the correlated-seek `Index Cond` shape + * at a size where the planner would not choose one is coverage theater; see + * `extractRecursiveTerm`'s docblock and the mutation check below. + * + * The `Index Cond` shape is pinned, never the index name: PostgreSQL + * satisfies `graph_id, kind, from_kind, from_id` equally well from + * `typegraph_edges_cardinality_idx` (leading columns happen to match) as + * from `typegraph_edges_from_idx`, and picks whichever it estimates + * cheaper — both are genuine index seeks, so the index name is not the + * fact this suite needs to hold. + * + * Mutation check: reverting + * `buildProbeBodyDirect` to route through `buildProbeBodyPlanned`'s + * `candidates` CTE machinery makes the "no candidates(" assertions in every + * test below fail immediately (the CTE reappears in the rendered SQL text), + * and turns the correlated-seek `Index Cond` assertions into failures too + * (the compound CTE plan has no worktable-correlated condition at all). + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStore, defineEdge, defineGraph, defineNode } from "../../../src"; +import { resolveRecursiveTraversal } from "../../../src/backend/capabilities/recursive-traversal"; +import { deriveBackend } from "../../../src/backend/derive-backend"; +import { createLocalPgliteBackend } from "../../../src/backend/postgres/pglite"; +import { + type GraphBackend, + type TransactionBackend, +} from "../../../src/backend/types"; +import { createSqlSchema } from "../../../src/query/compiler/schema"; +import { postgresDialect } from "../../../src/query/dialect"; +import { renderPostgres } from "../../../src/query/sql-fragment"; +import { type CompiledRowsSql } from "../../../src/query/sql-intent"; +import { type AcyclicRelationMember } from "../../../src/store/acyclicity"; +import { buildEdgeAcyclicityProbe } from "../../../src/store/recursive-cte"; +import { requireDefined } from "../../../src/utils/presence"; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); +const Milestone = defineNode("Milestone", { + schema: z.object({ name: z.string() }), +}); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); +const blockedBy = defineEdge("blockedBy", { schema: z.object({}) }); + +function buildGraph() { + return defineGraph({ + id: "acyclicity-query-plan-pg", + nodes: { Task: { type: Task }, Milestone: { type: Milestone } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + blockedBy: { type: blockedBy, from: [Task], to: [Task, Milestone] }, + }, + }); +} + +type CapturedStatement = Readonly<{ sql: string; params: readonly unknown[] }>; + +/** + * Wraps a real PGlite-backed Postgres backend so every `execute()` call + * (top-level and inside a transaction) is rendered — via the SAME + * `renderPostgres` the backend itself uses internally to turn a + * `SqlFragment` into `{ sql, params }` — and recorded. Mirrors + * `createPlanCaptureBackend`'s SQLite device in `tests/test-utils.ts`, + * which instead reads `backend.compileSql` (a method only the SQLite + * adapter exposes). A `CompiledRowsSql` IS a `SqlFragment` at runtime + * (branded only at the type level, `src/query/sql-intent.ts`), so rendering + * the object `execute` receives, before delegating, changes nothing about + * what actually runs. + */ +function capturingExecute(captured: CapturedStatement[]) { + return async function execute( + target: Pick, + query: CompiledRowsSql, + ): Promise { + captured.push(renderPostgres(query)); + return target.execute(query); + }; +} + +function captureTransactionTarget( + target: TransactionBackend, + captured: CapturedStatement[], +): TransactionBackend { + const execute = capturingExecute(captured); + return deriveBackend(target, { + execute: (query: CompiledRowsSql) => execute(target, query), + }); +} + +function capturePostgresProbes(raw: GraphBackend): Readonly<{ + backend: GraphBackend; + captured: CapturedStatement[]; +}> { + const captured: CapturedStatement[] = []; + const execute = capturingExecute(captured); + const backend = deriveBackend(raw, { + execute: (query: CompiledRowsSql) => execute(raw, query), + transaction: (fn, options) => + raw.transaction( + (target) => fn(captureTransactionTarget(target, captured)), + options, + ), + }); + return { backend, captured }; +} + +function findProbeStatement(captured: readonly CapturedStatement[]) { + return requireDefined( + captured.find((statement) => statement.sql.includes("ancestry(")), + "no acyclicity-probe statement was captured", + ); +} + +async function explainPlan( + client: Awaited>["client"], + statement: CapturedStatement, +): Promise { + const result = await client.query>( + `EXPLAIN ${statement.sql}`, + [...statement.params], + ); + return result.rows.map((row) => row["QUERY PLAN"]).join("\n"); +} + +/** + * Isolates the `Recursive Union`'s recursive term from a full `EXPLAIN` + * text tree, so the plan assertions below can pin what the recursive STEP + * does per round without also matching the (irrelevant) seed CTE scan or + * the outer closing `SELECT`. `EXPLAIN`'s indentation is structural: the + * sibling node that follows the `ancestry` CTE (`HashAggregate`, `Sort`, or + * similar, depending on whether the seed form needs `DISTINCT`/`LIMIT`) + * always renders at exactly two leading spaces before its own `->`, one + * level shallower than anything inside the CTE — so that is the boundary. + */ +function extractRecursiveTerm(plan: string): string { + const startIndex = plan.indexOf("Recursive Union"); + if (startIndex === -1) { + return ""; + } + const rest = plan.slice(startIndex); + const boundary = /\n {2}->/.exec(rest); + return boundary === null ? rest : rest.slice(0, boundary.index); +} + +/** A worktable-correlated `Index Cond` on the FORWARD (`from`) direction — the shape that proves the recursive step seeks per-frontier-node rather than scanning the kind partition. Pins the condition shape, not the index name (PostgreSQL may satisfy it from `typegraph_edges_cardinality_idx` or `typegraph_edges_from_idx`). */ +const FORWARD_SEEK_INDEX_COND = /from_id\s*=\s*\w+\.node_id/; +/** The reversed-direction twin of {@link FORWARD_SEEK_INDEX_COND}. */ +const REVERSED_SEEK_INDEX_COND = /to_id\s*=\s*\w+\.node_id/; + +/** Seeds `count` unrelated-kind edges so a real edge kind's own rows are a minority of the table — the shape a real multi-kind graph has, and the shape that lets the planner's index choice be meaningful rather than "scan everything, it's all one kind anyway". */ +async function seedNoiseEdges( + raw: GraphBackend, + graphId: string, + taskIds: readonly string[], + count: number, +): Promise { + for (let index = 0; index < count; index += 1) { + await raw.insertEdge({ + graphId, + id: `noise${String(index)}`, + kind: "blockedBy", + fromKind: "Task", + fromId: requireDefined(taskIds[index % taskIds.length]), + toKind: "Task", + toId: requireDefined(taskIds[(index + 1) % taskIds.length]), + props: {}, + }); + } +} + +/** + * Node/noise-edge counts tuned (empirically, against real PGlite `EXPLAIN` + * output) so the planner actually prefers a per-frontier-node correlated + * index seek over scanning the whole `dependsOn`/`blockedBy` partition. + * Below this scale the partition is cheap enough that a `Bitmap Heap Scan` + * driven only by `typegraph_edges_kind_idx` wins on cost — which is the + * right call by the planner, but useless as a regression backstop for the + * O(relation) shape this suite exists to catch. + */ +const PLAN_FIXTURE_NODE_COUNT = 400; +const PLAN_FIXTURE_NOISE_EDGE_COUNT = 4000; + +describe("edge-acyclicity probe (PostgreSQL dialect, PGlite): query plan", () => { + it("a single create's probe compiles no candidates CTE and its recursive step seeks a worktable-correlated index, never a Seq Scan of typegraph_edges", async () => { + const { backend: raw, client } = await createLocalPgliteBackend({ + vector: false, + }); + try { + const { backend, captured } = capturePostgresProbes(raw); + const store = createStore(buildGraph(), backend); + + const nodes = await Promise.all( + Array.from({ length: PLAN_FIXTURE_NODE_COUNT }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + for (let index = 0; index < nodes.length - 2; index += 1) { + await store.edges.dependsOn.create( + requireDefined(nodes[index]), + requireDefined(nodes[index + 1]), + ); + } + await seedNoiseEdges( + raw, + "acyclicity-query-plan-pg", + nodes.map((node) => node.id), + PLAN_FIXTURE_NOISE_EDGE_COUNT, + ); + await client.query("ANALYZE typegraph_edges"); + + captured.length = 0; + await store.edges.dependsOn.create( + requireDefined(nodes.at(-2)), + requireDefined(nodes.at(-1)), + ); + + const probeStatement = findProbeStatement(captured); + expect(probeStatement.sql).not.toContain("candidates("); + const plan = await explainPlan(client, probeStatement); + const recursiveTerm = extractRecursiveTerm(plan); + + expect(recursiveTerm).not.toMatch(/Seq Scan on typegraph_edges\b/); + expect(recursiveTerm).toMatch(/Index (Scan|Cond)/); + expect(recursiveTerm).toMatch(FORWARD_SEEK_INDEX_COND); + } finally { + await raw.close(); + } + }, 30_000); + + it("a bulkCreate batch's pre-insert probe compiles no candidates CTE and its recursive step seeks a worktable-correlated index, never a Seq Scan of typegraph_edges", async () => { + const { backend: raw, client } = await createLocalPgliteBackend({ + vector: false, + }); + try { + const { backend, captured } = capturePostgresProbes(raw); + const store = createStore(buildGraph(), backend); + + const nodes = await Promise.all( + Array.from({ length: PLAN_FIXTURE_NODE_COUNT }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + for (let index = 0; index < nodes.length - 3; index += 1) { + await store.edges.dependsOn.create( + requireDefined(nodes[index]), + requireDefined(nodes[index + 1]), + ); + } + await seedNoiseEdges( + raw, + "acyclicity-query-plan-pg", + nodes.map((node) => node.id), + PLAN_FIXTURE_NOISE_EDGE_COUNT, + ); + await client.query("ANALYZE typegraph_edges"); + + captured.length = 0; + await store.edges.dependsOn.bulkCreate([ + { + from: requireDefined(nodes.at(-3)), + to: requireDefined(nodes.at(-2)), + }, + { + from: requireDefined(nodes.at(-2)), + to: requireDefined(nodes.at(-1)), + }, + ]); + + const probeStatement = findProbeStatement(captured); + expect(probeStatement.sql).toContain("VALUES"); + expect(probeStatement.sql).not.toContain("candidates("); + const plan = await explainPlan(client, probeStatement); + const recursiveTerm = extractRecursiveTerm(plan); + + expect(recursiveTerm).not.toMatch(/Seq Scan on typegraph_edges\b/); + expect(recursiveTerm).toMatch(/Index (Scan|Cond)/); + expect(recursiveTerm).toMatch(FORWARD_SEEK_INDEX_COND); + } finally { + await raw.close(); + } + }, 30_000); + + it("a mixed-orientation relation's audit probe compiles no candidates CTE and its recursive step BitmapOrs two worktable-correlated index seeks, one per direction, never a Seq Scan", async () => { + const { backend: raw, client } = await createLocalPgliteBackend({ + vector: false, + }); + try { + const store = createStore(buildGraph(), raw); + + const tasks = await Promise.all( + Array.from({ length: PLAN_FIXTURE_NODE_COUNT }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + const milestones = await Promise.all( + Array.from({ length: PLAN_FIXTURE_NODE_COUNT }, (_unused, index) => + store.nodes.Milestone.create({ name: `m${String(index)}` }), + ), + ); + for (let index = 0; index < tasks.length - 1; index += 1) { + await raw.insertEdge({ + graphId: "acyclicity-query-plan-pg", + id: `f${String(index)}`, + kind: "dependsOn", + fromKind: "Task", + fromId: requireDefined(tasks[index]).id, + toKind: "Task", + toId: requireDefined(tasks[index + 1]).id, + props: {}, + }); + await raw.insertEdge({ + graphId: "acyclicity-query-plan-pg", + id: `r${String(index)}`, + kind: "blockedBy", + fromKind: "Task", + fromId: requireDefined(tasks[index]).id, + toKind: "Milestone", + toId: requireDefined(milestones[index]).id, + props: {}, + }); + } + // Unrelated-kind noise, same shape and same tuned scale as the other + // two tests: without it (or below `PLAN_FIXTURE_NOISE_EDGE_COUNT`) the + // planner reasonably prefers a single `Bitmap Heap Scan` of the whole + // `dependsOn`/`blockedBy` partition (materialized and reused across + // recursion rounds) over a correlated seek — a real graph's edges + // table holds many kinds, and that scan IS the O(relation) shape this + // suite exists to catch. + for (let index = 0; index < PLAN_FIXTURE_NOISE_EDGE_COUNT; index += 1) { + await raw.insertEdge({ + graphId: "acyclicity-query-plan-pg", + id: `noise${String(index)}`, + kind: "noise", + fromKind: "Task", + fromId: requireDefined(tasks[index % tasks.length]).id, + toKind: "Task", + toId: requireDefined(tasks[(index + 1) % tasks.length]).id, + props: {}, + }); + } + await client.query("ANALYZE typegraph_edges"); + + const members: readonly AcyclicRelationMember[] = [ + { edgeKind: "dependsOn", reversed: false, membership: "allRows" }, + { edgeKind: "blockedBy", reversed: true, membership: "allRows" }, + ]; + const fragment = buildEdgeAcyclicityProbe({ + graphId: "acyclicity-query-plan-pg", + members, + seed: { kind: "relation" }, + dialect: postgresDialect, + schema: createSqlSchema(raw.tableNames), + recursiveTraversal: resolveRecursiveTraversal(raw.capabilities), + operation: "test", + }); + const rendered = renderPostgres(fragment); + expect(rendered.sql).not.toContain("candidates("); + + const plan = await explainPlan(client, rendered); + const recursiveTerm = extractRecursiveTerm(plan); + + expect(recursiveTerm).not.toMatch(/Seq Scan on typegraph_edges\b/); + // The OR of two index-seekable arms (buildAcyclicityAncestryStepDirect's + // docblock) is what lets PostgreSQL's BitmapOr seek both directions in + // one recursive step instead of falling back to a partition scan. + expect(recursiveTerm).toContain("BitmapOr"); + expect(recursiveTerm).toMatch(FORWARD_SEEK_INDEX_COND); + expect(recursiveTerm).toMatch(REVERSED_SEEK_INDEX_COND); + } finally { + await raw.close(); + } + }, 30_000); +}); diff --git a/packages/typegraph/tests/backends/postgres/pglite-identity-transition-log.test.ts b/packages/typegraph/tests/backends/postgres/pglite-identity-transition-log.test.ts new file mode 100644 index 000000000..bcfdea696 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/pglite-identity-transition-log.test.ts @@ -0,0 +1,72 @@ +/** + * `readIdentityTransitions` must decode `recorded_at` / `valid_at` + * through `toCanonicalIdentityTimestamp` (row-codec.ts) — the identity + * module's one owner of "how is a raw driver timestamp value turned into a + * canonical ISO string" — rather than a private, stricter + * string-only decoder. Both columns are declared + * `timestamp(..., { withTimezone: true }).notNull()` on PostgreSQL + * (schema/postgres.ts), and `pg` decodes `timestamptz` into a JS `Date`; a + * decoder that only accepts a string throws on that value. + * + * PGlite's own driver returns text rather than a `Date` for a timestamp + * column, so this suite cannot exercise the `Date`-typed branch a real `pg` + * connection would hit (see `toCanonicalIdentityTimestamp`'s `instanceof + * Date` branch). It still + * exercises the PostgreSQL DDL/dialect end to end and pins that the decode + * path produces genuinely canonical ISO output, not merely "does not throw". + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineGraph, defineNode } from "../../../src"; +import { createLocalPgliteBackend } from "../../../src/backend/postgres/pglite"; +import { + asRecordedInstant, + recordedInstantWallTime, +} from "../../../src/core/temporal"; +import { identityTransitionsOf } from "../../../src/identity/replay"; +import { createStore } from "../../../src/store"; +import { storeRuntime } from "../../../src/store/runtime-port"; +import { isCanonicalIsoDate } from "../../../src/utils/date"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); + +const graph = defineGraph({ + id: "pglite_identity_transition_log", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +describe("identity transition log on PGlite (PostgreSQL dialect)", () => { + it("round-trips recorded_at and valid_at as canonical ISO instants", async () => { + const { backend } = await createLocalPgliteBackend({ vector: false }); + try { + const store = createStore(graph, backend, { history: true }); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + await store.identity.assertSame( + { kind: "Person", id: alice.id }, + { kind: "Person", id: bob.id }, + ); + + const ctx = storeRuntime(store).identityContext(); + const { transitions } = await identityTransitionsOf(ctx, { + kind: "Person", + id: alice.id, + }); + + expect(transitions.length).toBeGreaterThan(0); + for (const transition of transitions) { + expect( + isCanonicalIsoDate( + recordedInstantWallTime(asRecordedInstant(transition.recorded)), + ), + ).toBe(true); + expect(isCanonicalIsoDate(transition.validAt)).toBe(true); + } + } finally { + await backend.close(); + } + }); +}); diff --git a/packages/typegraph/tests/backends/postgres/pglite-interchange-roundtrip.test.ts b/packages/typegraph/tests/backends/postgres/pglite-interchange-roundtrip.test.ts new file mode 100644 index 000000000..472a7a534 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/pglite-interchange-roundtrip.test.ts @@ -0,0 +1,58 @@ +/** + * The interchange round-trip property (tests/property/ + * interchange-composition-roundtrip.test.ts) on PGlite: the PostgreSQL dialect + * renders every claim, window comparison and identity write the round trip + * depends on, so the same histories must round-trip there too. A smaller + * budget than the SQLite lane — each run leases two fresh schemas. + */ +import fc from "fast-check"; +import { afterAll, describe, it } from "vitest"; + +import { createStoreWithSchema } from "../../../src"; +import { createPgliteFixturePool } from "../../graph-merge/pglite-fixture-pool"; +import { + buildRoundTripGraph, + checkRoundTrip, + historyArb, + type RoundTripStore, +} from "../../property/interchange-composition-model"; +import { propertyRuns } from "../../property/run-budget"; + +const pool = createPgliteFixturePool(); + +afterAll(async () => { + await pool.dispose(); +}); + +describe("interchange round trip over composition and identity histories (PGlite)", () => { + for (const identity of [false, true]) { + it(`import(export(store)) preserves rows, windows, claims, ${identity ? "the identity ledger, " : ""}and composition reads`, async () => { + await fc.assert( + fc.asyncProperty(historyArb(identity), async (history) => { + const cleanups: (() => Promise)[] = []; + async function createStore( + withIdentity: boolean, + ): Promise { + const fixture = await pool.makeFixture(); + cleanups.push(fixture.cleanup); + const [store] = await createStoreWithSchema( + buildRoundTripGraph({ + id: "interchange_composition_roundtrip", + identity: withIdentity, + }), + fixture.backend, + withIdentity ? { history: true } : undefined, + ); + return store; + } + try { + await checkRoundTrip(history, { identity, createStore }); + } finally { + for (const cleanup of cleanups) await cleanup(); + } + }), + { numRuns: propertyRuns({ ci: 6, local: 10 }) }, + ); + }, 240_000); + } +}); diff --git a/packages/typegraph/tests/backends/postgres/postgres-backend.test.ts b/packages/typegraph/tests/backends/postgres/postgres-backend.test.ts index 662473cac..b239acb01 100644 --- a/packages/typegraph/tests/backends/postgres/postgres-backend.test.ts +++ b/packages/typegraph/tests/backends/postgres/postgres-backend.test.ts @@ -72,6 +72,7 @@ import { import { provisionPostgresTestDatabase } from "../../postgres-test-database"; import { createAdapterTestSuite } from "../adapter-test-suite"; import { createIntegrationTestSuite } from "../integration-test-suite"; +import { TRUNCATE_RESETTABLE_TABLES_SQL } from "./managed-tables"; // ============================================================ // Test Configuration @@ -211,25 +212,7 @@ async function clearTestData(): Promise { // per-test createStoreWithSchema re-materializes every per-field vector // table + marker in lockstep. Otherwise a marker could outlive a dropped // table and a later boot in this database would skip the CREATE. - await sharedPool.query( - `TRUNCATE typegraph_index_materializations, - typegraph_contribution_materializations, - typegraph_kind_removals, - typegraph_reconciliation_markers, - typegraph_node_fulltext, - typegraph_revision_origins, - typegraph_recorded_clock, - typegraph_recorded_nodes, - typegraph_recorded_edges, - typegraph_recorded_identity_assertions, - typegraph_identity_closure, - typegraph_identity_assertions, - typegraph_nodes, - typegraph_edges, - typegraph_node_uniques, - typegraph_contribution_materializations, - typegraph_schema_versions CASCADE`, - ); + await sharedPool.query(TRUNCATE_RESETTABLE_TABLES_SQL); // Per-(kind, field) vector tables are materialized per field, so enumerate // and truncate any that exist to keep embedding rows from leaking across diff --git a/packages/typegraph/tests/backends/postgres/postgres-js-backend.test.ts b/packages/typegraph/tests/backends/postgres/postgres-js-backend.test.ts index 357ad1b34..810f15d1e 100644 --- a/packages/typegraph/tests/backends/postgres/postgres-js-backend.test.ts +++ b/packages/typegraph/tests/backends/postgres/postgres-js-backend.test.ts @@ -35,6 +35,7 @@ import { requireDefined } from "../../../src/utils/presence"; import { provisionPostgresTestDatabase } from "../../postgres-test-database"; import { createAdapterTestSuite } from "../adapter-test-suite"; import { createIntegrationTestSuite } from "../integration-test-suite"; +import { TRUNCATE_RESETTABLE_TABLES_SQL } from "./managed-tables"; const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); @@ -109,25 +110,7 @@ async function clearTestData(): Promise { // long-lived test database would let a marker outlive a dropped table, so a // later boot would trust the marker, skip the CREATE, and writes would hit a // missing relation. - await sharedSql.unsafe( - `TRUNCATE typegraph_index_materializations, - typegraph_contribution_materializations, - typegraph_kind_removals, - typegraph_reconciliation_markers, - typegraph_node_fulltext, - typegraph_revision_origins, - typegraph_recorded_clock, - typegraph_recorded_nodes, - typegraph_recorded_edges, - typegraph_recorded_identity_assertions, - typegraph_identity_closure, - typegraph_identity_assertions, - typegraph_nodes, - typegraph_edges, - typegraph_node_uniques, - typegraph_contribution_materializations, - typegraph_schema_versions CASCADE`, - ); + await sharedSql.unsafe(TRUNCATE_RESETTABLE_TABLES_SQL); // Per-(kind, field) vector tables are materialized per field, so enumerate // and truncate any that exist (there is no single shared embeddings table). // createStoreWithSchema re-creates any that are missing (markers cleared). diff --git a/packages/typegraph/tests/backends/postgres/postgres-working-copy-allocation-schema.test.ts b/packages/typegraph/tests/backends/postgres/postgres-working-copy-allocation-schema.test.ts index 1ed15fd48..df8d23f1b 100644 --- a/packages/typegraph/tests/backends/postgres/postgres-working-copy-allocation-schema.test.ts +++ b/packages/typegraph/tests/backends/postgres/postgres-working-copy-allocation-schema.test.ts @@ -22,6 +22,7 @@ import { deriveBackend } from "../../../src/backend/derive-backend"; import type { AnyPgTransaction } from "../../../src/backend/drizzle/execution"; import { createPostgresBackend } from "../../../src/backend/drizzle/postgres"; import { bindNamesToAllocationSchema } from "../../../src/backend/drizzle/postgres-allocation-schema"; +import { allocationNames } from "../../../src/backend/drizzle/postgres-working-copy-names"; import { createPostgresTables, defaultPostgresTableNames, @@ -1047,14 +1048,9 @@ describe.runIf(process.env["POSTGRES_URL"])( } }, 60_000); - it("refuses a bound backend on a driver that cannot hold the transaction its DDL needs", () => { + it("refuses a bound backend on a driver that cannot hold the transaction its DDL needs", async () => { const pool = new Pool({ connectionString: TEST_DATABASE_URL, max: 1 }); - const names = Object.fromEntries( - Object.keys(defaultPostgresTableNames).map((key) => [ - key, - `tgw_${"c".repeat(DIGEST_LENGTH)}_${key.slice(0, 15)}`, - ]), - ) as PostgresTableNames; + const names = await allocationNames("non-interactive-driver"); bindNamesToAllocationSchema(names, "public"); let refusal: unknown; try { diff --git a/packages/typegraph/tests/backends/postgres/postgres-working-copy-base-schema-reopen.test.ts b/packages/typegraph/tests/backends/postgres/postgres-working-copy-base-schema-reopen.test.ts new file mode 100644 index 000000000..2fd26f531 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/postgres-working-copy-base-schema-reopen.test.ts @@ -0,0 +1,188 @@ +/** + * A durable allocation keeps the base schema of the release that provisioned + * it. Reopening it under a later release adopts the base-schema steps it is + * missing in place, before any store reads or writes it; an allocation stamped + * by a newer release is refused. + */ +import { drizzle } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + BaseSchemaMigrationError, + createStoreWithSchema, + defineGraph, + defineNode, +} from "../../../src"; +import { CURRENT_BASE_SCHEMA_VERSION } from "../../../src/backend/drizzle/base-schema"; +import { createPostgresBackend } from "../../../src/backend/drizzle/postgres"; +import { allocationNames } from "../../../src/backend/drizzle/postgres-working-copy-names"; +import { createPostgresTables } from "../../../src/backend/drizzle/schema/postgres"; +import { createPostgresWorkingCopyManager } from "../../../src/backend/postgres/working-copy"; +import { branchDurable, reopenDurableBranch } from "../../../src/graph-merge"; +import { isOk, unwrap } from "../../../src/graph-merge/result"; +import { asBranchId } from "../../../src/graph-merge/types"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +/** The last base-schema version before the identity transition log. */ +const VERSION_BEFORE_TRANSITION_LOG = 5; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); +const Author = defineNode("Author", { + schema: z.object({ penName: z.string() }), +}); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { Person: { type: Person }, Author: { type: Author } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); +} + +async function sealAllocation(pool: Pool, allocationId: string) { + const graph = buildGraph(`working_copy_base_schema_${allocationId}`); + const control = createPostgresBackend(drizzle(pool)); + const [source] = await createStoreWithSchema(graph, control, { + history: true, + }); + await source.nodes.Person.create({ name: "Source" }, { id: "source" }); + const manager = createPostgresWorkingCopyManager({ + control, + connect: (names) => + Promise.resolve( + createPostgresBackend(drizzle(pool), { + tables: createPostgresTables(names), + }), + ), + }); + const created = await branchDurable(source, manager.durable, { + id: asBranchId(allocationId), + allocationId, + }); + if (!isOk(created)) throw created.error; + const { branch, descriptor } = unwrap(created); + await branch.close(); + + const ledger = await pool.query<{ schema_name: string | null }>( + "SELECT schema_name FROM typegraph_working_copy_allocations WHERE allocation_id = $1", + [allocationId], + ); + const schema = ledger.rows[0]?.schema_name ?? "public"; + return { + pool, + graph, + manager, + descriptor, + names: await allocationNames(allocationId), + qualified: (table: string) => `"${schema}"."${table}"`, + }; +} + +type Allocation = Awaited>; + +async function withSealedAllocation( + allocationId: string, + scenario: (allocation: Allocation) => Promise, +): Promise { + const pool = new Pool({ connectionString: TEST_DATABASE_URL, max: 4 }); + try { + await scenario(await sealAllocation(pool, allocationId)); + } finally { + await pool.end(); + } +} + +function causeOfType( + error: unknown, + type: abstract new (...args: never[]) => T, +): T | undefined { + let link: unknown = error; + while (link instanceof Error) { + if (link instanceof type) return link; + link = link.cause; + } + return undefined; +} + +async function markerVersion(allocation: Allocation): Promise { + const marker = await allocation.pool.query<{ version: number }>( + `SELECT version FROM ${allocation.qualified(allocation.names.baseSchemaVersions)} WHERE installation = 1`, + ); + return Number(marker.rows[0]?.version); +} + +describe.runIf(process.env["POSTGRES_URL"])( + "reopening a durable allocation provisioned by an earlier release", + () => { + it("adopts the identity transition log in place and records identity history", async () => { + await withSealedAllocation("base-schema-adopt", async (allocation) => { + const { pool, names, qualified } = allocation; + // The allocation an earlier release provisioned: no transition-log + // relations, its marker at the version before them. + await pool.query( + `DROP TABLE ${qualified(names.identityTransitions)}; DROP TABLE ${qualified(names.identityTransitionRetention)}; UPDATE ${qualified(names.baseSchemaVersions)} SET version = ${String(VERSION_BEFORE_TRANSITION_LOG)} WHERE installation = 1`, + ); + + const reopened = unwrap( + await reopenDurableBranch( + allocation.graph, + allocation.descriptor, + allocation.manager.durable, + ), + ); + try { + expect(await markerVersion(allocation)).toBe( + CURRENT_BASE_SCHEMA_VERSION, + ); + await reopened.store.nodes.Person.create( + { name: "Ada" }, + { id: "ada" }, + ); + // A same-id fold writes a transition-log row. + await reopened.store.nodes.Author.create( + { penName: "A." }, + { id: "ada" }, + ); + const { transitions } = await reopened.store.identity.transitionsOf({ + kind: "Person", + id: "ada", + }); + expect(transitions.map((transition) => transition.cause)).toEqual([ + "fold", + ]); + } finally { + await reopened.close(); + } + }); + }, 120_000); + + it("refuses an allocation stamped by a newer release before attaching a store", async () => { + await withSealedAllocation("base-schema-newer", async (allocation) => { + const { pool, names, qualified } = allocation; + await pool.query( + `UPDATE ${qualified(names.baseSchemaVersions)} SET version = ${String(CURRENT_BASE_SCHEMA_VERSION + 1)} WHERE installation = 1`, + ); + + const outcome = await reopenDurableBranch( + allocation.graph, + allocation.descriptor, + allocation.manager.durable, + ).then( + (result) => (isOk(result) ? result : result.error), + (error: unknown) => error, + ); + // The reopen reports its own failure and carries the refusal as cause. + const refusal = causeOfType(outcome, BaseSchemaMigrationError); + expect(refusal?.details).toMatchObject({ + reason: "newer", + installedVersion: CURRENT_BASE_SCHEMA_VERSION + 1, + }); + }); + }, 120_000); + }, +); diff --git a/packages/typegraph/tests/backends/postgres/postgres-working-copy.test.ts b/packages/typegraph/tests/backends/postgres/postgres-working-copy.test.ts index 3818d35a9..df9967d3a 100644 --- a/packages/typegraph/tests/backends/postgres/postgres-working-copy.test.ts +++ b/packages/typegraph/tests/backends/postgres/postgres-working-copy.test.ts @@ -1,9 +1,10 @@ import { drizzle } from "drizzle-orm/node-postgres"; import { Pool } from "pg"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { + defineEdge, defineGraph, defineGraphExtension, defineNode, @@ -50,6 +51,7 @@ import { pgvectorStrategy, } from "../../../src/query/dialect/vector/pgvector-strategy"; import type { CompiledRowsSql } from "../../../src/query/sql-intent"; +import { rollbackSchema } from "../../../src/schema"; import { storeBackend } from "../../../src/store/runtime-port"; import { createStore, createStoreWithSchema } from "../../../src/store/store"; import { sha256Hex } from "../../../src/utils/hash"; @@ -127,6 +129,9 @@ describe.runIf(process.env["POSTGRES_URL"])( if (names === undefined) throw new Error("Missing allocated table names."); const prefix = names.nodes.slice(0, -"nodes".length); + expect(new Set(Object.values(names)).size).toBe( + Object.keys(names).length, + ); const inventory = await pool.query<{ tablename: string }>( "SELECT tablename FROM pg_tables WHERE schemaname = current_schema() AND left(tablename, length($1)) = $1 ORDER BY tablename", [prefix], @@ -1475,6 +1480,18 @@ describe.runIf(process.env["POSTGRES_URL"])( version: 1, }), ).rejects.toThrow(/fixed schema/); + const preflight = vi.fn(() => Promise.resolve()); + await expect( + storeBackend(copy).setActiveVersionWithPreflight?.( + { + graphId: graph.id, + expected: { kind: "active", version: 1 }, + version: 1, + }, + preflight, + ), + ).rejects.toThrow(/fixed schema/); + expect(preflight).not.toHaveBeenCalled(); const systemIndexes = await copy.materializeSystemIndexes(); expect( systemIndexes.results.some((entry) => entry.status === "failed"), @@ -1507,6 +1524,79 @@ describe.runIf(process.env["POSTGRES_URL"])( } }, 60_000); + it("refuses a rollback that owes a preflight and keeps the active version", async () => { + const Source = defineNode("Source", { schema: z.object({}) }); + const Target = defineNode("Target", { schema: z.object({}) }); + const link = defineEdge("link", { schema: z.object({}) }); + const graphId = "postgres-working-copy-fixed-rollback"; + const nodes = { Source: { type: Source }, Target: { type: Target } }; + const tight = defineGraph({ + id: graphId, + nodes, + edges: { + link: { + type: link, + from: [Source], + to: [Target], + cardinality: "one", + }, + }, + }); + const loose = defineGraph({ + id: graphId, + nodes, + edges: { + link: { + type: link, + from: [Source], + to: [Target], + cardinality: "many", + }, + }, + }); + const pool = new Pool({ connectionString: TEST_DATABASE_URL, max: 6 }); + try { + const control = createPostgresBackend(drizzle(pool)); + const storeOptions = { history: true, revisionTracking: true }; + await createStoreWithSchema(tight, control, storeOptions); + const [source] = await createStoreWithSchema( + loose, + control, + storeOptions, + ); + const manager = createPostgresWorkingCopyManager({ + control, + connect: (names) => + Promise.resolve( + createPostgresBackend(drizzle(pool), { + tables: createPostgresTables(names), + }), + ), + }); + const { branch } = unwrap( + await branchDurable(source, manager.durable, { + id: asBranchId("fixed-rollback"), + allocationId: "fixed-rollback-allocation", + }), + ); + const backend = storeBackend(branch.store); + const before = await backend.getActiveSchema(graphId); + console.log("active schema before rollback", before?.version); + expect(before?.version).toBe(2); + + // Reactivating the tighter cardinality owes a preflight, so the + // rollback reaches the preflight-carrying member, not setActiveVersion. + await expect(rollbackSchema(backend, graphId, 1)).rejects.toThrow( + /fixed schema; setActiveVersionWithPreflight is unsupported/u, + ); + const after = await backend.getActiveSchema(graphId); + expect(after?.version).toBe(2); + await branch.close(); + } finally { + await pool.end(); + } + }, 60_000); + it("bounds ledger migration locks and accepts concurrent legacy upgrades", async () => { const pool = new Pool({ connectionString: TEST_DATABASE_URL, max: 5 }); const lockClient = await pool.connect(); diff --git a/packages/typegraph/tests/backends/postgres/provenance-advisory-lock.test.ts b/packages/typegraph/tests/backends/postgres/provenance-advisory-lock.test.ts index d762bb663..26f48ef02 100644 --- a/packages/typegraph/tests/backends/postgres/provenance-advisory-lock.test.ts +++ b/packages/typegraph/tests/backends/postgres/provenance-advisory-lock.test.ts @@ -232,6 +232,27 @@ async function acquireLock( // the only honest reason to skip a server suite; anything else — a refused // connection, a migration that no longer applies — is a failure of this lane, // and routing it through the same skip made the two indistinguishable. +/** One source, one justification and the one fact they support. */ +async function seedLinearSupport( + store: Awaited>, +) { + const source = await store.nodes.Source.create( + { label: "source-a", retracted: false }, + { id: "source-a" }, + ); + const fact = await store.nodes.Fact.create( + { label: "fact-a" }, + { id: "fact-a" }, + ); + const justification = await store.nodes.Justification.create( + { label: "justification-a" }, + { id: "justification-a" }, + ); + await store.edges.premiseOf.create(source, justification); + await store.edges.derives.create(justification, fact); + return { source, fact }; +} + describe.runIf(process.env["POSTGRES_URL"])( "recorded graph-write advisory lock (Postgres)", () => { @@ -370,6 +391,48 @@ describe.runIf(process.env["POSTGRES_URL"])( }); }); + it("holding() reads on a read-only session: its exclusion writes nothing", async () => { + const targetPool = requirePostgres(); + const { fact } = await seedLinearSupport( + await createGraphStore(targetPool), + ); + // Every transaction of this pool is read-only, as under a role or + // database default: any INSERT or UPDATE is refused by the server. + const readOnlyPool = new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + options: "-c default_transaction_read_only=on", + }); + try { + const reader = await createGraphStore(readOnlyPool); + + await expect( + createRetractionCapability(reader, config).holding(), + ).resolves.toEqual([{ kind: "Fact", id: fact.id }]); + } finally { + await readOnlyPool.end(); + } + }); + + it("holding() still waits for a writer holding the graph-write lock", async () => { + const targetPool = requirePostgres(); + const store = await createGraphStore(targetPool); + const { fact } = await seedLinearSupport(store); + + const clientA = await targetPool.connect(); + heldClients.push(clientA); + await clientA.query("BEGIN"); + await acquireLock(clientA, recordedGraphWriteAdvisoryLockSql(graph.id)); + + const pendingRead = createRetractionCapability(store, config).holding(); + expect(await raceTimeout(pendingRead, 500)).toBe(TIMEOUT_SENTINEL); + + await clientA.query("COMMIT"); + await expect(pendingRead).resolves.toEqual([ + { kind: "Fact", id: fact.id }, + ]); + }); + it("resolves without deadlocking against a concurrent ordinary write to the retracted source", async () => { const targetPool = requirePostgres(); const store = await createGraphStore(targetPool); diff --git a/packages/typegraph/tests/backends/postgres/schema-commit-isolation.test.ts b/packages/typegraph/tests/backends/postgres/schema-commit-isolation.test.ts new file mode 100644 index 000000000..380452313 --- /dev/null +++ b/packages/typegraph/tests/backends/postgres/schema-commit-isolation.test.ts @@ -0,0 +1,322 @@ +/** + * A PostgreSQL schema commit's data checks under a snapshot-isolation server + * default. + * + * A managed writer holds the graph's active schema row `FOR SHARE` for its + * whole transaction and changes nothing in it. A schema commit waits for that + * writer on its `FOR UPDATE`, and the commit's tightening preflight then reads + * the graph. At `REPEATABLE READ` or `SERIALIZABLE` the commit's snapshot was + * taken by its first statement, before the wait, and a lock granted after a + * share-locker commits raises no serialization failure — so the preflight would + * read the graph as it was before the writer committed. The schema-write + * transaction therefore asks for `READ COMMITTED` whatever the server default + * is, and the fence statement confirms the session got it. + * + * Server PostgreSQL only: the race needs a second connection holding the + * writer's transaction open while the commit waits. + */ +import { sql } from "drizzle-orm"; +import { drizzle, type NodePgDatabase } from "drizzle-orm/node-postgres"; +import { Pool } from "pg"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + MigrationError, +} from "../../../src"; +import { generatePostgresMigrationSQL } from "../../../src/backend/drizzle/ddl"; +import { + createPostgresBackend, + createPostgresTransactionBackend, +} from "../../../src/backend/postgres"; +import { requireDefined } from "../../../src/utils/presence"; +import { createGate } from "../../concurrency-utils"; +import { provisionPostgresTestDatabase } from "../../postgres-test-database"; +import { runServerSuiteSetup } from "./server-suite-setup"; + +const TEST_DATABASE_URL = await provisionPostgresTestDatabase(import.meta.url); + +const SNAPSHOT_DEFAULTS = ["repeatable read", "serializable"] as const; +type SnapshotDefault = (typeof SNAPSHOT_DEFAULTS)[number]; + +const LOCK_WAIT_POLL_MS = 20; + +const Task = defineNode("Task", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +function taskGraph(id: string, acyclic: boolean) { + return defineGraph({ + id, + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + ...(acyclic ? { acyclic: true } : {}), + }, + }, + }); +} + +let writerPool: Pool | undefined; +let observerPool: Pool | undefined; +const migratorPools = new Map(); + +function createPool(defaultIsolation?: SnapshotDefault): Pool { + return new Pool({ + connectionString: TEST_DATABASE_URL, + connectionTimeoutMillis: 5000, + max: 2, + ...(defaultIsolation === undefined ? + {} + : { + options: `-c default_transaction_isolation=${defaultIsolation.replace(" ", String.raw`\ `)}`, + }), + }); +} + +beforeAll(async () => { + if (!process.env["POSTGRES_URL"]) return; + const writer = createPool(); + const observer = createPool(); + const migrators = SNAPSHOT_DEFAULTS.map( + (level) => [level, createPool(level)] as const, + ); + await runServerSuiteSetup( + "schema-commit-isolation", + [writer, observer, ...migrators.map(([, pool]) => pool)], + async () => { + await writer.query(generatePostgresMigrationSQL()); + await observer.query("SELECT 1"); + for (const [level, pool] of migrators) { + const { rows } = await pool.query<{ level: string }>( + "SELECT current_setting('default_transaction_isolation') AS level", + ); + if (rows[0]?.level !== level) { + throw new Error( + `The migrator pool runs at ${String(rows[0]?.level)}, not ${level}.`, + ); + } + migratorPools.set(level, pool); + } + writerPool = writer; + observerPool = observer; + }, + ); +}); + +afterAll(async () => { + await writerPool?.end(); + await observerPool?.end(); + for (const pool of migratorPools.values()) await pool.end(); +}); + +/** Resolves once some session of THIS database is parked on a lock. */ +async function untilSessionWaitsOnLock(observer: Pool): Promise { + for (;;) { + const { rows } = await observer.query<{ waiting: string }>( + `SELECT count(*) AS waiting FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`, + ); + if (Number(rows[0]?.waiting) > 0) return; + await new Promise((resolve) => setTimeout(resolve, LOCK_WAIT_POLL_MS)); + } +} + +type SessionState = Readonly<{ + lockTimeout: string; + advisoryLocks: number; +}>; + +/** The caller-visible state a refused schema write must leave untouched. */ +async function readSessionState( + tx: Pick, +): Promise { + const { rows } = await tx.execute(sql` + SELECT current_setting('lock_timeout') AS lock_timeout, + (SELECT count(*) FROM pg_locks + WHERE locktype = 'advisory' AND pid = pg_backend_pid()) AS advisory_locks + `); + return { + lockTimeout: String(rows[0]?.["lock_timeout"]), + advisoryLocks: Number(rows[0]?.["advisory_locks"]), + }; +} + +/** The error `work` rejects with; a resolved `work` fails the test. */ +async function rejection(work: Promise): Promise { + try { + await work; + } catch (error) { + return error; + } + throw new Error("Expected the schema write to be refused."); +} + +describe.runIf(process.env["POSTGRES_URL"])( + "schema commit isolation under a snapshot server default (PostgreSQL)", + () => { + for (const level of SNAPSHOT_DEFAULTS) { + it(`refuses an acyclic tightening over a cycle a writer committed while the commit waited, at a ${level} default`, async () => { + const graphId = `schema-commit-isolation-${level.replace(" ", "-")}`; + const writerBackend = createPostgresBackend( + drizzle(requireDefined(writerPool)), + ); + const [writer] = await createStoreWithSchema( + taskGraph(graphId, false), + writerBackend, + ); + const a = await writer.nodes.Task.create({}, { id: "a" }); + const b = await writer.nodes.Task.create({}, { id: "b" }); + await writer.edges.dependsOn.create(a, b); + + const closing = createGate(); + const holding = createGate(); + const closingWrite = writer.transaction(async (tx) => { + await tx.edges.dependsOn.create(b, a); + holding.open(); + await closing.opened; + }); + await holding.opened; + + const migration = rejection( + createStoreWithSchema( + taskGraph(graphId, true), + createPostgresBackend( + drizzle(requireDefined(migratorPools.get(level))), + ), + ), + ); + // A commit refused before it reaches the schema row never waits; the + // race lets the assertions below report that refusal instead. + await Promise.race([ + untilSessionWaitsOnLock(requireDefined(observerPool)), + migration, + ]); + closing.open(); + await closingWrite; + const refusal = await migration; + + expect(refusal).toBeInstanceOf(MigrationError); + expect((refusal as MigrationError).details).toMatchObject({ + reason: "ontology-tightening-violated", + violations: [ + expect.objectContaining({ + family: "edgeAcyclicity", + relation: "dependsOn", + }), + ], + }); + const active = await writerBackend.getActiveSchema(graphId); + expect(active?.version).toBe(1); + }); + } + + it("refuses to fence a schema write on a caller's repeatable read transaction", async () => { + const db = drizzle(requireDefined(writerPool)); + const adopt = requireDefined( + createPostgresBackend(db).adoptSchemaWriteTransaction, + ); + + const refusal = await rejection( + db.transaction( + (tx) => + adopt(tx, "schema-commit-isolation-adopted", { + waitBudgetMs: 5000, + }), + { isolationLevel: "repeatable read" }, + ), + ); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toMatchObject({ + code: "SCHEMA_WRITE_REQUIRES_READ_COMMITTED", + isolation: "repeatable_read", + }); + }); + + it("leaves a caller's repeatable read transaction as it found it after refusing to fence a schema write", async () => { + const db = drizzle(requireDefined(writerPool)); + const adopt = requireDefined( + createPostgresBackend(db).adoptSchemaWriteTransaction, + ); + + const { before, refusal, after } = await db.transaction( + async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '30s'`); + const stateBefore = await readSessionState(tx); + const caught = await rejection( + adopt(tx, "schema-commit-isolation-adopted-residue", { + waitBudgetMs: 5000, + }), + ); + return { + before: stateBefore, + refusal: caught, + after: await readSessionState(tx), + }; + }, + { isolationLevel: "repeatable read" }, + ); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toMatchObject({ + code: "SCHEMA_WRITE_REQUIRES_READ_COMMITTED", + }); + expect(before).toEqual({ lockTimeout: "30s", advisoryLocks: 0 }); + expect(after).toEqual(before); + }); + + it("refuses a schema write through a transaction-scoped backend on a caller's repeatable read transaction, holding no schema fence afterwards", async () => { + const db = drizzle(requireDefined(writerPool)); + const graphId = "schema-commit-isolation-transaction-scoped"; + + const { refusal, after } = await db.transaction( + async (tx) => { + const caught = await rejection( + createStoreWithSchema( + taskGraph(graphId, false), + createPostgresTransactionBackend(tx), + ), + ); + return { refusal: caught, after: await readSessionState(tx) }; + }, + { isolationLevel: "repeatable read" }, + ); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toMatchObject({ + code: "SCHEMA_WRITE_REQUIRES_READ_COMMITTED", + graphId, + isolation: "repeatable_read", + }); + expect(after.advisoryLocks).toBe(0); + const active = await createPostgresBackend(db).getActiveSchema(graphId); + expect(active).toBeUndefined(); + }); + + it("commits a schema write through a transaction-scoped backend on a caller's read committed transaction", async () => { + const db = drizzle(requireDefined(writerPool)); + const graphId = "schema-commit-isolation-transaction-scoped-rc"; + + await db.transaction( + async (tx) => { + await createStoreWithSchema( + taskGraph(graphId, false), + createPostgresTransactionBackend(tx), + ); + }, + { isolationLevel: "read committed" }, + ); + + const active = await createPostgresBackend(db).getActiveSchema(graphId); + expect(active?.version).toBe(1); + }); + }, +); diff --git a/packages/typegraph/tests/backends/sqlite/edge-acyclicity-query-plan.test.ts b/packages/typegraph/tests/backends/sqlite/edge-acyclicity-query-plan.test.ts new file mode 100644 index 000000000..69af18388 --- /dev/null +++ b/packages/typegraph/tests/backends/sqlite/edge-acyclicity-query-plan.test.ts @@ -0,0 +1,224 @@ +/** + * The acyclicity probe (`src/store/recursive-cte.ts`'s + * `buildEdgeAcyclicityProbe`) must be an index seek on + * `typegraph_edges_from_idx` (or, for a mixed-orientation relation, also + * `typegraph_edges_to_idx`) for every real write path — never a scan of the + * whole relation. + * + * The defect this guards against (measured via `EXPLAIN QUERY + * PLAN` on the real probe): the pre-fix probe folded the relation's live + * edges and the proposed `seed` rows into one compound + * `candidates(...) AS (SELECT ... FROM typegraph_edges ... UNION ALL SELECT + * ... FROM seed)` CTE, and the recursive term joined `candidates`. SQLite + * cannot flatten a compound subquery whose outer query is a join + * (query-flattener rule 17d), so the plan was `MATERIALIZE candidates` — the + * ENTIRE relation copied into an ephemeral table on EVERY probe, making + * every acyclic insert O(|relation|) instead of an index seek. Every seed form + * now joins `typegraph_edges` directly — see `AcyclicityProbeSeed`'s + * docblock. + * + * Mutation check: temporarily routing the walk back through a compound + * `candidates` CTE makes every test below fail — `MATERIALIZE` reappears in + * the plan and the from/to-index seek disappears. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStore, defineEdge, defineGraph, defineNode } from "../../../src"; +import { resolveRecursiveTraversal } from "../../../src/backend/capabilities/recursive-traversal"; +import { createSqlSchema } from "../../../src/query/compiler/schema"; +import { sqliteDialect } from "../../../src/query/dialect"; +import { renderSqlite } from "../../../src/query/sql-fragment"; +import { type AcyclicRelationMember } from "../../../src/store/acyclicity"; +import { buildEdgeAcyclicityProbe } from "../../../src/store/recursive-cte"; +import { requireDefined } from "../../../src/utils/presence"; +import { createPlanCaptureBackend, explainQueryPlan } from "../../test-utils"; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); +const Milestone = defineNode("Milestone", { + schema: z.object({ name: z.string() }), +}); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); +const blockedBy = defineEdge("blockedBy", { schema: z.object({}) }); + +function buildGraph() { + return defineGraph({ + id: "acyclicity-query-plan", + nodes: { Task: { type: Task }, Milestone: { type: Milestone } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + blockedBy: { type: blockedBy, from: [Task], to: [Task, Milestone] }, + }, + }); +} + +/** The most recent captured statement that IS the acyclicity probe. */ +function findProbeStatement( + captured: readonly Readonly<{ sql: string; params: readonly unknown[] }>[], +) { + return requireDefined( + captured.find((statement) => statement.sql.includes("ancestry(")), + "no acyclicity-probe statement was captured", + ); +} + +describe("edge-acyclicity probe: query plan", () => { + it("a single create's probe seeks typegraph_edges_from_idx directly, no MATERIALIZE candidates", async () => { + const { backend, captured, client } = createPlanCaptureBackend(); + const store = createStore(buildGraph(), backend); + + // Populate the relation with a real chain, so the probe runs against a + // non-trivial live population rather than an empty table. + const nodes = await Promise.all( + Array.from({ length: 6 }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + for (let index = 0; index < nodes.length - 2; index += 1) { + await store.edges.dependsOn.create( + requireDefined(nodes[index]), + requireDefined(nodes[index + 1]), + ); + } + await store.refreshStatistics(); + + captured.length = 0; + // Extends the chain by one more edge — not a cycle. + await store.edges.dependsOn.create( + requireDefined(nodes.at(-2)), + requireDefined(nodes.at(-1)), + ); + + const probeStatement = findProbeStatement(captured); + const plan = explainQueryPlan(client, probeStatement); + + expect(plan).toMatch(/typegraph_edges_from_idx/); + // `MATERIALIZE seed` (the small proposed-row VALUES list) is fine and + // expected; `MATERIALIZE candidates` — the whole-relation copy — is the + // regression this test exists to catch, and cannot appear at all now + // that the `"proposed"` form has no `candidates` CTE. + // No standalone "no full scan" assertion here: verified empirically (by the + // mutation check) that reverting to the old compound + // `candidates` CTE makes SQLite populate it via an INDEX SEARCH on + // `typegraph_edges_to_idx` (filtered only by `graph_id`), never a bare + // `SCAN e` — a scan-shaped assertion would never fire. The + // `from_idx`/`MATERIALIZE candidates` pair above already fails under + // that exact mutation and is the real backstop. + expect(plan).not.toContain("MATERIALIZE candidates"); + }); + + it("a bulkCreate batch's pre-insert probe seeks typegraph_edges_from_idx directly, no MATERIALIZE candidates", async () => { + const { backend, captured, client } = createPlanCaptureBackend(); + const store = createStore(buildGraph(), backend); + + const nodes = await Promise.all( + Array.from({ length: 6 }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + for (let index = 0; index < nodes.length - 3; index += 1) { + await store.edges.dependsOn.create( + requireDefined(nodes[index]), + requireDefined(nodes[index + 1]), + ); + } + await store.refreshStatistics(); + + captured.length = 0; + // Two more edges in ONE batch, extending the chain — not a cycle. The + // batch's own probe runs BEFORE this insert, with both rows as origins. + await store.edges.dependsOn.bulkCreate([ + { from: requireDefined(nodes.at(-3)), to: requireDefined(nodes.at(-2)) }, + { from: requireDefined(nodes.at(-2)), to: requireDefined(nodes.at(-1)) }, + ]); + + const probeStatement = findProbeStatement(captured); + expect(probeStatement.sql).toContain("VALUES"); + const plan = explainQueryPlan(client, probeStatement); + + expect(plan).toMatch(/typegraph_edges_from_idx/); + // No standalone "no full scan" assertion here: verified empirically (by the + // mutation check) that reverting to the old compound + // `candidates` CTE makes SQLite populate it via an INDEX SEARCH on + // `typegraph_edges_to_idx` (filtered only by `graph_id`), never a bare + // `SCAN e` — a scan-shaped assertion would never fire. The + // `from_idx`/`MATERIALIZE candidates` pair above already fails under + // that exact mutation and is the real backstop. + expect(plan).not.toContain("MATERIALIZE candidates"); + }); + + it("a mixed-orientation relation's audit probe seeks BOTH from_idx and to_idx directly (MULTI-INDEX OR), no candidates CTE", async () => { + const { backend, client } = createPlanCaptureBackend(); + const store = createStore(buildGraph(), backend); + + // `blockedBy` is not itself `acyclic: true` in this fixture — the mixed + // relation below is constructed directly, in the composition style, exactly as + // tests/edge-acyclicity.test.ts's mixed-orientation fixture does. + // A larger population than the other two tests: SQLite's OR-optimization + // (`MULTI-INDEX OR`, seeking BOTH `typegraph_edges_from_idx` and + // `typegraph_edges_to_idx`) is a cost-based choice the planner declines + // at trivial cardinality, seeking only one index and filtering the rest + // — still correct, but not what this test is pinning. + const tasks = await Promise.all( + Array.from({ length: 40 }, (_unused, index) => + store.nodes.Task.create({ name: `t${String(index)}` }), + ), + ); + const milestones = await Promise.all( + Array.from({ length: 40 }, (_unused, index) => + store.nodes.Milestone.create({ name: `m${String(index)}` }), + ), + ); + for (let index = 0; index < 30; index += 1) { + await backend.insertEdge({ + graphId: "acyclicity-query-plan", + id: `f${String(index)}`, + kind: "dependsOn", + fromKind: "Task", + fromId: requireDefined(tasks[index]).id, + toKind: "Task", + toId: requireDefined(tasks[index + 1]).id, + props: {}, + }); + await backend.insertEdge({ + graphId: "acyclicity-query-plan", + id: `r${String(index)}`, + kind: "blockedBy", + fromKind: "Task", + fromId: requireDefined(tasks[index]).id, + toKind: "Milestone", + toId: requireDefined(milestones[index]).id, + props: {}, + }); + } + await store.refreshStatistics(); + + const members: readonly AcyclicRelationMember[] = [ + { edgeKind: "dependsOn", reversed: false, membership: "allRows" }, + { edgeKind: "blockedBy", reversed: true, membership: "allRows" }, + ]; + const fragment = buildEdgeAcyclicityProbe({ + graphId: "acyclicity-query-plan", + members, + seed: { kind: "relation" }, + dialect: sqliteDialect, + schema: createSqlSchema(backend.tableNames), + recursiveTraversal: resolveRecursiveTraversal(backend.capabilities), + operation: "test", + }); + const rendered = renderSqlite(fragment); + expect(rendered.sql).not.toContain("candidates("); + + const plan = explainQueryPlan(client, rendered); + expect(plan).toContain("MULTI-INDEX OR"); + expect(plan).toMatch(/typegraph_edges_from_idx/); + expect(plan).toMatch(/typegraph_edges_to_idx/); + expect(plan).not.toContain("MATERIALIZE candidates"); + }); +}); diff --git a/packages/typegraph/tests/base-schema-adoption.test.ts b/packages/typegraph/tests/base-schema-adoption.test.ts index 9f1a28668..2503c27e7 100644 --- a/packages/typegraph/tests/base-schema-adoption.test.ts +++ b/packages/typegraph/tests/base-schema-adoption.test.ts @@ -14,10 +14,12 @@ import { z } from "zod"; import type { GraphBackend, ReconciledSchema } from "../src"; import { BaseSchemaMigrationError, + createStore, createStoreWithSchema, createVerifiedStore, defineEdge, defineGraph, + defineGraphExtension, defineNode, instantiateGraphTemplate, registerGraphTemplate, @@ -1225,6 +1227,45 @@ describe("deployment-wide base-schema adoption", () => { } }); + it("catches an installed version-5 PGlite database up to version 6 through adoptBaseSchema() directly, gaining the identity transition log", async () => { + // `adoptBaseSchema()` is the offline path, which never calls + // `generateDdl()`, so only the version-6 step body itself can recreate + // the relations a real version-5 deployment lacks — including the + // transition log's `restored_at` column, which ships with the table. + const { backend, client } = await createLocalPgliteBackend({ + vector: false, + }); + try { + await createStoreWithSchema(graph, backend); + await client.exec( + [ + 'DROP TABLE "typegraph_identity_transitions"', + 'DROP TABLE "typegraph_identity_transition_retention"', + 'UPDATE "typegraph_base_schema_versions" SET version = 5 WHERE installation = 1', + ].join(";\n"), + ); + + await requireDefined(backend.adoptBaseSchema)(); + + const advancedMarker = await client.query<{ version: number }>( + 'SELECT version FROM "typegraph_base_schema_versions" WHERE installation = 1', + ); + expect(advancedMarker.rows[0]?.version).toBe(CURRENT_BASE_SCHEMA_VERSION); + const columns = await client.query<{ column_name: string }>( + "SELECT column_name FROM information_schema.columns WHERE table_name = 'typegraph_identity_transitions'", + ); + expect(columns.rows.map((row) => row.column_name)).toContain( + "restored_at", + ); + const retention = await client.query<{ table_name: string }>( + "SELECT table_name FROM information_schema.tables WHERE table_name = 'typegraph_identity_transition_retention'", + ); + expect(retention.rows).toHaveLength(1); + } finally { + await backend.close(); + } + }); + it.each([ ["missing", undefined], ["stale", 0], @@ -1287,6 +1328,91 @@ describe("deployment-wide base-schema adoption", () => { } }); + it("catches an installed version-5 SQLite database up to version 6, gaining the identity transition log", async () => { + const { backend, db } = createLocalSqliteBackend(); + const client = sqliteClient(db); + try { + await createStoreWithSchema(graph, backend); + client.exec( + [ + "DROP TABLE typegraph_identity_transitions", + "DROP TABLE typegraph_identity_transition_retention", + "UPDATE typegraph_base_schema_versions SET version = 5 WHERE installation = 1", + ].join(";\n"), + ); + + await createStoreWithSchema(graph, backend); + + expect(markerVersion(client, "typegraph_base_schema_versions")).toBe( + CURRENT_BASE_SCHEMA_VERSION, + ); + const columns = client + .prepare(`PRAGMA table_info("typegraph_identity_transitions")`) + .all() as readonly Readonly<{ name: string }>[]; + expect(columns.map((column) => column.name)).toContain("restored_at"); + expect( + client + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get("typegraph_identity_transition_retention"), + ).toEqual({ name: "typegraph_identity_transition_retention" }); + } finally { + await backend.close(); + } + }); + + // Load-bearing: `Store.evolve` checks identity storage ahead of its schema + // commit, as opening a store with its schema does, and that check reads a + // missing identity relation on an enabled graph as lost data. Revert check: + // have `evolve` call `ensureIdentitySchemaStorage` without adopting base + // storage first and this rejects with ConfigurationError + // IDENTITY_STORAGE_MISSING naming the two relations the upgrade adds. + it("evolves an identity graph attached to a version-5 SQLite database without a privileged open", async () => { + const identityGraph = defineGraph({ + id: "base_schema_adoption_evolve", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const { backend, db } = createLocalSqliteBackend(); + const client = sqliteClient(db); + try { + const [installed] = await createStoreWithSchema(identityGraph, backend, { + history: true, + }); + await installed.nodes.Person.create({ name: "Ada" }); + client.exec( + [ + "DROP TABLE typegraph_identity_transitions", + "DROP TABLE typegraph_identity_transition_retention", + "UPDATE typegraph_base_schema_versions SET version = 5 WHERE installation = 1", + ].join(";\n"), + ); + + const attached = createStore(identityGraph, backend, { history: true }); + const evolved = await attached.evolve( + defineGraphExtension({ + nodes: { Tag: { properties: { label: { type: "string" } } } }, + }), + ); + + expect(evolved.registry.hasNodeType("Tag")).toBe(true); + expect(markerVersion(client, "typegraph_base_schema_versions")).toBe( + CURRENT_BASE_SCHEMA_VERSION, + ); + expect( + client + .prepare( + "SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?", + ) + .get("typegraph_identity_transitions"), + ).toEqual({ name: "typegraph_identity_transitions" }); + } finally { + await backend.close(); + } + }); + it("refuses to downgrade a newer base marker during bootstrap", async () => { const { backend, db } = createLocalSqliteBackend(); const client = sqliteClient(db); diff --git a/packages/typegraph/tests/bulk-create-batching.test.ts b/packages/typegraph/tests/bulk-create-batching.test.ts index 01f2b318e..7493bc4d2 100644 --- a/packages/typegraph/tests/bulk-create-batching.test.ts +++ b/packages/typegraph/tests/bulk-create-batching.test.ts @@ -12,16 +12,24 @@ * split, alongside the behavioral semantics that must not drift: * in-batch conflicts, conflicts with existing rows, and create-over- * tombstone. + * + * The same accounting covers a batch whose items carry `partOf`: the whole + * rows go through one getNodes per kind (never one getNode per item, and never + * a read of the part row the batch's own insert just wrote), and the + * acyclicity relation is walked once for the whole batch rather than once per + * attaching item. */ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { asNodeId, createStoreWithSchema, + defineEdge, defineGraph, defineNode, embedding, + partOf, searchable, } from "../src"; import { @@ -31,7 +39,8 @@ import { } from "../src/backend/derive-backend"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; import type { GraphBackend, TransactionBackend } from "../src/backend/types"; -import { UniquenessError } from "../src/errors"; +import { EndpointNotFoundError, UniquenessError } from "../src/errors"; +import * as acyclicityModule from "../src/store/acyclicity"; import { requireDefined } from "../src/utils/presence"; const Person = defineNode("Person", { @@ -46,6 +55,27 @@ const Document = defineNode("Doc", { }), }); +/** A reflexive composition pair: the only shape in which a create batch's own items can close a cycle. */ +const Folder = defineNode("Folder", { schema: z.object({ name: z.string() }) }); +const folderOf = defineEdge("folderOf", { schema: z.object({}) }); + +function buildCompositionGraph() { + return defineGraph({ + id: "bulk-batching-composition", + nodes: { Folder: { type: Folder } }, + edges: { + folderOf: { + type: folderOf, + from: [Folder], + to: [Folder], + cardinality: "one", + }, + }, + // Reflexive, so the orientation must be stated explicitly. + ontology: [partOf(Folder, Folder, { via: folderOf, partSide: "from" })], + }); +} + function buildGraph() { return defineGraph({ id: "bulk-batching", @@ -128,11 +158,15 @@ function withCallCounts(backend: GraphBackend): { return { backend: counted, counts }; } -async function withCountedStore( +/** + * The counted backend and a store on it, with every call the store's own boot + * made already discounted — so a test's assertions describe only what its own + * `run` body triggered. Shared by both graphs' helpers below, which differ + * only in the graph they build (and so in the store type they hand back). + */ +async function withCountedBackend( run: ( - store: Awaited< - ReturnType>> - >[0], + backend: GraphBackend, counts: CallCounts, raw: GraphBackend, ) => Promise, @@ -140,16 +174,55 @@ async function withCountedStore( const { backend: raw } = createLocalSqliteBackend(); try { const { backend, counts } = withCallCounts(raw); - const [store] = await createStoreWithSchema(buildGraph(), backend); - // Boot traffic is not under test — count only what `run` triggers. - for (const name of COUNTED_METHODS) counts[name] = 0; - counts["transaction"] = 0; - return await run(store, counts, raw); + return await run(backend, counts, raw); } finally { await raw.close(); } } +function resetCounts(counts: CallCounts): void { + for (const name of COUNTED_METHODS) counts[name] = 0; + counts["transaction"] = 0; +} + +async function withCountedStore( + run: ( + store: Awaited< + ReturnType>> + >[0], + counts: CallCounts, + raw: GraphBackend, + ) => Promise, +): Promise { + return withCountedBackend(async (backend, counts, raw) => { + const [store] = await createStoreWithSchema(buildGraph(), backend); + // Boot traffic is not under test — count only what `run` triggers. + resetCounts(counts); + return run(store, counts, raw); + }); +} + +async function withCountedCompositionStore( + run: ( + store: Awaited< + ReturnType< + typeof createStoreWithSchema> + > + >[0], + counts: CallCounts, + raw: GraphBackend, + ) => Promise, +): Promise { + return withCountedBackend(async (backend, counts, raw) => { + const [store] = await createStoreWithSchema( + buildCompositionGraph(), + backend, + ); + resetCounts(counts); + return run(store, counts, raw); + }); +} + const BATCH_SIZE = 40; function personInputs(offset = 0) { @@ -527,3 +600,170 @@ describe("bulkCreate batching semantics (must not drift)", () => { }); }); }); + +describe("bulkCreate composition attach batching", () => { + const ATTACHING_BATCH_SIZE = 8; + + it("reads the whole rows once per kind, and never re-reads the part rows it just wrote", async () => { + await withCountedCompositionStore(async (store, counts) => { + const whole = await store.nodes.Folder.create({ name: "whole" }); + resetCounts(counts); + + const parts = await store.nodes.Folder.bulkCreate( + Array.from({ length: ATTACHING_BATCH_SIZE }, (_, index) => ({ + props: { name: `part-${index}` }, + partOf: { whole: { kind: "Folder" as const, id: whole.id } }, + })), + ); + expect(parts).toHaveLength(ATTACHING_BATCH_SIZE); + + // One getNodes for the single distinct whole; zero getNode, which is + // what rules out both a per-item whole probe and a part-row re-read + // (the items' own ids are generated, so nothing else reads a node). + expect(counts["getNodes"]).toBe(1); + expect(counts["getNode"]).toBe(0); + }); + }); + // MUTATION CHECK: have `prepareBatchCompositionCreateEdges` + // (src/store/operations/node-operations.ts) prepare each item with + // `endpoints: { source: "read" }` instead of the primed whole. Both endpoint + // rows are then read per item and `getNode` counts 16 instead of 0. + + it("walks the acyclicity relation once for the whole batch", async () => { + await withCountedCompositionStore(async (store) => { + const whole = await store.nodes.Folder.create({ name: "whole" }); + const probe = vi.spyOn( + acyclicityModule, + "assertUnwrittenEdgeRelationsAcyclic", + ); + try { + await store.nodes.Folder.bulkCreate( + Array.from({ length: ATTACHING_BATCH_SIZE }, (_, index) => ({ + props: { name: `part-${index}` }, + partOf: { whole: { kind: "Folder" as const, id: whole.id } }, + })), + ); + expect(probe).toHaveBeenCalledTimes(1); + expect(probe.mock.calls[0]?.[1]).toHaveLength(ATTACHING_BATCH_SIZE); + } finally { + probe.mockRestore(); + } + }); + }); + // MUTATION CHECK: restore the per-item probe (prepare with + // `validateAcyclicity: true` and drop the single + // `assertPreparedEdgeCreatesAcyclic` call) — the probe is then entered 8 + // times, once per attaching item, each with a single proposed edge. + + it("attaches every item of the batch", async () => { + await withCountedCompositionStore(async (store) => { + const whole = await store.nodes.Folder.create({ name: "whole" }); + const parts = await store.nodes.Folder.bulkCreate( + Array.from({ length: ATTACHING_BATCH_SIZE }, (_, index) => ({ + props: { name: `part-${index}` }, + partOf: { whole: { kind: "Folder" as const, id: whole.id } }, + })), + ); + const edges = await store.edges.folderOf.find({}); + expect(edges).toHaveLength(ATTACHING_BATCH_SIZE); + expect(new Set(edges.map((edge) => edge.fromId))).toEqual( + new Set(parts.map((part) => part.id)), + ); + expect(new Set(edges.map((edge) => edge.toId))).toEqual( + new Set([whole.id]), + ); + }); + }); +}); + +/** + * The `getNodes` port is an optimization, so the attach loop keeps a + * per-`(kind, id)` `getNode` fallback. A backend projected WITHOUT the port + * is the only way to reach that arm — and what it must prove is not the read + * count but that the arm reaches the identical verdicts: the same successful + * attachment, and the same refusal on the same endpoint side. + */ +async function withPortlessCompositionStore( + run: ( + store: Awaited< + ReturnType< + typeof createStoreWithSchema> + > + >[0], + counts: CallCounts, + ) => Promise, +): Promise { + return withCountedBackend(async (backend, counts) => { + const portless = projectBackendWithout(backend, ["getNodes"]); + expect("getNodes" in portless).toBe(false); + const [store] = await createStoreWithSchema( + buildCompositionGraph(), + portless, + ); + resetCounts(counts); + return run(store, counts); + }); +} + +describe("bulkCreate composition attach on a backend without the batch point read", () => { + it("attaches every item through the per-key getNode fallback", async () => { + await withPortlessCompositionStore(async (store, counts) => { + const whole = await store.nodes.Folder.create({ name: "whole" }); + resetCounts(counts); + + const parts = await store.nodes.Folder.bulkCreate([ + { + props: { name: "one" }, + partOf: { whole: { kind: "Folder", id: whole.id } }, + }, + { + props: { name: "two" }, + partOf: { whole: { kind: "Folder", id: whole.id } }, + }, + ]); + + const edges = await store.edges.folderOf.find({}); + expect(edges).toHaveLength(2); + expect(new Set(edges.map((edge) => edge.fromId))).toEqual( + new Set(parts.map((part) => part.id)), + ); + // The fallback reads the one distinct whole ONCE, not once per item. + expect(counts["getNodes"]).toBe(0); + expect(counts["getNode"]).toBe(1); + }); + }); + // MUTATION CHECK: make `readBatchCompositionWholeRows` + // (src/store/operations/node-operations.ts) return an empty map when the port + // is unreachable instead of falling back — both items then refuse with + // EndpointNotFoundError against a whole that is demonstrably live. + + it("refuses a missing whole with the identical error the ported path raises", async () => { + await withPortlessCompositionStore(async (store) => { + const whole = await store.nodes.Folder.create({ name: "whole" }); + const before = await store.nodes.Folder.count(); + + const error = await store.nodes.Folder.bulkCreate([ + { + props: { name: "ok" }, + partOf: { whole: { kind: "Folder", id: whole.id } }, + }, + { + props: { name: "orphan" }, + partOf: { whole: { kind: "Folder", id: "no-such-folder" } }, + }, + ]).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(EndpointNotFoundError); + expect((error as EndpointNotFoundError).details).toMatchObject({ + edgeKind: "folderOf", + // `folderOf` carries the part on its `from` side, so the WHOLE is `to`. + endpoint: "to", + nodeKind: "Folder", + nodeId: "no-such-folder", + }); + // One refused item aborts the whole batch: neither node row survives. + expect(await store.nodes.Folder.count()).toBe(before); + expect(await store.edges.folderOf.find({})).toHaveLength(0); + }); + }); +}); diff --git a/packages/typegraph/tests/bulk-get-or-create-batch-verdicts.test.ts b/packages/typegraph/tests/bulk-get-or-create-batch-verdicts.test.ts new file mode 100644 index 000000000..364ca06bc --- /dev/null +++ b/packages/typegraph/tests/bulk-get-or-create-batch-verdicts.test.ts @@ -0,0 +1,165 @@ +/** + * What `bulkGetOrCreateByConstraint` reads, and when, around the batch's first + * write. + * + * The cross-backend cases (`tests/backends/integration/composition-attachment.ts`) + * pin which refusals precede the first write. These two pin what only a + * dedicated graph or a spy on the backend can: the identity fold a restored + * row owes is judged before the batch writes, and an already-satisfied batch + * reads each part's attachment once. + */ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + createStore, + defineEdge, + defineGraph, + defineNode, + disjointWith, + IdentityContradictionError, + partOf, +} from "../src"; +import type * as CompositionCreate from "../src/store/operations/composition-create"; +import { findLiveCompositionAttachment } from "../src/store/operations/composition-create"; +import { createTestBackend } from "./test-utils"; + +// Counts the attachment reads the operations layer issues (calls made from +// inside `composition-create` itself are not routed through the mock). +vi.mock( + "../src/store/operations/composition-create", + async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + findLiveCompositionAttachment: vi.fn( + actual.findLiveCompositionAttachment, + ), + }; + }, +); + +const attachmentReads = vi.mocked(findLiveCompositionAttachment); + +const Ghost = defineNode("Ghost", { + schema: z.object({ slug: z.string(), note: z.string().optional() }), +}); +const Spirit = defineNode("Spirit", { schema: z.object({}) }); +const Other = defineNode("Other", { schema: z.object({}) }); + +const identityGraph = defineGraph({ + id: "bulk_get_or_create_identity_verdicts", + nodes: { + Ghost: { + type: Ghost, + unique: [ + { + name: "ghost_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], + }, + Spirit: { type: Spirit }, + Other: { type: Other }, + }, + edges: {}, + ontology: [disjointWith(Ghost, Spirit)], + identity: { sameIdAcrossKinds: "fold" }, +}); + +const Shelf = defineNode("Shelf", { schema: z.object({}) }); +const Book = defineNode("Book", { schema: z.object({ slug: z.string() }) }); +const bookOn = defineEdge("bookOn", { schema: z.object({}) }); + +const shelfGraph = defineGraph({ + id: "bulk_get_or_create_attachment_reads", + nodes: { + Shelf: { type: Shelf }, + Book: { + type: Book, + unique: [ + { + name: "book_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + bookOn: { type: bookOn, from: [Book], to: [Shelf], cardinality: "one" }, + }, + ontology: [partOf(Book, Shelf, { via: bookOn })], +}); + +describe("bulkGetOrCreateByConstraint batch verdicts", () => { + it("refuses a restore whose identity fold would contradict before updating an earlier item", async () => { + const store = createStore(identityGraph, createTestBackend()); + const live = await store.nodes.Ghost.create({ + slug: "live", + note: "before", + }); + const gone = await store.nodes.Ghost.create({ slug: "gone" }, { id: "x" }); + await store.nodes.Ghost.delete(gone.id); + // "x" now names a live Other whose class holds a Spirit, which is + // disjoint with Ghost: no read of the NODE rows at id "x" sees a + // disjoint kind, only the identity fold does. + const other = await store.nodes.Other.create({}, { id: "x" }); + const spirit = await store.nodes.Spirit.create({}, { id: "y" }); + await store.identity.assertSame(other, spirit); + + // MUTATION CHECK: drop the `ctx.identity?.assertFoldable` read from + // `assertGetOrCreateBatchApplicable` + // (src/store/operations/node-operations.ts) — "live" is then updated + // before the restore's fold refuses, and keeps the new note after the + // caught refusal. + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.Ghost.bulkGetOrCreateByConstraint( + "ghost_slug", + [ + { props: { slug: "live", note: "changed" } }, + { props: { slug: "gone" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(IdentityContradictionError); + const after = await store.nodes.Ghost.getById(live.id); + expect(after?.note).toBe("before"); + expect(after?.meta.version).toBe(live.meta.version); + expect(await store.nodes.Ghost.getById(gone.id)).toBeUndefined(); + }); + + it("reads each already-attached part's attachment once when the whole batch is satisfied", async () => { + const store = createStore(shelfGraph, createTestBackend()); + const shelf = await store.nodes.Shelf.create({}); + const slugs = ["a", "b", "c", "d", "e"]; + for (const slug of slugs) { + await store.nodes.Book.create({ slug }, { partOf: { whole: shelf } }); + } + + attachmentReads.mockClear(); + const results = await store.nodes.Book.bulkGetOrCreateByConstraint( + "book_slug", + slugs.map((slug) => ({ props: { slug } })), + { partOf: { whole: shelf } }, + ); + + // MUTATION CHECK: have `partitionExistingGetOrCreateLegs` judge each + // part's attachment and then write every match through the + // postcondition (which pre-checks again) — the count doubles. + expect(results.map((result) => result.action)).toEqual( + slugs.map(() => "found"), + ); + expect(attachmentReads).toHaveBeenCalledTimes(slugs.length); + }); +}); diff --git a/packages/typegraph/tests/bundle-member-access.test.ts b/packages/typegraph/tests/bundle-member-access.test.ts index b3ff85b2b..1b76b9b28 100644 --- a/packages/typegraph/tests/bundle-member-access.test.ts +++ b/packages/typegraph/tests/bundle-member-access.test.ts @@ -45,7 +45,14 @@ const STATICALLY_REQUIRED_COUNT = 2; // Candidate one-active and durable edge identity planning add four accesses. // The graph storage inventory resolves the graph-relation names once and reads // the session isolation expression once: 123 -> 125. -const REASONED_FLOOR = 125; +// `rollbackSchema` reads the optional preflight flip once; edge acyclicity +// and composition tightening add four table-name reads: 125 -> 130. +// The managed PostgreSQL copy guards the preflight-carrying version flip +// beside its five other schema-writing ports: 130 -> 131. +// Reopening a durable working copy below the current base schema checks and +// calls adoptBaseSchema (+2), and the tightening probe reads its acyclic and +// composition relations through one table-name read (-1): 131 -> 132. +const REASONED_FLOOR = 132; // Cached projection/relation rows and scalar terminals use executeRaw through // the rawStatementReuse bundle; bulk import also adds one endpoint-set read. // Adopted vector evolution checks the root capability before fencing and @@ -68,13 +75,15 @@ const REASONED_FLOOR = 125; // The graph storage inventory and the namespace fork's fulltext provisioning // check each add one guarded strategy access: 236 -> 238 live, 241 -> 243 // declared. -const DEFERRED_LIVE_TOTAL = 238; -const DEFERRED_DECLARED_TOTAL = 243; +// The composition cascade and unattached-part read each add one heterogeneous +// endpoint-set read: 238 -> 240 live, 243 -> 245 declared. +const DEFERRED_LIVE_TOTAL = 240; +const DEFERRED_DECLARED_TOTAL = 245; const EXCLUDED_COUNT = 5; -const TOTAL_ROW_COUNT = 377; +const TOTAL_ROW_COUNT = 386; const ANNOTATED_RESIDUE_KEYS = [ - "backend/migrate-recorded-time.ts:161#executeStatement", - "backend/migrate-recorded-time.ts:168#executeStatement", + "backend/migrate-recorded-time.ts:162#executeStatement", + "backend/migrate-recorded-time.ts:169#executeStatement", "identity/sql-target.ts:101#executeStatement", "identity/sql-target.ts:155#executeStatement", "store/recorded-capture/guards.ts:76#executeStatement", @@ -218,7 +227,7 @@ describe("live bundle member access scan (I6, T21)", () => { expect(scan.byClass.deferred).toBe(DEFERRED_LIVE_TOTAL); }); - it("the class partition covers every scanned row (total 377)", () => { + it("the class partition covers every scanned row (total 385)", () => { // STATICALLY_REQUIRED_SITES asserted positively: each must appear in the // scan output, so an arm-(b) regression that stops resolving them fails // loudly here rather than silently shrinking the bucket. diff --git a/packages/typegraph/tests/capability-bundle-totality.test.ts b/packages/typegraph/tests/capability-bundle-totality.test.ts index caf66647e..c8779cd26 100644 --- a/packages/typegraph/tests/capability-bundle-totality.test.ts +++ b/packages/typegraph/tests/capability-bundle-totality.test.ts @@ -29,19 +29,19 @@ function bundledMembers(): readonly string[] { } describe("capability bundle totality (T9)", () => { - it("16 pilot + 91 unbundled = 107, with no member counted twice", () => { + it("16 pilot + 92 unbundled = 108, with no member counted twice", () => { const bundled = bundledMembers(); const bundledSet = new Set(bundled); expect(bundled.length).toBe(bundledSet.size); expect(bundledSet.size).toBe(16); const unbundledNames = Object.keys(UNBUNDLED_OPTIONAL_MEMBERS); - expect(unbundledNames.length).toBe(91); + expect(unbundledNames.length).toBe(92); const overlap = unbundledNames.filter((name) => bundledSet.has(name)); expect(overlap).toEqual([]); - expect(bundledSet.size + unbundledNames.length).toBe(107); + expect(bundledSet.size + unbundledNames.length).toBe(108); }); it("pairwise bundle member sets are disjoint", () => { @@ -108,11 +108,11 @@ describe("capability bundle totality (T9)", () => { } }); - it("41 reasoned entries sum to 125 accesses; 50 deferred entries sum to 243", () => { + it("42 reasoned entries sum to 132 accesses; 50 deferred entries sum to 245", () => { const entries = Object.values(UNBUNDLED_OPTIONAL_MEMBERS); const reasoned = entries.filter((entry) => entry.kind === "reasoned"); const deferred = entries.filter((entry) => entry.kind === "deferred"); - expect(reasoned.length).toBe(41); + expect(reasoned.length).toBe(42); expect(deferred.length).toBe(50); // B9's scanner corrected two grep-tier undercounts with type-aware // evidence: `tableNames` 22->23 (store/store.ts:1001 holds two accesses @@ -167,7 +167,15 @@ describe("capability bundle totality (T9)", () => { // add four accesses. The graph storage inventory resolves the // graph-relation names through one tableNames access and reads the session // isolation expression through one fenceSql access: 123 -> 125. - expect(reasoned.reduce((sum, entry) => sum + entry.accesses, 0)).toBe(125); + // `rollbackSchema` reads the optional preflight flip once to refuse a + // tightening on a backend without it, edge acyclicity adds three + // tableNames reads and composition tightening one more: 125 -> 130. + // The managed PostgreSQL copy's fixed-schema guard reads the optional + // preflight flip once to refuse it: 130 -> 131. Reopening a durable + // working copy below the current base schema checks and calls + // adoptBaseSchema (+2), and the tightening probe reads its acyclic and + // composition relations through one table-name read (-1): 131 -> 132. + expect(reasoned.reduce((sum, entry) => sum + entry.accesses, 0)).toBe(132); // Compiled projection/relation templates add four raw-statement reuse // sites (row and scalar terminals), while import adds one heterogeneous // endpoint-set prefetch: 218 -> 223. @@ -179,6 +187,8 @@ describe("capability bundle totality (T9)", () => { // Vector lifecycle inventory adds one executeDdl, two upsertEmbedding, // and five vectorStrategy access sites: 233 -> 241. The graph storage // inventory and the namespace fork add one guarded strategy read each: 241 -> 243. - expect(deferred.reduce((sum, entry) => sum + entry.ceiling, 0)).toBe(243); + // The composition cascade and the unattached-part read each add one + // heterogeneous endpoint-set consumer: 243 -> 245. + expect(deferred.reduce((sum, entry) => sum + entry.ceiling, 0)).toBe(245); }); }); diff --git a/packages/typegraph/tests/ci-workflow-contract.test.ts b/packages/typegraph/tests/ci-workflow-contract.test.ts index 1283e38c9..55fa73678 100644 --- a/packages/typegraph/tests/ci-workflow-contract.test.ts +++ b/packages/typegraph/tests/ci-workflow-contract.test.ts @@ -12,6 +12,8 @@ import { fileURLToPath } from "node:url"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { DECLARATION_HEAP_MB } from "../scripts/declaration-emit"; + const WORKFLOW_PATH = fileURLToPath( new URL("../../../.github/workflows/ci.yml", import.meta.url), ); @@ -27,6 +29,9 @@ const METADATA_PREDICATE_PATH = fileURLToPath( const TSUP_CONFIG_PATH = fileURLToPath( new URL("../tsup.config.ts", import.meta.url), ); +const BUILD_PIPELINE_PATH = fileURLToPath( + new URL("../scripts/build.ts", import.meta.url), +); function runGit(fixtureDirectory: string, args: readonly string[]): string { return execFileSync("git", args, { @@ -115,9 +120,26 @@ describe("CI workflow contract", () => { }); it("gives CI and release declaration builds enough worker heap", () => { + // `scripts/build.ts` carries the declaration-pipeline ceiling itself and + // passes it to both of its children, so a local `pnpm build` never depends + // on an ambient NODE_OPTIONS; the workflow-wide ceiling covers the + // remaining steps and must not fall below the pipeline's own. + const buildScript = ( + JSON.parse( + readFileSync( + fileURLToPath(new URL("../package.json", import.meta.url)), + "utf8", + ), + ) as Readonly<{ scripts: Readonly> }> + ).scripts["build"]; + expect(buildScript).toBe("node --import tsx scripts/build.ts"); + expect(readFileSync(BUILD_PIPELINE_PATH, "utf8")).toContain( + "`--max-old-space-size=${DECLARATION_HEAP_MB}`", + ); + expect(DECLARATION_HEAP_MB).toBe(8192); for (const workflowPath of [WORKFLOW_PATH, RELEASE_WORKFLOW_PATH]) { expect(readFileSync(workflowPath, "utf8")).toContain( - "NODE_OPTIONS: --max-old-space-size=6144", + `NODE_OPTIONS: --max-old-space-size=${DECLARATION_HEAP_MB}`, ); } }); diff --git a/packages/typegraph/tests/command-contract.test.ts b/packages/typegraph/tests/command-contract.test.ts index 955f60303..9ab79fffe 100644 --- a/packages/typegraph/tests/command-contract.test.ts +++ b/packages/typegraph/tests/command-contract.test.ts @@ -1,18 +1,23 @@ import { describe, expect, it } from "vitest"; import { + assertFencedSnapshotIsFresh, assertGraphCommandConvergenceIsolation, assertGraphCommandExecutionContext, executeAuthoritativeGraphCommand, - graphCommandCoordinationIsolation, + graphCommandCoordinationAcquisition, graphCommandExecutionContext, mintGraphCommandCoordination, + observesEveryEarlierFenceHolder, + observesPostFenceCommits, + readGraphWriteFenceAcquisition, } from "../src/backend/command-contract"; import type { EdgeConvergeCreateCommand, EdgeRow, GraphCommand, GraphCommandPort, + GraphWriteFenceAcquisition, NodeCreateCommand, NodeRow, } from "../src/backend/types"; @@ -116,19 +121,21 @@ describe("authoritative graph command contract", () => { session: "transaction", execute: () => Promise.resolve(RESULT), }; - const coordination = mintGraphCommandCoordination( - port, - "graph", + const coordination = mintGraphCommandCoordination(port, "graph", { isolation, - ); + fenceRow: "none", + }); expect( - graphCommandCoordinationIsolation(port, "graph", coordination), + graphCommandCoordinationAcquisition(port, coordination, "graph") + .isolation, ).toBe(isolation); expect( - graphCommandCoordinationIsolation(port, "other", coordination), + graphCommandCoordinationAcquisition(port, coordination, "other") + .isolation, ).toBe("unknown"); expect( - graphCommandCoordinationIsolation(otherPort, "graph", coordination), + graphCommandCoordinationAcquisition(otherPort, coordination, "graph") + .isolation, ).toBe("unknown"); }, ); @@ -247,11 +254,10 @@ describe("authoritative graph command contract", () => { session: "transaction", execute: () => Promise.resolve(RESULT), }; - const coordination = mintGraphCommandCoordination( - portA, - "other-graph", - "read_committed", - ); + const coordination = mintGraphCommandCoordination(portA, "other-graph", { + isolation: "read_committed", + fenceRow: "none", + }); expect(() => executeAuthoritativeGraphCommand(portA, COMMAND, coordination), @@ -266,11 +272,10 @@ describe("authoritative graph command contract", () => { session: "transaction", execute: () => Promise.resolve(RESULT), }; - const coordination = mintGraphCommandCoordination( - port, - "graph", - "read_committed", - ); + const coordination = mintGraphCommandCoordination(port, "graph", { + isolation: "read_committed", + fenceRow: "none", + }); expect(() => graphCommandExecutionContext("root", coordination)).toThrow( "root command", @@ -282,11 +287,10 @@ describe("authoritative graph command contract", () => { session: "transaction", execute: () => Promise.resolve(RESULT), }; - const coordination = mintGraphCommandCoordination( - port, - "graph-a", - "read_committed", - ); + const coordination = mintGraphCommandCoordination(port, "graph-a", { + isolation: "read_committed", + fenceRow: "none", + }); expect(() => { assertGraphCommandConvergenceIsolation(port, "graph-a", coordination); @@ -296,3 +300,168 @@ describe("authoritative graph command contract", () => { }).toThrow("Match-key convergence requires"); }); }); + +/** + * Every acquisition the per-graph write fence can report, and whether a + * session that acquired it that way reads what the fence's previous holders + * committed. A lock cannot refresh a snapshot taken before the wait, so a + * snapshot isolation is trusted only when its acquisition advanced the + * graph's fence row — the write a stale session fails on. + */ +const FENCE_ACQUISITIONS: readonly Readonly<{ + acquisition: GraphWriteFenceAcquisition; + observesWinner: boolean; +}>[] = [ + { + acquisition: { isolation: "read_committed", fenceRow: "advanced" }, + observesWinner: true, + }, + { + acquisition: { isolation: "read_committed", fenceRow: "none" }, + observesWinner: true, + }, + { + acquisition: { isolation: "repeatable_read", fenceRow: "advanced" }, + observesWinner: true, + }, + { + acquisition: { isolation: "repeatable_read", fenceRow: "none" }, + observesWinner: false, + }, + { + acquisition: { isolation: "serializable", fenceRow: "advanced" }, + observesWinner: true, + }, + { + acquisition: { isolation: "serializable", fenceRow: "none" }, + observesWinner: false, + }, + { + acquisition: { isolation: "unknown", fenceRow: "advanced" }, + observesWinner: false, + }, + { + acquisition: { isolation: "unknown", fenceRow: "none" }, + observesWinner: false, + }, +]; + +describe("a fenced decision observes the fence's previous holders", () => { + const port: GraphCommandPort = { + session: "transaction", + execute: () => Promise.resolve(RESULT), + }; + const refusal = { code: "LOCK_ONLY_REQUIRES_FRESH_SNAPSHOT", subject: "It" }; + + // The two consumers read the SAME verdict off the coordination the + // acquisition minted: a lock-only read and match-key convergence. + it.each(FENCE_ACQUISITIONS.filter((entry) => entry.observesWinner))( + "$acquisition.isolation with fence row $acquisition.fenceRow is trusted", + ({ acquisition }) => { + expect(observesPostFenceCommits(acquisition)).toBe(true); + const coordination = mintGraphCommandCoordination( + port, + "graph", + acquisition, + ); + expect(() => { + assertFencedSnapshotIsFresh(port, "graph", coordination, refusal); + }).not.toThrow(); + expect(() => { + assertGraphCommandConvergenceIsolation(port, "graph", coordination); + }).not.toThrow(); + }, + ); + + it.each(FENCE_ACQUISITIONS.filter((entry) => !entry.observesWinner))( + "$acquisition.isolation with fence row $acquisition.fenceRow is refused", + ({ acquisition }) => { + expect(observesPostFenceCommits(acquisition)).toBe(false); + const coordination = mintGraphCommandCoordination( + port, + "graph", + acquisition, + ); + expect(() => { + assertFencedSnapshotIsFresh(port, "graph", coordination, refusal); + }).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ + code: refusal.code, + ...acquisition, + }) as unknown, + }), + ); + expect(() => { + assertGraphCommandConvergenceIsolation(port, "graph", coordination); + }).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ + code: "MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT", + ...acquisition, + }) as unknown, + }), + ); + }, + ); + + it("needs no evidence where no keyed fence exists to wait on", () => { + expect(() => { + assertFencedSnapshotIsFresh(port, "graph", undefined, refusal); + }).not.toThrow(); + }); + + it("refuses coordination earned for another graph as unobserved", () => { + const coordination = mintGraphCommandCoordination(port, "other-graph", { + isolation: "read_committed", + fenceRow: "advanced", + }); + expect(() => { + assertFencedSnapshotIsFresh(port, "graph", coordination, refusal); + }).toThrow( + expect.objectContaining({ + details: expect.objectContaining({ + isolation: "unknown", + fenceRow: "none", + }) as unknown, + }), + ); + }); +}); + +describe("a holder that reads the whole graph observes every earlier holder", () => { + // Only deciders advance the fence row, so it is evidence about deciders + // alone: a per-statement snapshot is the only thing that shows a session + // everything every earlier holder committed. + it.each(FENCE_ACQUISITIONS)( + "$acquisition.isolation with fence row $acquisition.fenceRow", + ({ acquisition }) => { + expect(observesEveryEarlierFenceHolder(acquisition)).toBe( + acquisition.isolation === "read_committed", + ); + }, + ); +}); + +describe("readGraphWriteFenceAcquisition", () => { + it.each([ + [ + { transaction_isolation: "read committed", generation: "3" }, + { isolation: "read_committed", fenceRow: "advanced" }, + ], + [ + { transaction_isolation: "serializable", generation: 1 }, + { isolation: "serializable", fenceRow: "advanced" }, + ], + [ + { transaction_isolation: "repeatable read" }, + { isolation: "repeatable_read", fenceRow: "none" }, + ], + [{ generation: 7 }, { isolation: "unknown", fenceRow: "advanced" }], + // eslint-disable-next-line unicorn/no-null -- a driver reports an absent column value as SQL NULL + [{ generation: null }, { isolation: "unknown", fenceRow: "none" }], + [undefined, { isolation: "unknown", fenceRow: "none" }], + ] as const)("reads %o as %o", (row, expected) => { + expect(readGraphWriteFenceAcquisition(row)).toEqual(expected); + }); +}); diff --git a/packages/typegraph/tests/composition-attachment-fence.test.ts b/packages/typegraph/tests/composition-attachment-fence.test.ts new file mode 100644 index 000000000..748c45a0d --- /dev/null +++ b/packages/typegraph/tests/composition-attachment-fence.test.ts @@ -0,0 +1,619 @@ +/** + * A get-or-create `partOf` postcondition decides the incumbent's disposition + * UNDER THE FENCE, and writes the attachment in the same transaction as the + * property update it came with. + * + * The cross-backend suite (`tests/backends/integration/composition-*.ts`) + * asserts the outcomes of the attachment surface on a real engine, but it + * cannot place a competing write in the window between the lock-free + * pre-check and the fenced re-read: two calls started with `Promise.all` + * usually serialize on a single-connection engine, so an implementation that + * acts on the stale pre-check verdict passes by luck. This file removes the + * luck — the racing attachment is performed INSIDE the pre-check's own read + * (a wrapper on the root backend's `findEdgesConnectedTo`, through + * `deriveBackend`, never a spread copy), after the read has already answered, + * so the second caller's pre-check provably observed a pre-attach snapshot. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CardinalityError, + CompositionExistenceError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + EndpointNotFoundError, + partOf, + UniquenessError, +} from "../src"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { + type FindEdgesConnectedToParams, + type GraphBackend, + type TransactionBackend, + type TransactionOptions, +} from "../src/backend/types"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const AfWhole = defineNode("AfWhole", { schema: z.object({}) }); +const AfPart = defineNode("AfPart", { + schema: z.object({ slug: z.string(), code: z.string() }), +}); +const AF_SLUG_UNIQUE = { + name: "af_part_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", +} as const; +/** + * A SECOND unique constraint, on a field the get-or-create lookup does NOT + * key on: what lets one call match an existing part by `slug` while the + * property update it carries collides with another part's `code`. + */ +const AF_CODE_UNIQUE = { + name: "af_part_code", + fields: ["code"], + scope: "kind", + collation: "binary", +} as const; +/** + * The realizing edge carries one optional property, so a caller can restate + * `partOf.props` the way an idempotent ingest does. + */ +const afPartOf = defineEdge("afPartOf", { + schema: z.object({ rank: z.number().optional() }), +}); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + AfWhole: { type: AfWhole }, + AfPart: { type: AfPart, unique: [AF_SLUG_UNIQUE, AF_CODE_UNIQUE] }, + }, + edges: { + afPartOf: { + type: afPartOf, + from: [AfPart], + to: [AfWhole], + cardinality: "one", + }, + }, + // `existence: "optional"` (the default): the part can exist unattached, + // which is the state a get-or-create postcondition repairs. + ontology: [partOf(AfPart, AfWhole, { via: afPartOf })], + }); +} + +/** + * The same shape with `targetCardinality: "one"` on the realizing edge: a + * whole holds at most one part, so attaching a second is a refusal the + * edge's own PREPARATION raises (a cardinality read), not the incumbent + * decision — the class of refusal that used to follow the property update. + */ +function buildSinglePartWholeGraph(id: string) { + return defineGraph({ + id, + nodes: { + AfWhole: { type: AfWhole }, + AfPart: { type: AfPart, unique: [AF_SLUG_UNIQUE, AF_CODE_UNIQUE] }, + }, + edges: { + afPartOf: { + type: afPartOf, + from: [AfPart], + to: [AfWhole], + cardinality: "one", + targetCardinality: "one", + }, + }, + ontology: [partOf(AfPart, AfWhole, { via: afPartOf })], + }); +} + +/** + * Decorates the ROOT backend's `findEdgesConnectedTo` — the read the + * `partOf` pre-check runs outside any transaction — so that an armed race + * hook runs AFTER the read resolved and BEFORE its rows are returned. The + * caller therefore holds a snapshot that the hook's own committed write has + * already invalidated, which is exactly the state a stale verdict would act + * on. + */ +function raceablePreCheckBackend( + base: GraphBackend, + state: { + hook: (() => Promise) | undefined; + partId: string | undefined; + }, +): GraphBackend { + return deriveBackend(base, { + findEdgesConnectedTo: async (params: FindEdgesConnectedToParams) => { + const rows = await base.findEdgesConnectedTo(params); + const hook = state.hook; + if (hook !== undefined && params.nodeId === state.partId) { + state.hook = undefined; + await hook(); + } + return rows; + }, + }); +} + +describe("get-or-create's partOf postcondition under the fence", () => { + it("refuses a part that acquired a DIFFERENT whole between the pre-check and the fenced re-read, leaving the winner's attachment", async () => { + const state: { + hook: (() => Promise) | undefined; + partId: string | undefined; + } = { hook: undefined, partId: undefined }; + const backend = raceablePreCheckBackend(createTestBackend(), state); + const [store] = await createStoreWithSchema( + buildGraph("af_refuse_race"), + backend, + ); + + const wholeA = await store.nodes.AfWhole.create({}); + const wholeB = await store.nodes.AfWhole.create({}); + const part = await store.nodes.AfPart.create({ + slug: "contested", + code: "c1", + }); + state.partId = part.id; + + // The competing caller: attaches the part to A, and commits, while the + // refusing caller below is holding a pre-attach read. + state.hook = async () => { + const attached = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "contested", code: "c1" }, + { partOf: { whole: { kind: "AfWhole", id: wholeA.id } } }, + ); + expect(attached.action).toBe("found"); + }; + + // MUTATION CHECK: state `onIncumbent: "replace"` in + // `resolveGetOrCreateAttachmentRequest` + // (src/store/operations/node-operations.ts), or act on the lock-free + // pre-check's verdict instead of the fenced re-read's. This call then + // MOVES the part to B — no refusal at all — and the final assertion + // finds the part under the loser's whole. + const refusal = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "contested", code: "c1" }, + { partOf: { whole: { kind: "AfWhole", id: wholeB.id } } }, + ).catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(CompositionExistenceError); + const details = (refusal as CompositionExistenceError).details; + expect(details.situation).toBe("existing"); + expect(details.currentWhole).toEqual({ kind: "AfWhole", id: wholeA.id }); + expect(details.requestedWhole).toEqual({ kind: "AfWhole", id: wholeB.id }); + expect(details.requestedVia).toBe("afPartOf"); + + // Exactly one attachment, held by the winner. + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).toId).toBe(wholeA.id); + // The hook ran: the race really happened. + expect(state.hook).toBeUndefined(); + }); + + it("is satisfied when the whole acquired in that same window is the one it asked for", async () => { + const state: { + hook: (() => Promise) | undefined; + partId: string | undefined; + } = { hook: undefined, partId: undefined }; + const backend = raceablePreCheckBackend(createTestBackend(), state); + const [store] = await createStoreWithSchema( + buildGraph("af_idempotent_race"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + const part = await store.nodes.AfPart.create({ + slug: "converging", + code: "c1", + }); + state.partId = part.id; + + state.hook = async () => { + await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "converging", code: "c1" }, + { partOf: { whole: { kind: "AfWhole", id: whole.id } } }, + ); + }; + + // MUTATION CHECK: make the fenced verdict treat ANY incumbent as a + // contradiction (drop the `incumbentHoldsRequestedAttachment` arm from + // `decideCompositionIncumbent`, src/store/operations/composition-create.ts) + // and this convergent second call refuses instead of resolving. + const result = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "converging", code: "c1" }, + { partOf: { whole: { kind: "AfWhole", id: whole.id } } }, + ); + expect(result.action).toBe("found"); + + // One attachment, not two, and no retired window from a needless move. + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).meta.validTo).toBeUndefined(); + expect(state.hook).toBeUndefined(); + }); + + it("leaves an unattached part unattached when the ifExists update it came with is refused", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("af_one_write_plan"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + const keeper = await store.nodes.AfPart.create({ + slug: "keeper", + code: "free", + }); + await store.nodes.AfPart.create({ slug: "other", code: "taken" }); + + // MUTATION CHECK: run the attachment in its own write plan again (call + // `applyExistingPartOfPostcondition` before `executeNodeUpsertUpdate` + // instead of passing `compositionAttachment` into it, + // src/store/operations/node-operations.ts). The attachment then commits + // before the update is refused, and both assertions below fail: the edge + // exists, and the part holds a whole the call it came with never applied. + const refusal = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "keeper", code: "taken" }, + { + ifExists: "update", + partOf: { whole: { kind: "AfWhole", id: whole.id } }, + }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(UniquenessError); + + // No attachment, in any window state: the refused update took the + // attachment with it. + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toEqual([]); + + // And the property the call tried to write never landed either. + const reread = requireDefined(await store.nodes.AfPart.getById(keeper.id)); + expect(reread.code).toBe("free"); + }); +}); + +describe("a refused attachment and the property update it came with", () => { + /** + * The refusal the FENCED incumbent decision raises must precede the + * property update's first statement, not follow it. A caller that catches + * the refusal inside an enclosing `store.transaction(...)` is the case that + * can tell the two orders apart: the get-or-create leg runs on the + * caller's transaction (`resolveWriteTransactionMode` answers `"existing"`), + * so there is no nested frame to roll back and whatever statements already + * ran stay committed with the enclosing transaction. + */ + it("leaves the property update unapplied when the fenced attachment refuses and the caller catches it inside a transaction", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("af_refuse_before_update"), + backend, + ); + + const wholeA = await store.nodes.AfWhole.create({}); + const wholeB = await store.nodes.AfWhole.create({}); + const part = await store.nodes.AfPart.create({ + slug: "owned", + code: "original", + }); + await store.nodes.AfPart.reparent(part.id, { + whole: { kind: "AfWhole", id: wholeA.id }, + }); + + // MUTATION CHECK: move the `decideCompositionAttachmentUnderFence` call + // in `executeNodeUpsertUpdate` back below + // `performNodeUpdateWithResurrectionRecovery` + // (src/store/operations/node-operations.ts). The refused attachment then + // follows the update, and `code` below reads "mutated". + await store.transaction(async (tx) => { + const refusal = await tx.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "owned", code: "mutated" }, + { + ifExists: "update", + partOf: { whole: { kind: "AfWhole", id: wholeB.id } }, + }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(CompositionExistenceError); + expect((refusal as CompositionExistenceError).details.situation).toBe( + "existing", + ); + }); + + const reread = requireDefined(await store.nodes.AfPart.getById(part.id)); + expect(reread.code).toBe("original"); + + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).toId).toBe(wholeA.id); + expect(requireDefined(edges[0]).meta.validTo).toBeUndefined(); + }); + + /** + * The same precedence question as the previous test, for the refusal a + * DEAD whole raises rather than a conflicting incumbent: both must precede + * the property update, because both are now decided inside + * `decideCompositionAttachmentUnderFence` before it returns. + */ + it("leaves an unattached part unattached when the requested whole is dead and the caller catches it inside a transaction", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("af_refuse_dead_whole_before_update"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + const part = await store.nodes.AfPart.create({ + slug: "unattached", + code: "original", + }); + await store.nodes.AfWhole.delete(whole.id); + + // MUTATION CHECK: delete the whole-liveness read in + // `decideCompositionAttachmentUnderFence` + // (src/store/operations/composition-create.ts) — i.e. revert to letting + // `prepareCompositionCreateEdge`'s own `assertLiveEdgeEndpoints` be the + // only place a dead whole is caught. The refusal then follows + // `performNodeUpdateWithResurrectionRecovery` instead of preceding it, + // and `code` below reads "mutated". + await store.transaction(async (tx) => { + const refusal = await tx.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "unattached", code: "mutated" }, + { + ifExists: "update", + partOf: { whole: { kind: "AfWhole", id: whole.id } }, + }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(EndpointNotFoundError); + expect((refusal as EndpointNotFoundError).details.endpoint).toBe("to"); + }); + + const reread = requireDefined(await store.nodes.AfPart.getById(part.id)); + expect(reread.code).toBe("original"); + + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toEqual([]); + }); + + /** + * The refusals the realizing edge's own preparation owns — here a + * cardinality read against a whole that already holds its one part — must + * precede the property update as well: the get-or-create leg prepares the + * edge before the update and issues only the insert after it. + */ + it("leaves the property update unapplied when the edge's own cardinality read refuses and the caller catches it inside a transaction", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildSinglePartWholeGraph("af_refuse_cardinality_before_update"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + const occupant = await store.nodes.AfPart.create( + { slug: "occupant", code: "occupant" }, + { partOf: { whole: { kind: "AfWhole", id: whole.id } } }, + ); + const part = await store.nodes.AfPart.create({ + slug: "second", + code: "original", + }); + + // MUTATION CHECK: in `executeNodeUpsertUpdate` + // (src/store/operations/node-operations.ts), move the + // `prepareCompositionAttachmentDecision` call below + // `performNodeUpdateWithResurrectionRecovery`. The cardinality refusal + // then follows the update, and `code` below reads "mutated". + await store.transaction(async (tx) => { + const refusal = await tx.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "second", code: "mutated" }, + { + ifExists: "update", + partOf: { whole: { kind: "AfWhole", id: whole.id } }, + }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(CardinalityError); + }); + + const reread = requireDefined(await store.nodes.AfPart.getById(part.id)); + expect(reread.code).toBe("original"); + + const edges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).fromId).toBe(occupant.id); + }); + + /** + * `reparent`'s replace arm owes the same order: the replacement is prepared + * (cardinality at the new whole, acyclicity, edge props) BEFORE the + * incumbent is retired, so a refusal leaves the part attached where it was + * — a caller catching the refusal inside an enclosing transaction has no + * nested frame that would roll the retirement back. + */ + it("leaves the incumbent attachment in place when a reparent's replacement refuses and the caller catches it inside a transaction", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildSinglePartWholeGraph("af_reparent_refuse_before_retire"), + backend, + ); + + const wholeA = await store.nodes.AfWhole.create({}); + const wholeB = await store.nodes.AfWhole.create({}); + const part = await store.nodes.AfPart.create( + { slug: "moving", code: "moving" }, + { partOf: { whole: { kind: "AfWhole", id: wholeA.id } } }, + ); + await store.nodes.AfPart.create( + { slug: "occupant", code: "occupant" }, + { partOf: { whole: { kind: "AfWhole", id: wholeB.id } } }, + ); + + // MUTATION CHECK: retire the incumbent inside + // `prepareCompositionAttachmentMoves` + // (src/store/operations/node-operations.ts), ahead of its + // `prepareCompositionCreateEdge` call. + // The cardinality refusal then follows the retirement, and the part below + // is left with no live attachment. + await store.transaction(async (tx) => { + const refusal = await tx.nodes.AfPart.reparent(part.id, { + whole: { kind: "AfWhole", id: wholeB.id }, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(CardinalityError); + }); + + const allEdges = await store.edges.afPartOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const edges = allEdges.filter((edge) => edge.fromId === part.id); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).toId).toBe(wholeA.id); + expect(requireDefined(edges[0]).meta.validTo).toBeUndefined(); + }); +}); + +/** + * Counts the write transactions a call opens. The per-graph write fence is + * taken inside one (`BEGIN IMMEDIATE` on SQLite, a session advisory lock on + * Postgres), so "no transaction" is the only observable proof that an + * already-satisfied resolve stayed read-only. + */ +function transactionCountingBackend( + base: GraphBackend, + counter: { transactions: number }, +): GraphBackend { + return deriveBackend(base, { + transaction: async ( + fn: (tx: TransactionBackend) => Promise, + options?: TransactionOptions, + ): Promise => { + counter.transactions += 1; + return base.transaction(fn, options); + }, + }); +} + +describe("an already-satisfied partOf resolve", () => { + /** + * W2's "the already-satisfied, no-update path stays read-only" must not + * depend on whether the caller restated the realizing edge's props: an + * idempotent ingest that passes `partOf: { ..., props }` every time would + * otherwise take the per-graph write fence on every call for a verdict of + * "nothing to do" — graph-wide serialization on Postgres, for a no-op. + */ + it("opens no write transaction when the stated partOf props already AGREE with the live edge", async () => { + const counter = { transactions: 0 }; + const backend = transactionCountingBackend(createTestBackend(), counter); + const [store] = await createStoreWithSchema( + buildGraph("af_satisfied_props_readonly"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + await store.nodes.AfPart.create({ slug: "ingested", code: "c1" }); + await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "ingested", code: "c1" }, + { + partOf: { + whole: { kind: "AfWhole", id: whole.id }, + props: { rank: 3 }, + }, + }, + ); + + counter.transactions = 0; + // MUTATION CHECK: narrow the pre-check's skip back to + // `request.attachment.props === undefined` + // (`applyExistingPartOfPostcondition`, + // src/store/operations/node-operations.ts) and this restate opens one + // write transaction to discover it has nothing to write. + const again = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "ingested", code: "c1" }, + { + partOf: { + whole: { kind: "AfWhole", id: whole.id }, + props: { rank: 3 }, + }, + }, + ); + expect(again.action).toBe("found"); + expect(counter.transactions).toBe(0); + }); + + /** + * The other half of the same skip: props that DISAGREE are never decided + * from the lock-free read. The call escalates to the fence, which is what + * refuses. + */ + it("still refuses through the fence when the stated partOf props DIFFER", async () => { + const counter = { transactions: 0 }; + const backend = transactionCountingBackend(createTestBackend(), counter); + const [store] = await createStoreWithSchema( + buildGraph("af_satisfied_props_differ"), + backend, + ); + + const whole = await store.nodes.AfWhole.create({}); + await store.nodes.AfPart.create({ slug: "ingested", code: "c1" }); + await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "ingested", code: "c1" }, + { + partOf: { + whole: { kind: "AfWhole", id: whole.id }, + props: { rank: 3 }, + }, + }, + ); + + counter.transactions = 0; + const refusal = await store.nodes.AfPart.getOrCreateByConstraint( + "af_part_slug", + { slug: "ingested", code: "c1" }, + { + partOf: { + whole: { kind: "AfWhole", id: whole.id }, + props: { rank: 9 }, + }, + }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(CompositionExistenceError); + expect((refusal as CompositionExistenceError).details.situation).toBe( + "props", + ); + expect(counter.transactions).toBeGreaterThan(0); + }); +}); diff --git a/packages/typegraph/tests/composition-cascade-pglite.test.ts b/packages/typegraph/tests/composition-cascade-pglite.test.ts new file mode 100644 index 000000000..c5a029834 --- /dev/null +++ b/packages/typegraph/tests/composition-cascade-pglite.test.ts @@ -0,0 +1,237 @@ +/** + * The composition cascade's fused-delete ineligibility, on the ONE backend + * configuration where the fused atomic `deleteNodes` command is reachable at + * all — a PGlite ROOT backend (see + * `node-delete-policy-root-atomic-bypass-pglite.test.ts`'s doc for why SQLite + * and every transaction-scoped backend never reach it, so a + * SQLite-only suite could never exercise this). + * + * `Podcast` here is deliberately atomic-eligible on every OTHER axis — no + * uniques, no identity, no searchable/embedding fields, `onDelete: "restrict"` + * — so the ONLY thing standing between it and the fused command is + * `resolveAtomicNodeDeleteBatchExecutor`'s composition check + * (`atomic-mutation-program.ts`). Without that check this delete would be a + * single read-free SQL statement with no notion of a parts closure, and + * `Episode` would survive an ostensibly-cascading Podcast delete. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineEdge, defineGraph, defineNode, partOf } from "../src"; +import { withAtomicMutationProgramDispatchObserver } from "../src/backend/capabilities/atomic-mutation-program"; +import { createPostgresBackend } from "../src/backend/drizzle/postgres"; +import { createLocalPgliteBackend } from "../src/backend/postgres/pglite"; +import { createStoreWithSchema } from "../src/store"; +import { STORE_RUNTIME } from "../src/store/runtime-port"; +import { advisoryOnlyBackend } from "./advisory-only-backend"; + +const Podcast = defineNode("Podcast", { schema: z.object({}) }); +const Episode = defineNode("Episode", { schema: z.object({}) }); +const episodeOf = defineEdge("episodeOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-cascade-pglite-fused-ineligible", + nodes: { + Podcast: { type: Podcast, onDelete: "restrict" }, + Episode: { type: Episode }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + }, + ontology: [partOf(Episode, Podcast, { via: episodeOf })], +}); + +describe("composition whole delete is statically ineligible for the fused atomic path", () => { + it("never dispatches the fused deleteNodes command, and still cascades to Episode", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({}); + const episode = await store.nodes.Episode.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + + const dispatched: string[] = []; + await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + () => + store[STORE_RUNTIME].deleteNodeWithPolicy(backend, { + kind: "Podcast", + id: podcast.id, + }), + ); + + // MUTATION: remove the `compositionEdgeKindsUnder` guard from + // `resolveAtomicNodeDeleteBatchExecutor` and `deleteNodes` appears + // here — a single read-free statement that cannot cascade, after + // which the assertion below (Episode survives) also flips. + expect(dispatched).not.toContain("deleteNodes"); + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + } finally { + await local.backend.close(); + } + }); +}); + +// ============================================================ +// A composition PART, not a whole: `compositionEdgeKindsUnder` alone does +// not disqualify it (it declares no parts of its own), so only the +// `compositionEdgeKindsOver` half of the guard keeps it off the fused path. +// ============================================================ + +const restrictedPartGraph = defineGraph({ + id: "composition-cascade-pglite-restricted-part-fused-ineligible", + nodes: { + Podcast: { type: Podcast }, + // `restrict`, and declares no composition parts of its own: only a + // `compositionEdgeKindsOver` (part-side) check keeps this off the fused + // `deleteNodes` command. + Episode: { type: Episode, onDelete: "restrict" }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + }, + ontology: [partOf(Episode, Podcast, { via: episodeOf })], +}); + +describe("a composition PART declared onDelete: 'restrict' is statically ineligible for the fused atomic path", () => { + it("never dispatches the fused deleteNodes command, and does not restrict deleting the part out of its whole", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(restrictedPartGraph, backend); + const podcast = await store.nodes.Podcast.create({}); + const episode = await store.nodes.Episode.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + + const dispatched: string[] = []; + await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + () => + store[STORE_RUNTIME].deleteNodeWithPolicy(backend, { + kind: "Episode", + id: episode.id, + }), + ); + + // MUTATION: drop `compositionEdgeKindsOver` from + // `resolveAtomicNodeDeleteBatchExecutor`'s guard (leaving only + // `compositionEdgeKindsUnder`) and `deleteNodes` appears here — the + // fused command's read-free refusal diagnosis then counts the + // composition edge as a live restrict obstacle and this delete throws + // `RestrictedDeleteError` instead of succeeding. + expect(dispatched).not.toContain("deleteNodes"); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + // The whole is untouched: deleting a part directly must not touch it. + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeDefined(); + } finally { + await local.backend.close(); + } + }); +}); + +// ============================================================ +// The cascade's parts closure is a lock-only read, like the acyclicity probe: +// a session that cannot see what committed while it waited for the fence +// would plan from a stale snapshot and leave a just-attached part behind. +// ============================================================ + +const SNAPSHOT_ISOLATION_LEVELS = [ + { setting: "repeatable read", isolation: "repeatable_read" }, + { setting: "serializable", isolation: "serializable" }, +] as const; + +describe("a composition whole delete requires a snapshot taken after the fence", () => { + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "refuses at $isolation on a port whose fence advances no fence row, and deletes nothing", + async ({ setting, isolation }) => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = advisoryOnlyBackend( + createPostgresBackend(local.db, { vector: false }), + ); + try { + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({}); + const episode = await store.nodes.Episode.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await local.client.exec( + `SET default_transaction_isolation = '${setting}'`, + ); + + // MUTATION CHECK: removing `assertFencedSnapshotIsFresh` from + // `planCompositionCascade` lets this delete resolve — verified and + // reverted. + await expect( + store.nodes.Podcast.delete(podcast.id), + ).rejects.toMatchObject({ + details: { + code: "COMPOSITION_CASCADE_REQUIRES_FRESH_SNAPSHOT", + isolation, + fenceRow: "none", + }, + }); + + await local.client.exec( + "SET default_transaction_isolation = 'read committed'", + ); + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeDefined(); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeDefined(); + } finally { + await local.backend.close(); + } + }, + ); + + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "cascades at $isolation on the bundled backend, whose fence advances the fence row", + async ({ setting }) => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({}); + const episode = await store.nodes.Episode.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await local.client.exec( + `SET default_transaction_isolation = '${setting}'`, + ); + + await store.nodes.Podcast.delete(podcast.id); + + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + } finally { + await local.backend.close(); + } + }, + ); +}); diff --git a/packages/typegraph/tests/composition-cascade.test.ts b/packages/typegraph/tests/composition-cascade.test.ts new file mode 100644 index 000000000..3a4930c0c --- /dev/null +++ b/packages/typegraph/tests/composition-cascade.test.ts @@ -0,0 +1,1483 @@ +/** + * The composition delete cascade. + * + * Two layers of coverage: + * + * - UNIT tests directly against `composition-cascade.ts`'s two exports — + * `compositionEdgeCounts` (the population predicate) and + * `planCompositionCascade` (the leaf-first closure walk, and its + * should-be-impossible invariant throws) — driven with hand-built rows so + * the population contrast and the invariant branches are exercised + * directly, without depending on a real cascade reaching them. + * - INTEGRATION tests through the real Store API: `store.nodes.Podcast.delete` + * etc. against a Podcast -> Episode -> Segment composition graph (the exact + * fixture `ontology-composition-declaration.test.ts` uses for the registry + * layer), proving the cascade end to end. A second fixture (Album + * -[hasTrack]-> Track -[noteOf]<- Note) covers the `hasPart` / + * `partSide: "to"` orientation and a closure that mixes both + * orientations in one walk. + * + * MUTATION CHECKS are recorded inline, next to the assertion each one + * guards, as an `// MUTATION:` comment naming the exact change and which + * assertion it flips. + */ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + type CompositionCascadedPart, + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + hasPart, + partOf, +} from "../src"; +import { + deriveBackend, + type ExactBackendOverlay, +} from "../src/backend/derive-backend"; +import { generateSqliteDDL } from "../src/backend/drizzle/ddl"; +import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; +import { + type EdgeRow, + type GraphBackend, + type TransactionBackend, +} from "../src/backend/types"; +import { buildKindRegistry } from "../src/registry"; +import { + compositionEdgeCounts, + planCompositionCascade, +} from "../src/store/operations/composition-cascade"; +import { uncapturedGraphWriteLock } from "../src/store/recorded-capture/clock"; +import { transactionDeleteNodeWithPolicy } from "../src/store/runtime-port"; +import { type OperationHookContext } from "../src/store/types"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend, matchingObject } from "./test-utils"; + +const emptySchema = z.object({}); + +// ============================================================ +// Fixture: Podcast -> Episode -> Segment (the composition registry fixture) +// ============================================================ + +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string().default("untitled") }), +}); +const Episode = defineNode("Episode", { + schema: z.object({ title: z.string().default("untitled") }), +}); +const Segment = defineNode("Segment", { schema: emptySchema }); +const Tag = defineNode("Tag", { schema: emptySchema }); + +const episodeOf = defineEdge("episodeOf", { schema: emptySchema }); +const segmentOf = defineEdge("segmentOf", { schema: emptySchema }); +const taggedWith = defineEdge("taggedWith", { schema: emptySchema }); + +/** One expected `cascadedParts` entry: the part, its immediate whole, and the binding edge kind. */ +function cascadedPart( + part: Readonly<{ kind: string; id: string }>, + whole: Readonly<{ kind: string; id: string }>, + viaEdgeKind: string, +): CompositionCascadedPart { + return { + kind: part.kind, + id: part.id, + whole: { kind: whole.kind, id: whole.id }, + viaEdgeKind, + }; +} + +function buildPodcastGraph(id: string) { + return defineGraph({ + id, + nodes: { + Podcast: { + type: Podcast, + unique: [ + { + name: "podcast_title", + fields: ["title"], + scope: "kind", + collation: "binary", + }, + ], + }, + Episode: { + type: Episode, + // `disconnect`, not the default `restrict`: an Episode that a part + // once reparented AWAY from (`oneActive`) still carries that ENDED + // segmentOf row (`findEdgesConnectedTo` returns ended-but-undeleted + // edges, valid_to notwithstanding) — a plain `restrict` Episode would + // therefore refuse to delete even once no LIVE part remains, which is + // orthogonal to what this fixture's tests are about. + onDelete: "disconnect", + unique: [ + { + name: "episode_title", + fields: ["title"], + scope: "kind", + collation: "binary", + }, + ], + }, + Segment: { type: Segment, onDelete: "restrict" }, + Tag: { type: Tag }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "oneActive", + }, + taggedWith: { type: taggedWith, from: [Segment], to: [Tag] }, + }, + ontology: [ + partOf(Episode, Podcast, { via: episodeOf }), + partOf(Segment, Episode, { via: segmentOf }), + ], + }); +} + +// ============================================================ +// Unit tests: compositionEdgeCounts +// ============================================================ + +describe("compositionEdgeCounts", () => { + const liveRow: Pick = { valid_to: undefined }; + const endedRow: Pick = { + valid_to: "2020-01-01T00:00:00.000Z", + }; + + it("population 'one' counts an ended row exactly as a live one", () => { + // MUTATION: change this predicate's `pair.population === "one"` branch to + // `false` (always defer to `valid_to`) and this assertion flips. + expect( + compositionEdgeCounts({ partSide: "from", population: "one" }, endedRow), + ).toBe(true); + expect( + compositionEdgeCounts({ partSide: "from", population: "one" }, liveRow), + ).toBe(true); + }); + + it("population 'oneActive' counts only an open-ended row", () => { + // MUTATION: drop the `row.valid_to === undefined` disjunct and this + // assertion flips (an ended row would wrongly still count). + expect( + compositionEdgeCounts( + { partSide: "from", population: "oneActive" }, + endedRow, + ), + ).toBe(false); + expect( + compositionEdgeCounts( + { partSide: "from", population: "oneActive" }, + liveRow, + ), + ).toBe(true); + }); + + it("is indifferent to partSide — only population decides", () => { + expect( + compositionEdgeCounts( + { partSide: "to", population: "oneActive" }, + endedRow, + ), + ).toBe(false); + expect( + compositionEdgeCounts({ partSide: "to", population: "one" }, endedRow), + ).toBe(true); + }); +}); + +// ============================================================ +// Unit tests: planCompositionCascade — ordering and invariants +// ============================================================ + +describe("planCompositionCascade", () => { + it("returns the empty plan for a kind that declares no composition parts", async () => { + const registry = buildKindRegistry(buildPodcastGraph("cascade-plan-empty")); + const backend = createTestBackend(); + const plan = await planCompositionCascade( + { graphId: "g", registry, lock: uncapturedGraphWriteLock() }, + "Tag", + "t1", + backend, + ); + expect(plan).toEqual({ members: [], consumedEdgeIds: new Set() }); + }); + + it("orders members LEAF-FIRST: Segment before its Episode, before its Podcast", async () => { + const graph = buildPodcastGraph("cascade-plan-order"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + const segment = await store.nodes.Segment.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create(segment, episode, {}); + + const registry = buildKindRegistry(graph); + const plan = await planCompositionCascade( + { graphId: graph.id, registry, lock: uncapturedGraphWriteLock() }, + "Podcast", + podcast.id, + backend, + ); + // MUTATION: drop the `.toReversed()` in `cascadeDeletionOrder` and this + // assertion flips to [Episode, Segment] (BFS discovery order). + expect(plan.members.map((member) => member.kind)).toEqual([ + "Segment", + "Episode", + ]); + expect(plan.consumedEdgeIds.size).toBe(2); + }); + + it("orders two SIBLING parts of one whole by kind then id, not by the order they were created or read", async () => { + const graph = buildPodcastGraph("cascade-plan-sibling-order"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create( + { title: "e" }, + { id: "cascade-order-episode" }, + ); + // Created in DESCENDING id order, so creation order and sort order + // disagree: anything that reports the closure in discovery (or row-read) + // order instead of sorting it answers "b" before "a". + await store.nodes.Segment.create({}, { id: "cascade-order-b" }); + await store.nodes.Segment.create({}, { id: "cascade-order-a" }); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create( + { kind: "Segment", id: "cascade-order-b" }, + episode, + {}, + ); + await store.edges.segmentOf.create( + { kind: "Segment", id: "cascade-order-a" }, + episode, + {}, + ); + + const registry = buildKindRegistry(graph); + const plan = await planCompositionCascade( + { graphId: graph.id, registry, lock: uncapturedGraphWriteLock() }, + "Podcast", + podcast.id, + backend, + ); + // MUTATION: reverse the per-round sort in `cascadeDeletionOrder` + // (src/store/operations/composition-cascade.ts) — swap the comparison's + // operands — and the two segments come back "b" before "a", failing this + // assertion on every run. + expect(plan.members.map((member) => `${member.kind}/${member.id}`)).toEqual( + [ + "Segment/cascade-order-a", + "Segment/cascade-order-b", + "Episode/cascade-order-episode", + ], + ); + }); + + it("reports that same sibling order on the delete's receipt", async () => { + const graph = buildPodcastGraph("cascade-receipt-sibling-order"); + const raw = createTestBackend(); + // Records the ids handed to each composition-edge cleanup batch, which is + // the one place the cascade's consumed edges are deleted. + const consumedEdgeIdArguments: string[][] = []; + function captureEdgeBatches( + target: T, + ): T { + const batchDelete = target.deleteEdgesBatch; + if (batchDelete === undefined) return target; + const overlay: Pick = { + deleteEdgesBatch: async (params) => { + consumedEdgeIdArguments.push([...params.ids]); + return batchDelete(params); + }, + }; + return deriveBackend>( + target, + overlay as ExactBackendOverlay>, + ); + } + const backend = deriveBackend(captureEdgeBatches(raw), { + transaction: (fn, options) => + raw.transaction((target) => fn(captureEdgeBatches(target)), options), + }); + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create( + { title: "e" }, + { id: "receipt-order-episode" }, + ); + await store.nodes.Segment.create({}, { id: "receipt-order-b" }); + await store.nodes.Segment.create({}, { id: "receipt-order-a" }); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create( + { kind: "Segment", id: "receipt-order-b" }, + episode, + {}, + ); + await store.edges.segmentOf.create( + { kind: "Segment", id: "receipt-order-a" }, + episode, + {}, + ); + + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.Podcast.delete(podcast.id); + }); + + // The plan's order IS the reported order: the receipt is a projection of + // it, never a second walk. (Same MUTATION as the test above.) + expect(receipt.cascadedParts).toEqual([ + cascadedPart( + { kind: "Segment", id: "receipt-order-a" }, + episode, + "segmentOf", + ), + cascadedPart( + { kind: "Segment", id: "receipt-order-b" }, + episode, + "segmentOf", + ), + cascadedPart(episode, podcast, "episodeOf"), + ]); + + // The consumed composition EDGE rows are deleted in one agreed order too — + // sorted by id, not in the order the walk collected them — so two cascades + // over overlapping closures cannot take these row locks in opposite + // orders. + // MUTATION: reverse the sort in `applyCompositionCascade`'s + // `deleteCompositionEdges` call (src/store/operations/node-operations.ts) + // and this assertion fails. + expect(consumedEdgeIdArguments).toHaveLength(1); + const deletedEdgeIds = requireDefined(consumedEdgeIdArguments[0]); + expect(deletedEdgeIds).toHaveLength(3); + expect(deletedEdgeIds).toEqual([...deletedEdgeIds].toSorted()); + }); + + it("throws CompositionCycleError on a revisit rather than truncating", async () => { + const graph = buildPodcastGraph("cascade-plan-revisit"); + const registry = buildKindRegistry(graph); + // A fake backend whose rows admit the SAME (kind, id) member twice — an + // INSTANCE-level cycle, which reflexive composition permits at the kind + // level (nothing yet refuses the corresponding write-time cycle) — hand- + // built here to reach the throw directly rather than depending on a real + // reflexive fixture. + const rootToEpisode: EdgeRow = { + graph_id: "g", + id: "root-to-episode", + kind: "episodeOf", + from_kind: "Episode", + from_id: "e1", + to_kind: "Podcast", + to_id: "p1", + props: {}, + valid_from: undefined, + valid_to: undefined, + created_at: "now", + updated_at: "now", + deleted_at: undefined, + }; + // Two DISTINCT segmentOf rows both binding the same Segment "s1" to + // Episode "e1": the second one, processed in the same round, resolves + // to an already-visited member. + const episodeToSegment: EdgeRow = { + ...rootToEpisode, + id: "episode-to-segment-1", + kind: "segmentOf", + from_kind: "Segment", + from_id: "s1", + to_kind: "Episode", + to_id: "e1", + }; + const episodeToSegmentAgain: EdgeRow = { + ...episodeToSegment, + id: "episode-to-segment-2", + }; + const fakeBackend = { + findEdgesByHeterogeneousEndpointSet: undefined, + findEdgesConnectedTo: ({ nodeId }: { nodeId: string }) => + Promise.resolve( + nodeId === "p1" ? [rootToEpisode] + : nodeId === "e1" ? [episodeToSegment, episodeToSegmentAgain] + : [], + ), + } as unknown as GraphBackend; + + await expect( + planCompositionCascade( + { graphId: "g", registry, lock: uncapturedGraphWriteLock() }, + "Podcast", + "p1", + fakeBackend, + ), + ).rejects.toThrow( + matchingObject({ + name: "CompositionCycleError", + code: "COMPOSITION_CYCLE_DETECTED", + }), + ); + }); +}); + +// ============================================================ +// Unit test: the heterogeneous set read's OWN temporal disposition +// +// `readWholeSideEdges` falls back to `findEdgesConnectedTo` whenever the set +// read returns zero rows — a childless round on a set-read-capable backend +// is therefore never PROOF the set read itself handles an ended row +// correctly; it could be silently rerouting to the (separately correct) +// fallback every time. This asserts the set read ALONE, bypassing that +// fallback entirely, so a future regression narrowing it to only +// open-ended rows cannot hide behind the reroute. +// ============================================================ + +describe("findEdgesByHeterogeneousEndpointSet — the set read's own temporal disposition", () => { + it("returns an ended-but-undeleted composition edge with excludeDeleted alone (no temporalMode)", async () => { + const graph = buildPodcastGraph("cascade-set-read-ended-row"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + const edge = await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.episodeOf.update( + edge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + const setRead = backend.findEdgesByHeterogeneousEndpointSet; + if (setRead === undefined) { + throw new Error( + "createTestBackend() is expected to license findEdgesByHeterogeneousEndpointSet", + ); + } + // MUTATION: add `temporalMode: "current"` (or any validTo-filtering + // condition) to `buildTemporalConditions`'s unconditional branch and + // this row disappears — `compositionEdgeCounts`, not the read, is + // supposed to be the only place `population: "one"` vs `"oneActive"` + // is decided. + const rows = await setRead({ + graphId: graph.id, + side: "to", + endpoints: [{ kind: "Podcast", id: podcast.id }], + edgeKinds: ["episodeOf"], + excludeDeleted: true, + }); + expect(rows).toHaveLength(1); + expect(rows[0]?.id).toBe(edge.id); + expect(rows[0]?.valid_to).toBeDefined(); + }); +}); + +// ============================================================ +// Integration tests through the real Store API +// ============================================================ + +describe("composition cascade — delete", () => { + it("deletes Podcast -> Episode -> Segment leaf-first in one transaction, releasing uniqueness and leaving no composition edge behind", async () => { + const graph = buildPodcastGraph("cascade-two-level"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + const segment = await store.nodes.Segment.create({}); + const episodeOfEdge = await store.edges.episodeOf.create( + episode, + podcast, + {}, + ); + const segmentOfEdge = await store.edges.segmentOf.create( + segment, + episode, + {}, + ); + + await store.nodes.Podcast.delete(podcast.id); + + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Segment.getById(segment.id), + ).resolves.toBeUndefined(); + await expect( + store.edges.episodeOf.getById(episodeOfEdge.id), + ).resolves.toBeUndefined(); + await expect( + store.edges.segmentOf.getById(segmentOfEdge.id), + ).resolves.toBeUndefined(); + + // Uniqueness released: a fresh Episode may reuse the deleted title. + const reusedTitle = await store.nodes.Episode.create({ title: "Pilot" }); + expect(reusedTitle.title).toBe("Pilot"); + }); + + it("reads each cascade member again at its own delete, and a constrained one after its tombstone", async () => { + const graph = buildPodcastGraph("cascade-member-row-reuse"); + const raw = createTestBackend(); + const reads: string[] = []; + function countReads( + target: T, + ): T { + return deriveBackend>(target, { + getNode: async (graphId: string, kind: string, id: string) => { + reads.push(`${kind}/${id}`); + return target.getNode(graphId, kind, id); + }, + } as ExactBackendOverlay>); + } + const backend = deriveBackend(countReads(raw), { + transaction: (fn, options) => + raw.transaction((target) => fn(countReads(target)), options), + }); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + const segment = await store.nodes.Segment.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create(segment, episode, {}); + + reads.length = 0; + await store.nodes.Podcast.delete(podcast.id); + + // Two reads per member: `planCompositionCascade`'s liveness pass, then the + // member's own delete confirming the row is still live. A member whose + // kind declares a unique constraint (Episode) is read once more, after + // its tombstone, for the props its uniqueness release is computed from: + // an update can commit before the tombstone without taking the per-graph + // lock. `tests/backends/postgres/concurrent-composition-cascade.test.ts` + // pins what that read buys on a real second connection. + expect( + reads.filter((read) => read === `Episode/${episode.id}`), + ).toHaveLength(3); + expect( + reads.filter((read) => read === `Segment/${segment.id}`), + ).toHaveLength(2); + }); + // MUTATION: carry the plan's row on each member and pass it as + // `deleteNodeRowInFrame`'s `existing` in `applyCompositionCascade` + // (src/store/operations/node-operations.ts) — both filters then find 1 read. + + it("finds a part through the one-time fallback when the set read answers empty although rows exist", async () => { + const graph = buildPodcastGraph("cascade-set-read-empty-fallback"); + const raw = createTestBackend(); + // A port whose set read is out of step with its rows: it answers every + // call with no rows at all. An empty answer is not evidence, so the + // first round must confirm it through `findEdgesConnectedTo`. + const setReadCalls: number[] = []; + function emptySetRead( + target: T, + ): T { + const overlay: Pick = + { + findEdgesByHeterogeneousEndpointSet: () => { + setReadCalls.push(1); + return Promise.resolve([]); + }, + }; + return deriveBackend>( + target, + overlay as ExactBackendOverlay>, + ); + } + const backend = deriveBackend(emptySetRead(raw), { + transaction: (fn, options) => + raw.transaction((target) => fn(emptySetRead(target)), options), + }); + const [store] = await createStoreWithSchema(graph, backend); + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + await store.edges.episodeOf.create(episode, podcast, {}); + + const plan = await store.nodes.Podcast.planDelete(podcast.id); + await store.nodes.Podcast.delete(podcast.id); + + expect(setReadCalls.length).toBeGreaterThan(0); + expect(plan?.cascadedParts).toEqual([ + cascadedPart(episode, podcast, "episodeOf"), + ]); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + expect(await store.edges.episodeOf.find({})).toEqual([]); + }); + // MUTATION: trust the set read's first empty answer in + // `readWholeSideEdges` (src/store/operations/composition-cascade.ts) — the + // plan then names no part and the episode survives its podcast's delete. + + it("aborts a restricting grandchild's delete ATOMICALLY — zero rows changed", async () => { + const graph = buildPodcastGraph("cascade-restrict-abort"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + const segment = await store.nodes.Segment.create({}); + const tag = await store.nodes.Tag.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create(segment, episode, {}); + // Segment's ONE non-composition edge — the reason it restricts. + await store.edges.taggedWith.create(segment, tag, {}); + + const countsBefore = await Promise.all([ + store.nodes.Podcast.getById(podcast.id), + store.nodes.Episode.getById(episode.id), + store.nodes.Segment.getById(segment.id), + store.nodes.Tag.getById(tag.id), + ]); + + await expect(store.nodes.Podcast.delete(podcast.id)).rejects.toThrow( + matchingObject({ + name: "RestrictedDeleteError", + details: matchingObject({ nodeKind: "Segment", nodeId: segment.id }), + }), + ); + + // Nothing changed: not the Podcast, not the Episode, not the Segment. + const countsAfter = await Promise.all([ + store.nodes.Podcast.getById(podcast.id), + store.nodes.Episode.getById(episode.id), + store.nodes.Segment.getById(segment.id), + store.nodes.Tag.getById(tag.id), + ]); + expect(countsAfter).toEqual(countsBefore); + expect(countsAfter.every((row) => row !== undefined)).toBe(true); + }); + + it("does not restrict deleting a PART directly out of its whole, even when the part itself declares onDelete: 'restrict'", async () => { + // A dedicated fixture: unlike `buildPodcastGraph` (which declares + // Episode `disconnect` specifically to keep this restrict question out + // of its other tests), Episode here declares `restrict` — its ONLY + // connected edge is the composition edge up to its whole. + const RestrictEpisode = defineNode("RestrictEpisode", { + schema: emptySchema, + }); + const RestrictPodcast = defineNode("RestrictPodcast", { + schema: emptySchema, + }); + const restrictEpisodeOf = defineEdge("restrictEpisodeOf", { + schema: emptySchema, + }); + const graph = defineGraph({ + id: "cascade-restrict-part-direct-delete", + nodes: { + RestrictPodcast: { type: RestrictPodcast }, + RestrictEpisode: { type: RestrictEpisode, onDelete: "restrict" }, + }, + edges: { + restrictEpisodeOf: { + type: restrictEpisodeOf, + from: [RestrictEpisode], + to: [RestrictPodcast], + cardinality: "one", + }, + }, + ontology: [ + partOf(RestrictEpisode, RestrictPodcast, { via: restrictEpisodeOf }), + ], + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.RestrictPodcast.create({}); + const episode = await store.nodes.RestrictEpisode.create({}); + const edge = await store.edges.restrictEpisodeOf.create( + episode, + podcast, + {}, + ); + + // MUTATION: drop the `!ctx.registry.isCompositionEdge(edge.kind)` filter + // from `enforceNodeDeleteBehavior`'s restrict arm and this throws + // `RestrictedDeleteError` instead of succeeding — a + // part may always be deleted out of its whole. + await store.nodes.RestrictEpisode.delete(episode.id); + + await expect( + store.nodes.RestrictEpisode.getById(episode.id), + ).resolves.toBeUndefined(); + // The whole is untouched: deleting a part directly must not touch it. + await expect( + store.nodes.RestrictPodcast.getById(podcast.id), + ).resolves.toBeDefined(); + // MUTATION: revert the restrict arm's composition-edge cleanup (return + // early once `restrictedEdges.length === 0` instead of deleting + // `unconsumedEdges` first) and this resolves to the edge row instead of + // `undefined` — the node tombstones while its composition edge survives + // pointing at it, the exact state edges cannot be in. + await expect( + store.edges.restrictEpisodeOf.getById(edge.id), + ).resolves.toBeUndefined(); + }); + + it("cascades a restricting intermediate whole's OWN parts, then does not restrict on its OWN composition edge into its whole", async () => { + // Episode here is BOTH a whole (of Segment, via segmentOf) and a part + // (of Podcast, via episodeOf) — and, unlike `buildPodcastGraph`, + // declares `onDelete: "restrict"` on itself: the exact "intermediate + // whole that is itself a part" shape. + const graph = defineGraph({ + id: "cascade-restrict-intermediate-whole", + nodes: { + Podcast: { type: Podcast }, + Episode: { type: Episode, onDelete: "restrict" }, + Segment: { type: Segment }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "oneActive", + }, + }, + ontology: [ + partOf(Episode, Podcast, { via: episodeOf }), + partOf(Segment, Episode, { via: segmentOf }), + ], + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + const segment = await store.nodes.Segment.create({}); + const episodeOfEdge = await store.edges.episodeOf.create( + episode, + podcast, + {}, + ); + const segmentOfEdge = await store.edges.segmentOf.create( + segment, + episode, + {}, + ); + + // MUTATION: same as above — drop the composition-edge exclusion from + // the restrict arm and this throws `RestrictedDeleteError` on the + // upward `episodeOf` edge, even though the Segment cascade below it + // already ran cleanly. + await store.nodes.Episode.delete(episode.id); + + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Segment.getById(segment.id), + ).resolves.toBeUndefined(); + await expect( + store.edges.segmentOf.getById(segmentOfEdge.id), + ).resolves.toBeUndefined(); + // The outer whole is untouched. + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeDefined(); + // MUTATION: same as the direct-part-delete test above — revert the + // restrict arm's composition-edge cleanup and this resolves to the + // `episodeOf` edge row instead of `undefined`: Episode's OWN upward + // composition edge into ITS whole, excluded from the restrict OBSTACLE + // count, must still be removed alongside Episode's tombstone. + await expect( + store.edges.episodeOf.getById(episodeOfEdge.id), + ).resolves.toBeUndefined(); + }); + + it("cascades three levels of REFLEXIVE composition, terminated by the visited set", async () => { + const SectionSchema = z.object({}); + const Section = defineNode("Section", { schema: SectionSchema }); + const containsSection = defineEdge("containsSection", { + schema: emptySchema, + }); + const graph = defineGraph({ + id: "cascade-reflexive", + nodes: { Section: { type: Section } }, + edges: { + containsSection: { + type: containsSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [ + partOf(Section, Section, { via: containsSection, partSide: "from" }), + ], + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const root = await store.nodes.Section.create({}, { id: "root" }); + const child = await store.nodes.Section.create({}, { id: "child" }); + const grandchild = await store.nodes.Section.create( + {}, + { id: "grandchild" }, + ); + await store.edges.containsSection.create(child, root, {}); + await store.edges.containsSection.create(grandchild, child, {}); + + await store.nodes.Section.delete(root.id); + + await expect(store.nodes.Section.getById(root.id)).resolves.toBeUndefined(); + await expect( + store.nodes.Section.getById(child.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Section.getById(grandchild.id), + ).resolves.toBeUndefined(); + }); + + it("resurrecting a soft-deleted whole restores the whole ALONE", async () => { + const graph = buildPodcastGraph("cascade-resurrect-whole-alone"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create( + { title: "My Show" }, + { id: "podcast-1" }, + ); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + await store.edges.episodeOf.create(episode, podcast, {}); + + await store.nodes.Podcast.delete(podcast.id); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + + const resurrected = await store.nodes.Podcast.create( + { title: "Anything" }, + { id: "podcast-1" }, + ); + expect(resurrected.id).toBe(podcast.id); + // The whole is back... + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeDefined(); + // ...but Episode was NOT revived alongside it: ending a whole's + // ownership when the cascade ran is not undone by reopening the whole's + // own currency. + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + }); + + it("refuses a composition whole's delete with CONSTRAINT_WRITE_FENCE_UNSUPPORTED on a backend with no interactive transaction, while a part-less kind still deletes normally", async () => { + const graph = buildPodcastGraph("cascade-fence-unsupported"); + const sqlite = new Database(":memory:"); + for (const statement of generateSqliteDDL()) sqlite.exec(statement); + const db = drizzle(sqlite); + const backend = createSqliteBackend(db, { + executionProfile: { transactionMode: "none", isSync: true }, + }); + try { + const store = createStore(graph, backend); + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + // Seeded through the raw backend, not `store.edges.episodeOf.create`: + // `episodeOf` ALSO declares `cardinality: "one"`, an unrelated + // constraint that would itself refuse on this transactionless + // backend — this test is about the composition fence on DELETE, not + // about edge-create's own fence. + await backend.insertEdge({ + graphId: graph.id, + kind: "episodeOf", + id: "episode-of-seed", + fromKind: "Episode", + fromId: episode.id, + toKind: "Podcast", + toId: podcast.id, + props: {}, + }); + + // MUTATION: make `nodeDeleteConstraintProbe` always return `undefined` + // (drop the composition classification) and this refusal disappears; + // make `nodeDeleteNeedsConstraintFence` report `"edgeCardinality"` and + // the `constraint` assertion below fails, since a cascading whole + // delete is not remediable by declaring a cardinality on an edge. + await expect(store.nodes.Podcast.delete(podcast.id)).rejects.toThrow( + matchingObject({ + name: "ConfigurationError", + details: matchingObject({ + code: "CONSTRAINT_WRITE_FENCE_UNSUPPORTED", + constraint: "edgeComposition", + }), + }), + ); + // Rollback proof: the whole and its part are untouched. + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeDefined(); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeDefined(); + + // The declaration-property fast path: a part-less kind (Tag) needs no + // fence and deletes normally on the SAME backend. + const tag = await store.nodes.Tag.create({}); + await store.nodes.Tag.delete(tag.id); + await expect(store.nodes.Tag.getById(tag.id)).resolves.toBeUndefined(); + } finally { + sqlite.close(); + } + }); + + it("population 'oneActive': reparenting a part frees it from its FORMER whole's cascade", async () => { + const graph = buildPodcastGraph("cascade-onactive-reparent"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const episodeA = await store.nodes.Episode.create({ title: "A" }); + const episodeB = await store.nodes.Episode.create({ title: "B" }); + const segment = await store.nodes.Segment.create({}); + const oldEdge = await store.edges.segmentOf.create(segment, episodeA, {}); + // Reparent: end the old window, then attach to the new episode. `oneActive` + // (holderLiveness "liveAndActive") frees the slot the instant the window + // ends, so the second create is not a cardinality conflict. + await store.edges.segmentOf.update( + oldEdge.id, + {}, + { validTo: new Date().toISOString() }, + ); + await store.edges.segmentOf.create(segment, episodeB, {}); + + await store.nodes.Episode.delete(episodeA.id); + + // Segment survived: the ended edge no longer counts under `oneActive`. + await expect( + store.nodes.Segment.getById(segment.id), + ).resolves.toBeDefined(); + }); + + it("population 'one': an ended-but-undeleted edge still counts against its whole's cascade", async () => { + const graph = buildPodcastGraph("cascade-one-no-reparent"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcastA = await store.nodes.Podcast.create({ title: "A" }); + const podcastB = await store.nodes.Podcast.create({ title: "B" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + const oldEdge = await store.edges.episodeOf.create(episode, podcastA, {}); + await store.edges.episodeOf.update( + oldEdge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + // `one` (holderLiveness "live") never frees the slot on an ended window — + // a second live edge from the same holder is still a conflict, proving a + // `one` composition edge cannot be reparented behind its whole's back + // without deleting the row outright. `episodeOf` is BOTH a declared + // `cardinality: "one"` edge and a composition (`partOf`) realizing edge, + // and the composition claim's write-fence priority (composition > + // cardinality) reports its own, more specific `CompositionError` rather + // than the generic `CardinalityError` an edge with no composition + // declaration would raise for the identical "still held" liveness rule. + await expect( + store.edges.episodeOf.create(episode, podcastB, {}), + ).rejects.toThrow(matchingObject({ name: "CompositionError" })); + + // Deleting the FORMER whole still cascades Episode away: the ended row + // counts unconditionally under `population: "one"`. + await store.nodes.Podcast.delete(podcastA.id); + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + }); +}); + +// ============================================================ +// Fixture: Album -[hasTrack]-> Track -[noteOf]<- Note +// +// Every fixture above declares `partOf`, whose realizing edge always runs +// PART -> WHOLE, so `compositionPartSide` infers `"from"` in every case — +// the "via may run in either orientation" half of the contract is +// exercised by nothing. +// This fixture uses `hasPart`, whose realizing edge runs WHOLE -> PART, to +// infer `partSide: "to"` instead, and nests a `partOf`-oriented (`"from"`) +// grandchild under it so the closure walk crosses BOTH orientations in one +// cascade. +// ============================================================ + +const Album = defineNode("Album", { + schema: z.object({ title: z.string().default("untitled") }), +}); +const Track = defineNode("Track", { schema: emptySchema }); +const Note = defineNode("Note", { schema: emptySchema }); + +const hasTrack = defineEdge("hasTrack", { schema: emptySchema }); +const noteOf = defineEdge("noteOf", { schema: emptySchema }); + +function buildAlbumGraph( + id: string, + trackTargetCardinality: "one" | "oneActive", +) { + return defineGraph({ + id, + nodes: { + Album: { type: Album }, + Track: { type: Track, onDelete: "disconnect" }, + Note: { type: Note }, + }, + edges: { + hasTrack: { + type: hasTrack, + from: [Album], + to: [Track], + targetCardinality: trackTargetCardinality, + }, + noteOf: { type: noteOf, from: [Note], to: [Track], cardinality: "one" }, + }, + ontology: [ + // WHOLE -> PART: infers `partSide: "to"` (Track is the edge's `to`). + hasPart(Album, Track, { via: hasTrack }), + // PART -> WHOLE: infers `partSide: "from"` (Note is the edge's `from`). + partOf(Note, Track, { via: noteOf }), + ], + }); +} + +describe("composition cascade — to-oriented (hasPart) and mixed-orientation closures", () => { + it("cascades a to-oriented (hasPart) whole to its part", async () => { + const graph = buildAlbumGraph("cascade-haspart-one-level", "one"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const album = await store.nodes.Album.create({ title: "Debut" }); + const track = await store.nodes.Track.create({}); + const hasTrackEdge = await store.edges.hasTrack.create(album, track, {}); + + await store.nodes.Album.delete(album.id); + + await expect(store.nodes.Album.getById(album.id)).resolves.toBeUndefined(); + // MUTATION: in `planCompositionCascade`, make the `partSide === "to"` + // resolution read `row.from_kind`/`row.from_id` instead of + // `row.to_kind`/`row.to_id` (breaking only the to-oriented arm) and this + // assertion flips — Track survives because the cascade resolves the + // WHOLE, not the part, as the member to delete. + await expect(store.nodes.Track.getById(track.id)).resolves.toBeUndefined(); + await expect( + store.edges.hasTrack.getById(hasTrackEdge.id), + ).resolves.toBeUndefined(); + }); + + it("cascades a MIXED-orientation closure: a to-oriented whole with a from-oriented grandchild", async () => { + const graph = buildAlbumGraph("cascade-haspart-mixed-orientation", "one"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const album = await store.nodes.Album.create({ title: "Debut" }); + const track = await store.nodes.Track.create({}); + const note = await store.nodes.Note.create({}); + await store.edges.hasTrack.create(album, track, {}); + await store.edges.noteOf.create(note, track, {}); + + await store.nodes.Album.delete(album.id); + + await expect(store.nodes.Album.getById(album.id)).resolves.toBeUndefined(); + await expect(store.nodes.Track.getById(track.id)).resolves.toBeUndefined(); + // The grandchild, reached through the SECOND (from-oriented) level of + // the same walk, is cascaded too. + await expect(store.nodes.Note.getById(note.id)).resolves.toBeUndefined(); + }); + + it("targetCardinality 'oneActive': reparenting a to-oriented part frees it from its FORMER whole's cascade", async () => { + const graph = buildAlbumGraph( + "cascade-haspart-onactive-reparent", + "oneActive", + ); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const albumA = await store.nodes.Album.create({ title: "A" }); + const albumB = await store.nodes.Album.create({ title: "B" }); + const track = await store.nodes.Track.create({}); + const oldEdge = await store.edges.hasTrack.create(albumA, track, {}); + // Reparent: end the old window, then attach to the new album. `oneActive` + // frees the slot the instant the window ends, so the second create is + // not a target-cardinality conflict. + await store.edges.hasTrack.update( + oldEdge.id, + {}, + { validTo: new Date().toISOString() }, + ); + await store.edges.hasTrack.create(albumB, track, {}); + + await store.nodes.Album.delete(albumA.id); + + // Track survived: the ended edge no longer counts under `oneActive`. + await expect(store.nodes.Track.getById(track.id)).resolves.toBeDefined(); + }); + + it("targetCardinality 'one': an ended-but-undeleted to-oriented edge still counts against its whole's cascade", async () => { + const graph = buildAlbumGraph("cascade-haspart-one-no-reparent", "one"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const albumA = await store.nodes.Album.create({ title: "A" }); + const albumB = await store.nodes.Album.create({ title: "B" }); + const track = await store.nodes.Track.create({}); + const oldEdge = await store.edges.hasTrack.create(albumA, track, {}); + await store.edges.hasTrack.update( + oldEdge.id, + {}, + { validTo: new Date().toISOString() }, + ); + + // `one` never frees the slot on an ended window — a second live edge + // targeting the same Track is still a conflict, proving a `one` + // composition edge cannot be reparented behind its whole's back without + // deleting the row outright. `hasTrack` is BOTH a declared + // `targetCardinality: "one"` edge and a composition (`hasPart`) + // realizing edge, and the composition claim's write-fence priority + // (composition > cardinality) reports its own, more specific + // `CompositionError` rather than the generic `CardinalityError` an edge + // with no composition declaration would raise for the identical "still + // held" liveness rule. + await expect( + store.edges.hasTrack.create(albumB, track, {}), + ).rejects.toThrow(matchingObject({ name: "CompositionError" })); + + // Deleting the FORMER whole still cascades Track away: the ended row + // counts unconditionally under `targetCardinality: "one"`. + await store.nodes.Album.delete(albumA.id); + await expect(store.nodes.Track.getById(track.id)).resolves.toBeUndefined(); + }); +}); + +/** + * "Concurrent attach racing a cascade", translated through the write lock + * `planCompositionCascade` reads under: the lock is taken BEFORE any row + * read, so no interleaving other than "fully before" or "fully after" the + * cascade's snapshot is reachable by ANY concurrent transaction — a + * `store.transaction` attach either commits before the delete's transaction + * opens (and the part is cascaded away with everything else) or after it + * commits (and is refused because the whole no longer exists to attach to). + * These two deterministic orderings are therefore an exact, non-flaky + * translation of the race, rather than a best-effort timing simulation. + * + * A genuinely concurrent two-connection race against the SAME PostgreSQL + * fence (two overlapping `pg_advisory_xact_lock` acquisitions on real + * Postgres, not PGlite's single embedded connection) is a documented case + * for a real-PostgreSQL run: open a delete transaction, pause it + * immediately after `planCompositionCascade` returns (before the cascade's + * row work commits), start a concurrent attach transaction from a SECOND + * connection, and assert it blocks on `typegraph:recorded-graph-write` + * until the delete transaction ends — never observing a part half-attached + * to an already-vanished whole. + */ +describe("composition cascade — attach ordering under the write lock", () => { + it("an attach committed BEFORE the delete opens is included in the cascade", async () => { + const graph = buildPodcastGraph("cascade-attach-before"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + // The attach — fully committed before the delete's own transaction opens. + await store.edges.episodeOf.create(episode, podcast, {}); + + await store.nodes.Podcast.delete(podcast.id); + + await expect( + store.nodes.Episode.getById(episode.id), + ).resolves.toBeUndefined(); + }); + + it("an attach attempted AFTER the delete commits is refused, never orphaned", async () => { + const graph = buildPodcastGraph("cascade-attach-after"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "p" }); + const episode = await store.nodes.Episode.create({ title: "e" }); + await store.nodes.Podcast.delete(podcast.id); + + // The whole is gone; an attach reaching it afterward must be refused — + // never silently accepted into a dangling composition edge. + await expect( + store.edges.episodeOf.create(episode, podcast, {}), + ).rejects.toThrow(); + await expect( + store.nodes.Podcast.getById(podcast.id), + ).resolves.toBeUndefined(); + }); +}); + +// ============================================================ +// The cascade's EXPOSURE: the delete's hook context and receipt +// ============================================================ + +describe("composition cascade — cascadedParts exposure", () => { + it("names the cascaded parts on the whole's onOperationEnd context, and nothing on onOperationStart", async () => { + const graph = buildPodcastGraph("cascade-hook-exposure"); + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + + const started: OperationHookContext[] = []; + const ended: OperationHookContext[] = []; + const store = createStore(graph, backend, { + hooks: { + onOperationStart: (ctx) => started.push(ctx), + onOperationEnd: (ctx) => ended.push(ctx), + }, + }); + + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + const segment = await store.nodes.Segment.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create(segment, episode, {}); + + started.length = 0; + ended.length = 0; + await store.nodes.Podcast.delete(podcast.id); + + // One event for the whole, exactly as before — the cascade adds no + // per-part operation. + expect(ended).toHaveLength(1); + const endContext = requireDefined(ended[0]); + expect(endContext.kind).toBe("Podcast"); + // MUTATION: drop `operationFacts: nodeDeleteOperationFacts` from + // `executeNodeDelete`'s write-plan options + // (src/store/operations/node-operations.ts) and this becomes undefined. + // MUTATION: project only `{ kind, id }` in `cascadedPartReferences` + // (src/store/operations/composition-cascade.ts) and the `whole` / + // `viaEdgeKind` of each entry go missing here. + expect(endContext.cascadedParts).toEqual([ + cascadedPart(segment, episode, "segmentOf"), + cascadedPart(episode, podcast, "episodeOf"), + ]); + // The START context cannot carry them: the cascade has not been planned. + expect(requireDefined(started[0]).cascadedParts).toBeUndefined(); + }); + + it("reports an empty cascade for a whole with no live parts", async () => { + const graph = buildPodcastGraph("cascade-hook-exposure-empty"); + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + + const ended: OperationHookContext[] = []; + const store = createStore(graph, backend, { + hooks: { onOperationEnd: (ctx) => ended.push(ctx) }, + }); + const podcast = await store.nodes.Podcast.create({ title: "Lonely" }); + ended.length = 0; + + await store.nodes.Podcast.delete(podcast.id); + expect(requireDefined(ended[0]).cascadedParts).toEqual([]); + }); + + it("carries the same parts on the transaction receipt, and a hard delete reports them too", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "My Show" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + const segment = await store.nodes.Segment.create({}); + await store.edges.episodeOf.create(episode, podcast, {}); + await store.edges.segmentOf.create(segment, episode, {}); + + // MUTATION: drop the `ctx.recordCascadedParts?.(outcome.cascadedParts)` + // call from `executeNodeHardDelete` and `cascadedParts` stays empty here + // while the delete still removes both parts. + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.Podcast.hardDelete(podcast.id); + }); + + expect(receipt.cascadedParts).toEqual([ + cascadedPart(segment, episode, "segmentOf"), + cascadedPart(episode, podcast, "episodeOf"), + ]); + // The cascade is NOT folded into the write counters: one caller-issued + // delete stays one write intent. + expect(receipt.writes.nodes).toEqual({ Podcast: 1 }); + await expect( + store.nodes.Segment.getById(segment.id), + ).resolves.toBeUndefined(); + }); + + it("reports every whole's cascade from a bulkDelete, in batch order", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure-bulk"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const first = await store.nodes.Podcast.create({ title: "First" }); + const firstEpisode = await store.nodes.Episode.create({ title: "1x01" }); + const firstSegment = await store.nodes.Segment.create({}); + await store.edges.episodeOf.create(firstEpisode, first, {}); + await store.edges.segmentOf.create(firstSegment, firstEpisode, {}); + + const second = await store.nodes.Podcast.create({ title: "Second" }); + const secondEpisode = await store.nodes.Episode.create({ title: "2x01" }); + await store.edges.episodeOf.create(secondEpisode, second, {}); + + // MUTATION: drop the `ctx.recordCascadedParts?.(outcome.cascadedParts)` + // call from `executeNodeDeleteBatch` + // (src/store/operations/node-operations.ts) — every part below is still + // deleted and `receipt.cascadedParts` comes back `[]`, which is the + // defect this test exists for: the batch path ran the cascade and + // reported nothing. + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.Podcast.bulkDelete([first.id, second.id]); + }); + + // Leaf-first within each item, items in the batch's own order. + expect(receipt.cascadedParts).toEqual([ + cascadedPart(firstSegment, firstEpisode, "segmentOf"), + cascadedPart(firstEpisode, first, "episodeOf"), + cascadedPart(secondEpisode, second, "episodeOf"), + ]); + await expect( + store.nodes.Segment.getById(firstSegment.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.Episode.getById(secondEpisode.id), + ).resolves.toBeUndefined(); + }); + + it("populates a tx.measure scope's receipt AND the outer one, attributed to the context the delete ran through", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure-measured"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const measured = await store.nodes.Podcast.create({ title: "Measured" }); + const measuredEpisode = await store.nodes.Episode.create({ + title: "In scope", + }); + await store.edges.episodeOf.create(measuredEpisode, measured, {}); + + const outer = await store.nodes.Podcast.create({ title: "Outer" }); + const outerEpisode = await store.nodes.Episode.create({ + title: "Out of scope", + }); + await store.edges.episodeOf.create(outerEpisode, outer, {}); + + // MUTATION: build the measured scope by wrapping the outer context's + // collections a second time instead of rebuilding the write surface + // against the recorder chain (`#attachMeasure`, src/store/store.ts) — the + // scope's write COUNTERS still come out right, while the delete's + // cascade reaches the transaction's recorder alone and + // `scope.receipt.cascadedParts` comes back `[]`. + const { receipt, result } = await store.transactionWithReceipt( + async (tx) => { + const scope = await tx.measure(async (scoped) => { + await scoped.nodes.Podcast.delete(measured.id); + }); + // Issued through the OUTER context while no scope is open: attribution + // is by context, so this cascade belongs to the transaction alone. + await tx.nodes.Podcast.delete(outer.id); + return scope; + }, + ); + + expect(result.receipt.cascadedParts).toEqual([ + cascadedPart(measuredEpisode, measured, "episodeOf"), + ]); + expect(result.receipt.writes.nodes).toEqual({ Podcast: 1 }); + // The outer receipt sees both cascades, in the order they ran. + expect(receipt.cascadedParts).toEqual([ + cascadedPart(measuredEpisode, measured, "episodeOf"), + cascadedPart(outerEpisode, outer, "episodeOf"), + ]); + }); + + it("populates a measured scope's receipt for a delete issued through the transaction's internal delete port", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure-runtime-port"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "Ported" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + await store.edges.episodeOf.create(episode, podcast, {}); + + // `TRANSACTION_RUNTIME`'s delete port is how a caller already inside a + // transaction reaches a non-default `NodeDeletePolicy` (merge apply is the + // one today). It carries its own node operation context, so a scope that + // rebuilds the write surface but inherits the OUTER port runs the delete + // against the outer context — right counters, no cascade. + // + // MUTATION: drop the `[TRANSACTION_RUNTIME]` overlay from `#attachMeasure` + // (src/store/store.ts) so the scoped context keeps the outer port — the + // scope's `cascadedParts` then comes back `[]` while the outer receipt + // still lists the episode. + const { receipt, result } = await store.transactionWithReceipt((tx) => + tx.measure((scoped) => + transactionDeleteNodeWithPolicy(scoped, { + kind: "Podcast", + id: podcast.id, + }), + ), + ); + + expect(result.receipt.cascadedParts).toEqual([ + cascadedPart(episode, podcast, "episodeOf"), + ]); + expect(receipt.cascadedParts).toEqual([ + cascadedPart(episode, podcast, "episodeOf"), + ]); + }); + + it("leaves a measured scope's cascadedParts empty when the delete runs outside it", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure-unmeasured"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const podcast = await store.nodes.Podcast.create({ title: "Outer only" }); + const episode = await store.nodes.Episode.create({ title: "Pilot" }); + await store.edges.episodeOf.create(episode, podcast, {}); + + // MUTATION: attribute the cascade by TIMING rather than by context (for + // example, push the scope recorder onto a "currently measuring" stack for + // the duration of the callback) — this delete, issued through `tx` while + // the scope is open, then leaks into the scope's receipt. + const { receipt, result } = await store.transactionWithReceipt( + async (tx) => { + const scope = await tx.measure(async () => { + await tx.nodes.Podcast.delete(podcast.id); + }); + return scope; + }, + ); + + expect(result.receipt.cascadedParts).toEqual([]); + expect(receipt.cascadedParts).toEqual([ + cascadedPart(episode, podcast, "episodeOf"), + ]); + }); + + it("leaves cascadedParts empty on a transaction that deletes no composition whole", async () => { + const graph = buildPodcastGraph("cascade-receipt-exposure-none"); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const { receipt } = await store.transactionWithReceipt(async (tx) => { + await tx.nodes.Tag.create({}); + }); + expect(receipt.cascadedParts).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/composition-edge-delete-pglite.test.ts b/packages/typegraph/tests/composition-edge-delete-pglite.test.ts new file mode 100644 index 000000000..04dc12075 --- /dev/null +++ b/packages/typegraph/tests/composition-edge-delete-pglite.test.ts @@ -0,0 +1,112 @@ +/** + * An edge bulk delete of a required-existence composition edge kind is + * statically ineligible for the fused atomic `deleteEdges` command, on the ONE + * backend configuration where that command is reachable at all — a PGlite ROOT + * backend (see `node-delete-policy-root-atomic-bypass-pglite.test.ts`'s doc for + * why SQLite and every transaction-scoped backend never reach it). + * + * The fused command is read-free, so it cannot ask whether the part a + * composition edge holds is still live; only the portable path's per-member + * `assertCompositionExistencePreserved` can. The optional-existence control + * kind below proves the fused path is reachable here, so the required kind's + * absence from the dispatch log is the decline, not an unreachable command. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CompositionExistenceError, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { withAtomicMutationProgramDispatchObserver } from "../src/backend/capabilities/atomic-mutation-program"; +import { createPostgresBackend } from "../src/backend/drizzle/postgres"; +import { createLocalPgliteBackend } from "../src/backend/postgres/pglite"; +import { createStoreWithSchema } from "../src/store"; + +const Episode = defineNode("Episode", { schema: z.object({}) }); +const Segment = defineNode("Segment", { schema: z.object({}) }); +const Collection = defineNode("Collection", { schema: z.object({}) }); +const Tag = defineNode("Tag", { schema: z.object({}) }); +const segmentOf = defineEdge("segmentOf", { schema: z.object({}) }); +const tagOf = defineEdge("tagOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-edge-delete-pglite-fused-ineligible", + nodes: { + Episode: { type: Episode }, + Segment: { type: Segment }, + Collection: { type: Collection }, + Tag: { type: Tag }, + }, + edges: { + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "one", + }, + tagOf: { type: tagOf, from: [Tag], to: [Collection], cardinality: "one" }, + }, + ontology: [ + partOf(Segment, Episode, { via: segmentOf, existence: "required" }), + partOf(Tag, Collection, { via: tagOf }), + ], +}); + +describe("an edge bulkDelete of a required-existence composition edge kind on a PGlite root backend", () => { + it("never dispatches the fused deleteEdges command, and refuses detaching a live part", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const episode = await store.nodes.Episode.create({}); + const segment = await store.nodes.Segment.create( + {}, + { partOf: { whole: { kind: "Episode", id: episode.id } } }, + ); + const collection = await store.nodes.Collection.create({}); + await store.nodes.Tag.create( + {}, + { partOf: { whole: { kind: "Collection", id: collection.id } } }, + ); + const segmentEdgeIds = async () => { + const edges = await store.edges.segmentOf.find({}); + return edges.map((edge) => edge.id); + }; + const segmentEdgesBefore = await segmentEdgeIds(); + const tagEdges = await store.edges.tagOf.find({}); + + const dispatched: string[] = []; + const refusal = await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + async () => { + await store.edges.tagOf.bulkDelete(tagEdges.map((edge) => edge.id)); + expect(dispatched).toEqual(["deleteEdges"]); + return store.edges.segmentOf + .bulkDelete(segmentEdgesBefore) + .catch((error: unknown) => error); + }, + ); + + // MUTATION: drop the `compositionEdgeHasRequiredExistencePart` decline + // from `resolveAtomicEdgeDeleteBatchExecutor` and a second + // `deleteEdges` appears here, the delete resolves, and the segment is + // left with no whole. + expect(dispatched).toEqual(["deleteEdges"]); + expect(refusal).toBeInstanceOf(CompositionExistenceError); + expect((refusal as CompositionExistenceError).details).toMatchObject({ + situation: "detach", + partId: segment.id, + }); + expect(await segmentEdgeIds()).toEqual(segmentEdgesBefore); + expect(await store.edges.tagOf.find({})).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + } finally { + await local.backend.close(); + } + }); +}); diff --git a/packages/typegraph/tests/composition-existence-fresh-snapshot-pglite.test.ts b/packages/typegraph/tests/composition-existence-fresh-snapshot-pglite.test.ts new file mode 100644 index 000000000..631908f88 --- /dev/null +++ b/packages/typegraph/tests/composition-existence-fresh-snapshot-pglite.test.ts @@ -0,0 +1,182 @@ +/** + * A detach's "is this required part still live" verdict is a lock-only + * decision: no claim row records it, so it is trustworthy only from a + * snapshot taken after the per-graph fence. On a port whose fence advances no + * fence row, a `REPEATABLE READ` or `SERIALIZABLE` snapshot cannot be shown + * fresh, so the detach must refuse rather than decide from it. + * + * The bundled PostgreSQL backend never reaches this refusal: its detach plan + * advances the graph's fence row in the plan's own first statement, so a + * stale snapshot fails there (see + * `backends/postgres/concurrent-composition-detach-fence.test.ts`). This port + * is the one shape where the verdict's own gate is the only thing standing + * between a stale read and an orphaned part. + * + * The part is tombstoned through the raw backend member with its composition + * edge left live, so every detach below would otherwise be allowed. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + ConfigurationError, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { createPostgresBackend } from "../src/backend/drizzle/postgres"; +import { createLocalPgliteBackend } from "../src/backend/postgres/pglite"; +import { createStoreWithSchema } from "../src/store"; +import { requireDefined } from "../src/utils/presence"; +import { advisoryOnlyBackend } from "./advisory-only-backend"; + +const Show = defineNode("Show", { schema: z.object({}) }); +const Clip = defineNode("Clip", { schema: z.object({}) }); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-existence-fresh-snapshot", + nodes: { Show: { type: Show }, Clip: { type: Clip } }, + edges: { + // `oneActive`, so ending the window is a detach too. + clipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + }, + ontology: [partOf(Clip, Show, { via: clipOf, existence: "required" })], +}); + +type GraphStore = Awaited< + ReturnType> +>[0]; + +const DETACHES: readonly Readonly<{ + name: string; + run: (store: GraphStore, edgeId: string) => Promise; +}>[] = [ + { + name: "edge delete", + run: (store, edgeId) => + store.edges.clipOf.delete(asEdgeId(edgeId)), + }, + { + name: "edge hardDelete", + run: (store, edgeId) => + store.edges.clipOf.hardDelete(asEdgeId(edgeId)), + }, + { + name: "edge bulkDelete", + run: (store, edgeId) => + store.edges.clipOf.bulkDelete([asEdgeId(edgeId)]), + }, + { + name: "edge window end", + run: (store, edgeId) => + store.edges.clipOf.update( + asEdgeId(edgeId), + {}, + { validTo: new Date().toISOString() }, + ), + }, +]; + +const SNAPSHOT_ISOLATION_LEVELS = [ + { setting: "repeatable read", isolation: "repeatable_read" }, + { setting: "serializable", isolation: "serializable" }, +] as const; + +async function withTombstonedPart( + run: ( + fixture: Readonly<{ + store: GraphStore; + edgeId: string; + setIsolation: (setting: string) => Promise; + edgeAndClaimRows: () => Promise; + }>, + ) => Promise, +): Promise { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = advisoryOnlyBackend( + createPostgresBackend(local.db, { vector: false }), + ); + try { + const [store] = await createStoreWithSchema(graph, backend); + const show = await store.nodes.Show.create({}); + const clip = await store.nodes.Clip.create({}, { partOf: { whole: show } }); + await backend.deleteNode({ graphId: graph.id, kind: "Clip", id: clip.id }); + const [edge] = await store.edges.clipOf.find({}); + await run({ + store, + edgeId: requireDefined(edge).id, + setIsolation: async (setting) => { + await local.client.exec( + `SET default_transaction_isolation = '${setting}'`, + ); + }, + edgeAndClaimRows: async () => { + const edges = await local.client.query( + "SELECT * FROM typegraph_edges WHERE graph_id = $1 ORDER BY id", + [graph.id], + ); + const claims = await local.client.query( + "SELECT * FROM typegraph_edge_claims WHERE graph_id = $1 ORDER BY axis, key", + [graph.id], + ); + return { edges: edges.rows, claims: claims.rows }; + }, + }); + } finally { + await local.backend.close(); + } +} + +describe("a required part's detach on a port whose fence advances no fence row", () => { + // MUTATION CHECK: drop the `fenceCompositionExistenceDecision` call from + // `assertCompositionExistencePreserved` + // (src/store/operations/composition-create.ts) — every snapshot case then + // decides from the unproven snapshot and the detach goes through. + for (const { setting, isolation } of SNAPSHOT_ISOLATION_LEVELS) { + for (const detach of DETACHES) { + it(`${detach.name} refuses at ${isolation} and writes nothing`, async () => { + await withTombstonedPart( + async ({ store, edgeId, setIsolation, edgeAndClaimRows }) => { + const before = await edgeAndClaimRows(); + await setIsolation(setting); + const refusal = await detach + .run(store, edgeId) + .catch((error: unknown) => error); + await setIsolation("read committed"); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect(refusal).toMatchObject({ + details: { + code: "COMPOSITION_EXISTENCE_REQUIRES_FRESH_SNAPSHOT", + isolation, + fenceRow: "none", + }, + }); + expect(await edgeAndClaimRows()).toEqual(before); + }, + ); + }); + } + } + + it.each(DETACHES)( + "$name of the tombstoned part's edge goes through at read committed", + async (detach) => { + await withTombstonedPart(async ({ store, edgeId }) => { + await detach.run(store, edgeId); + const [edge] = await store.edges.clipOf.find({}); + expect(edge === undefined || edge.meta.validTo !== undefined).toBe( + true, + ); + }); + }, + ); +}); diff --git a/packages/typegraph/tests/composition-navigation-exhaustive.test.ts b/packages/typegraph/tests/composition-navigation-exhaustive.test.ts new file mode 100644 index 000000000..fb9e2f1c2 --- /dev/null +++ b/packages/typegraph/tests/composition-navigation-exhaustive.test.ts @@ -0,0 +1,103 @@ +/** + * The decision between an exhaustive closure and a stated hop bound for + * `parts()` / `wholes()`, observed on the query AST: the end-to-end behavior + * (a chain past the old ceiling comes back whole) lives in the shared + * cross-backend composition-navigation suite. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { type VariableLengthSpec } from "../src/query/ast"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Section = defineNode("Section", { schema: z.object({}) }); +const parentSection = defineEdge("parentSection", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition_navigation_exhaustive", + nodes: { Section: { type: Section } }, + edges: { + parentSection: { + type: parentSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [ + partOf(Section, Section, { via: parentSection, partSide: "from" }), + ], +}); + +type NavigationOptions = Readonly<{ + maxHops?: number; + depth?: boolean; + path?: boolean; +}>; + +function partsTraversal(options?: NavigationOptions): VariableLengthSpec { + const store = createStore(graph, createTestBackend()); + const ast = store + .query() + .from("Section", "s") + .parts("x", options) + .select((ctx) => ctx.x.id) + .toAst(); + return requireDefined( + ast.traversals.find((traversal) => traversal.nodeAlias === "x") + ?.variableLength, + ); +} + +function wholesTraversal(options?: NavigationOptions): VariableLengthSpec { + const store = createStore(graph, createTestBackend()); + const ast = store + .query() + .from("Section", "s") + .wholes("x", options) + .select((ctx) => ctx.x.id) + .toAst(); + return requireDefined( + ast.traversals.find((traversal) => traversal.nodeAlias === "x") + ?.variableLength, + ); +} + +describe.each([ + { name: "parts()", traversal: partsTraversal }, + { name: "wholes()", traversal: wholesTraversal }, +])("$name with and without a stated maxHops", ({ traversal }) => { + it.each([ + { label: "no options", options: undefined }, + { label: "a depth column", options: { depth: true } }, + { label: "a path column", options: { path: true } }, + ])("is exhaustive and states no hop bound with $label", ({ options }) => { + expect(traversal(options)).toMatchObject({ + exhaustive: true, + maxDepth: -1, + cyclePolicy: "prevent", + }); + }); + + it.each([1, 5, 1000])( + "applies a stated maxHops of %i exactly and is not exhaustive", + (maxHops) => { + const spec = traversal({ maxHops, depth: true }); + + expect(spec.maxDepth).toBe(maxHops); + expect(spec.exhaustive).toBeUndefined(); + }, + ); +}); +// MUTATION CHECK: make `TraversalBuilder#recursive` keep `exhaustive` when a +// `maxHops` is stated (drop the `options?.maxHops === undefined &&` clause) and +// the stated-bound rows fail. Pass no exhaustive state from `navigateComposition` +// and the unbounded rows fail. `recursive()` is the single owner of the decision. diff --git a/packages/typegraph/tests/composition-portable-probe.test.ts b/packages/typegraph/tests/composition-portable-probe.test.ts new file mode 100644 index 000000000..b76e44a50 --- /dev/null +++ b/packages/typegraph/tests/composition-portable-probe.test.ts @@ -0,0 +1,257 @@ +/** + * "One whole per part" on the paths where no claim row decides it. + * + * The composition invariant spans every realizing edge kind, so a probe that + * counted only the edge kind being written would let a part attach to a + * second whole through a different kind. These cases drive the two backend + * shapes that reach the portable probe — a custom command port answering + * `unsupported`, and a backend that declares no claim relation at all — and + * hold them to the refusal a first-party backend's claim row raises. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CompositionError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { + deriveBackend, + projectBackendWithout, +} from "../src/backend/derive-backend"; +import { + type GraphBackend, + type GraphCommand, + type GraphCommandResult, + type TransactionBackend, +} from "../src/backend/types"; +import { createTestBackend } from "./test-utils"; + +const WholeA = defineNode("WholeA", { schema: z.object({}) }); +const WholeB = defineNode("WholeB", { schema: z.object({}) }); +const Part = defineNode("Part", { schema: z.object({}) }); +const inWholeA = defineEdge("inWholeA", { schema: z.object({}) }); +const inWholeB = defineEdge("inWholeB", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition_portable_probe", + nodes: { + WholeA: { type: WholeA }, + WholeB: { type: WholeB }, + Part: { type: Part }, + }, + edges: { + inWholeA: { + type: inWholeA, + from: [Part], + to: [WholeA], + cardinality: "one", + }, + inWholeB: { + type: inWholeB, + from: [Part], + to: [WholeB], + cardinality: "one", + }, + }, + ontology: [ + partOf(Part, WholeA, { via: inWholeA }), + partOf(Part, WholeB, { via: inWholeB }), + ], +}); + +const CLAIM_MEMBERS = [ + "claimEdgeCardinality", + "claimEdgeCardinalityGuarded", + "claimEdgeCardinalityBatch", + "purgeEdgeClaims", + "hardDeleteUniquesByConcreteKind", +] as const; + +type ClaimMembers = Readonly< + Partial> +>; + +function stripClaimMembers(target: T): T { + return projectBackendWithout(target as T & ClaimMembers, [ + ...CLAIM_MEMBERS, + ]) as T; +} + +/** A transactional backend that declares no claim relation at all. */ +function withoutClaimSupport(backend: GraphBackend): GraphBackend { + return deriveBackend(stripClaimMembers(backend), { + capabilities: { ...backend.capabilities, constraintClaims: false }, + transaction: (run, options) => + backend.transaction( + (target) => + run( + deriveBackend(stripClaimMembers(target), { + capabilities: { + ...target.capabilities, + constraintClaims: false, + }, + }), + ), + options, + ), + }); +} + +function unsupportedFor(command: GraphCommand): GraphCommandResult { + switch (command.kind) { + case "node.create": { + return { + outcome: "unsupported", + entity: "node", + dimensions: ["claims"], + }; + } + case "edge.create": { + return { + outcome: "unsupported", + entity: "edge", + dimensions: ["cardinalityClaim", "endpointPredicate"], + }; + } + case "edge.converge-create": { + return { + outcome: "unsupported", + entity: "edge", + dimensions: ["convergence", "endpointPredicate"], + }; + } + } +} + +/** A custom command port that answers `unsupported` to every command. */ +function withUnsupportedCommands(backend: GraphBackend): GraphBackend { + const decorate = (target: TransactionBackend): TransactionBackend => + deriveBackend(target, { + commands: { + session: target.commands.session, + execute: (command) => Promise.resolve(unsupportedFor(command)), + }, + }); + return deriveBackend(backend, { + transaction: (run, options) => + backend.transaction((target) => run(decorate(target)), options), + }); +} + +const BACKEND_SHAPES: Readonly GraphBackend>> = { + "first-party": () => createTestBackend(), + "custom port answering unsupported": () => + withUnsupportedCommands(createTestBackend()), + "no claim relation": () => withoutClaimSupport(createTestBackend()), + "no claim relation, port answering unsupported": () => + withUnsupportedCommands(withoutClaimSupport(createTestBackend())), +}; + +async function attachedPart(createBackend: () => GraphBackend) { + const [store] = await createStoreWithSchema(graph, createBackend()); + const wholeA = await store.nodes.WholeA.create({}); + const wholeB = await store.nodes.WholeB.create({}); + const part = await store.nodes.Part.create({}); + const incumbent = await store.edges.inWholeA.create(part, wholeA, {}); + return { store, wholeA, wholeB, part, incumbent }; +} + +const ACCEPTED = "accepted"; + +function refusalOf(operation: () => Promise): Promise { + return operation().then( + () => ACCEPTED, + (error: unknown) => error, + ); +} + +describe.each(Object.entries(BACKEND_SHAPES))( + "one whole per part on a %s backend", + (_shape, createBackend) => { + it("refuses a second whole through a different realizing edge kind, naming the incumbent edge", async () => { + const { store, wholeB, part, incumbent } = + await attachedPart(createBackend); + + const refusal = await refusalOf(() => + store.edges.inWholeB.create(part, wholeB, {}), + ); + + expect(refusal).toBeInstanceOf(CompositionError); + expect((refusal as CompositionError).details).toMatchObject({ + partKind: "Part", + partId: part.id, + wholeKind: "WholeB", + wholeId: wholeB.id, + edgeKind: "inWholeB", + incumbentEdgeId: incumbent.id, + }); + }); + + it("writes nothing for the refused attach, including when the refusal is caught inside a transaction", async () => { + const { store, wholeB, part } = await attachedPart(createBackend); + const second = { from: part, to: wholeB, props: {} }; + + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + for (const attempt of [ + () => tx.edges.inWholeB.create(part, wholeB, {}), + () => tx.edges.inWholeB.bulkCreate([second]), + () => tx.edges.inWholeB.bulkInsert([second]), + () => tx.edges.inWholeB.getOrCreateByEndpoints(part, wholeB, {}), + ]) { + refusals.push(await refusalOf(attempt)); + } + }); + + expect(refusals).toHaveLength(4); + for (const refusal of refusals) { + expect(refusal).toBeInstanceOf(CompositionError); + } + expect(await store.edges.inWholeB.find({})).toEqual([]); + expect(await store.edges.inWholeA.find({})).toHaveLength(1); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("refuses resurrecting an attachment after the part took another whole", async () => { + const { store, wholeB, part, incumbent } = + await attachedPart(createBackend); + await store.edges.inWholeA.delete(incumbent.id); + const replacement = await store.edges.inWholeB.create(part, wholeB, {}); + + const refusal = await refusalOf(() => + store.edges.inWholeA.bulkUpsertById([ + { + id: incumbent.id, + from: part, + to: { kind: "WholeA", id: incumbent.toId }, + props: {}, + }, + ]), + ); + + expect(refusal).toBeInstanceOf(CompositionError); + expect((refusal as CompositionError).details).toMatchObject({ + situation: "occupied", + incumbentEdgeId: replacement.id, + }); + expect(await store.edges.inWholeA.find({})).toEqual([]); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + + it("still attaches a part that has no whole", async () => { + const [store] = await createStoreWithSchema(graph, createBackend()); + const wholeB = await store.nodes.WholeB.create({}); + const part = await store.nodes.Part.create({}); + + await store.edges.inWholeB.create(part, wholeB, {}); + + expect(await store.edges.inWholeB.find({})).toHaveLength(1); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + }, +); diff --git a/packages/typegraph/tests/composition-reparent-hooks.test.ts b/packages/typegraph/tests/composition-reparent-hooks.test.ts new file mode 100644 index 000000000..6747e7c46 --- /dev/null +++ b/packages/typegraph/tests/composition-reparent-hooks.test.ts @@ -0,0 +1,93 @@ +/** + * Operation hooks on composition moves. `reparent` is a single-entity write + * and fires one `update` operation naming the part it moved; `bulkReparent` + * is a batch method and, like every other batch method, fires none — a hook + * names one entity, and a batch is not one. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type OperationHookContext, + partOf, +} from "../src"; +import { createTestBackend } from "./test-utils"; + +const RhShow = defineNode("RhShow", { schema: z.object({}) }); +const RhClip = defineNode("RhClip", { schema: z.object({}) }); +const rhClipOf = defineEdge("rhClipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition_reparent_hooks", + nodes: { RhShow: { type: RhShow }, RhClip: { type: RhClip } }, + edges: { + rhClipOf: { + type: rhClipOf, + from: [RhClip], + to: [RhShow], + cardinality: "oneActive", + }, + }, + ontology: [partOf(RhClip, RhShow, { via: rhClipOf })], +}); + +async function hookedStore() { + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + const operations: OperationHookContext[] = []; + const store = createStore(graph, backend, { + hooks: { onOperationStart: (ctx) => operations.push(ctx) }, + }); + const from = await store.nodes.RhShow.create({}); + const to = await store.nodes.RhShow.create({}); + const first = await store.nodes.RhClip.create( + {}, + { partOf: { whole: from } }, + ); + const second = await store.nodes.RhClip.create( + {}, + { partOf: { whole: from } }, + ); + operations.length = 0; + return { store, operations, to, first, second }; +} + +describe("operation hooks on composition moves", () => { + it("reparent fires one update operation naming the part it moves", async () => { + const { store, operations, to, first } = await hookedStore(); + + await store.nodes.RhClip.reparent(first.id, { whole: to }); + + expect( + operations.map(({ operation, entity, kind, id }) => ({ + operation, + entity, + kind, + id, + })), + ).toEqual([ + { operation: "update", entity: "node", kind: "RhClip", id: first.id }, + ]); + }); + + it("bulkReparent fires no operation hook, like every other batch method", async () => { + const { store, operations, to, first, second } = await hookedStore(); + + // MUTATION CHECK: run the batch frame through `runHookedWritePlan` with + // the first item's id (`reparentParts`, + // src/store/operations/node-operations.ts) — the batch then reports + // one update of `first` and none of `second`. + const results = await store.nodes.RhClip.bulkReparent([ + { id: first.id, options: { whole: to } }, + { id: second.id, options: { whole: to } }, + ]); + + expect(results.map((result) => result.moved)).toEqual([true, true]); + expect(operations).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/composition-reparent-instant.test.ts b/packages/typegraph/tests/composition-reparent-instant.test.ts new file mode 100644 index 000000000..dc0f0e741 --- /dev/null +++ b/packages/typegraph/tests/composition-reparent-instant.test.ts @@ -0,0 +1,662 @@ +/** + * `reparent` moves a part with ONE clock read, and the two halves of the move + * abut in valid time. + * + * The cross-backend suite (`tests/backends/integration/composition-attachment.ts`) + * asserts the abutment on a real engine, but it cannot PROVE the single read: + * two `nowIso()` calls a few statements apart usually land in the same + * millisecond, so a two-read implementation passes it by luck. This file + * removes the luck — the fake clock advances between the retire and the attach + * (a wrapper on the transaction target's edge insert), so a second read is + * guaranteed to sample a later instant and the hole becomes observable. + */ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + CompositionError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, + ValidationError, +} from "../src"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { + type GraphBackend, + type InsertEdgeParams, + type TransactionBackend, +} from "../src/backend/types"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Show = defineNode("RiShow", { schema: z.object({}) }); +const Clip = defineNode("RiClip", { schema: z.object({}) }); +const clipOf = defineEdge("riClipOf", { schema: z.object({}) }); +/** A `population: "one"` part: its incumbent is deleted on a move, not ended. */ +const Still = defineNode("RiStill", { schema: z.object({}) }); +const stillOf = defineEdge("riStillOf", { schema: z.object({}) }); + +const CLOCK_START = "2026-03-01T00:00:00.000Z"; +/** Far larger than any plausible same-millisecond collision. */ +const ADVANCE_MS = 60_000; + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + RiShow: { type: Show }, + RiClip: { type: Clip }, + RiStill: { type: Still }, + }, + edges: { + riClipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + riStillOf: { + type: stillOf, + from: [Still], + to: [Show], + cardinality: "one", + }, + }, + ontology: [ + partOf(Clip, Show, { via: clipOf, existence: "required" }), + partOf(Still, Show, { via: stillOf }), + ], + }); +} + +function advanceClock(): void { + vi.setSystemTime(new Date(Date.now() + ADVANCE_MS)); +} + +/** + * Advances the fake clock immediately BEFORE every edge insert the write + * transaction issues. A reparent that samples its own instant for the attach + * therefore stamps a `valid_from` strictly later than the `valid_to` it just + * wrote on the incumbent; one that carries the move instant through cannot. + * + * The wrapper decorates the TRANSACTION TARGET (through `deriveBackend`, never + * a spread copy), because that is the object the attach's insert reaches. + */ +function clockAdvancingBackend(base: GraphBackend): GraphBackend { + function advancing(target: TransactionBackend): TransactionBackend { + return deriveBackend(target, { + insertEdge: async (params: InsertEdgeParams) => { + advanceClock(); + return target.insertEdge(params); + }, + ...(target.insertEdgeNoReturn === undefined ? + {} + : { + insertEdgeNoReturn: async (params: InsertEdgeParams) => { + advanceClock(); + await requireDefined(target.insertEdgeNoReturn)(params); + }, + }), + }); + } + return deriveBackend(base, { + transaction: (run, options) => + base.transaction((target) => run(advancing(target)), options), + }); +} + +describe("reparent's move instant", () => { + it("ends the incumbent window at the SAME instant the new window opens, with the clock advancing in between", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(CLOCK_START)); + const backend = clockAdvancingBackend(createTestBackend()); + const [store] = await createStoreWithSchema( + buildGraph("reparent_move_instant"), + backend, + ); + + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ); + + // MUTATION: give the two halves of the move their own clock reads — + // in `prepareCompositionAttachmentMoves` + // (src/store/operations/node-operations.ts), record `nowIso()` as the + // retire's `moveInstant` instead of the instant the replacement was + // prepared with. The two rows then carry instants a clock advance + // apart, and both assertions below fail. + await store.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + }); + + const rows = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(rows).toHaveLength(2); + const ended = requireDefined( + rows.find((edge) => edge.toId === showA.id), + "the retired attachment", + ); + const open = requireDefined( + rows.find((edge) => edge.toId === showB.id), + "the new attachment", + ); + const moveInstant = requireDefined( + ended.meta.validTo, + "the retired window's validTo", + ); + expect(open.meta.validFrom).toBe(moveInstant); + + // The windows are half-open `[from, to)`, so the move instant belongs to + // exactly one of them: this `existence: "required"` part holds one whole + // at every valid-time coordinate, never zero and never two. + const atMove = await store.asOf(moveInstant).edges.riClipOf.find({}); + expect(atMove).toHaveLength(1); + expect( + requireDefined(atMove[0], "the attachment at the move instant").toId, + ).toBe(showB.id); + } finally { + vi.useRealTimers(); + } + }); + + it("takes at as its only move instant and refuses a stated validFrom", async () => { + const [store] = await createStoreWithSchema( + buildGraph("reparent_stated_at"), + createTestBackend(), + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { + partOf: { + whole: { kind: "RiShow", id: showA.id }, + validFrom: "2024-01-01T00:00:00.000Z", + }, + }, + ); + const at = "2024-06-01T00:00:00.000Z"; + + // MUTATION: add "validFrom" to `REPARENT_OPTION_KEYS` + // (src/store/operations/composition-create.ts). The option is then + // accepted and dropped, the move goes through, and this rejects nothing. + const refusal = await store.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + // @ts-expect-error reparent has one instant, `at`; it takes no validFrom + validFrom: at, + }).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(ValidationError); + expect((refusal as ValidationError).details.issues).toEqual([ + expect.objectContaining({ path: "options.validFrom" }), + ]); + const untouched = await store.edges.riClipOf.find({}); + expect(untouched.map((edge) => edge.toId)).toEqual([showA.id]); + + const moved = await store.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + at, + }); + expect(moved.moved).toBe(true); + expect(moved.edge.meta.validFrom).toBe(at); + expect(moved.edge.kind).toBe("riClipOf"); + + const history = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = requireDefined( + history.find((edge) => edge.toId === showA.id), + "the retired attachment", + ); + expect(retired.meta.validTo).toBe(at); + }); + + it("refuses a stated validTo or a malformed at, each under its own option name", async () => { + const [store] = await createStoreWithSchema( + buildGraph("reparent_refused_options"), + createTestBackend(), + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ); + const whole = { kind: "RiShow", id: showB.id } as const; + + const issuePaths = async ( + attempt: Promise, + ): Promise => { + const refusal = await attempt.catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(ValidationError); + return (refusal as ValidationError).details.issues.map( + (issue) => issue.path, + ); + }; + + // MUTATION: add "validTo" to `REPARENT_OPTION_KEYS` + // (src/store/operations/composition-create.ts). The bound is then + // accepted and applied, so the move goes through and nothing is refused. + expect( + await issuePaths( + store.nodes.RiClip.reparent(clip.id, { + whole, + // @ts-expect-error a move states no window; `at` is its only instant + validTo: "2999-01-01T00:00:00.000Z", + }), + ), + ).toEqual(["options.validTo"]); + + // MUTATION: return `at` unchecked from `readMoveInstant`. The malformed + // value then reaches the edge insert and is refused under "validFrom". + expect( + await issuePaths( + // @ts-expect-error `at` is a string + store.nodes.RiClip.reparent(clip.id, { whole, at: 5 }), + ), + ).toEqual(["options.at"]); + + const untouched = await store.edges.riClipOf.find({}); + expect(untouched.map((edge) => edge.toId)).toEqual([showA.id]); + }); + + it("does not copy the part node's validity window onto the realizing edge", async () => { + const [store] = await createStoreWithSchema( + buildGraph("composition_edge_window"), + createTestBackend(), + ); + const show = await store.nodes.RiShow.create({}); + const nodeFrom = "2020-01-01T00:00:00.000Z"; + const edgeFrom = "2021-01-01T00:00:00.000Z"; + await store.nodes.RiClip.create( + {}, + { + validFrom: nodeFrom, + partOf: { whole: { kind: "RiShow", id: show.id }, validFrom: edgeFrom }, + }, + ); + const edges = await store.edges.riClipOf.find({}); + expect(edges).toHaveLength(1); + expect(requireDefined(edges[0]).meta.validFrom).toBe(edgeFrom); + const clips = await store.nodes.RiClip.find({}); + const clip = requireDefined(clips[0]); + expect(clip.meta.validFrom).toBe(nodeFrom); + }); +}); + +/** Where the attachments are written, before the clock steps back to {@link STEPPED_BACK}. */ +const ATTACHED_AT = "2026-03-01T00:00:10.000Z"; +const STEPPED_BACK = "2026-03-01T00:00:05.000Z"; + +/** + * A store with two shows and `clipCount` clips attached to the first, written + * at {@link ATTACHED_AT}, with the fake clock then stepped back to + * {@link STEPPED_BACK}: every incumbent attachment now starts later than the + * clock reads. The caller restores real timers. + */ +async function storeAfterBackwardStep(graphId: string, clipCount: number) { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(ATTACHED_AT)); + const [store] = await createStoreWithSchema( + buildGraph(graphId), + createTestBackend(), + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clips = []; + for (let index = 0; index < clipCount; index += 1) { + clips.push( + await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ), + ); + } + vi.setSystemTime(new Date(STEPPED_BACK)); + return { store, showA, showB, clips }; +} + +describe("reparent across a backward clock step", () => { + it("moves a oneActive part with no `at` at the incumbent's start, leaving a zero-width retired window", async () => { + try { + const { store, showA, showB, clips } = await storeAfterBackwardStep( + "reparent_backward_step", + 1, + ); + const clip = requireDefined(clips[0]); + + // MUTATION: make `resolveCompositionMoveInstant` + // (src/store/operations/composition-create.ts) return the clock read for + // a defaulted instant. The incumbent's window then ends before it + // starts, and the move is refused with an inverted-window + // ValidationError naming an edge the caller never mentioned. + const moved = await store.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + }); + expect(moved.moved).toBe(true); + expect(moved.edge.meta.validFrom).toBe(ATTACHED_AT); + + const rows = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = requireDefined( + rows.find((edge) => edge.toId === showA.id), + "the retired attachment", + ); + expect(retired.meta.validFrom).toBe(ATTACHED_AT); + expect(retired.meta.validTo).toBe(ATTACHED_AT); + + // Once wall time reaches the move, the part holds exactly the new whole. + vi.setSystemTime(new Date(ATTACHED_AT)); + const current = await store.edges.riClipOf.find({}); + expect(current.map((edge) => edge.toId)).toEqual([showB.id]); + } finally { + vi.useRealTimers(); + } + }); + + it("moves every part of a bulkReparent with no `at` at its own incumbent's start", async () => { + try { + const { store, showA, showB, clips } = await storeAfterBackwardStep( + "bulk_reparent_backward_step", + 2, + ); + + // MUTATION: as above — a clock-read move instant refuses the batch. + const moved = await store.nodes.RiClip.bulkReparent( + clips.map((clip) => ({ + id: clip.id, + options: { whole: { kind: "RiShow", id: showB.id } }, + })), + ); + expect(moved.map((result) => result.moved)).toEqual([true, true]); + expect(moved.map((result) => result.edge.meta.validFrom)).toEqual([ + ATTACHED_AT, + ATTACHED_AT, + ]); + + const history = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = history.filter((edge) => edge.toId === showA.id); + expect(retired.map((edge) => edge.meta.validTo)).toEqual([ + ATTACHED_AT, + ATTACHED_AT, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("refuses a stated `at` earlier than the incumbent's start with COMPOSITION_MOVE_BEFORE_INCUMBENT, writing nothing", async () => { + try { + const { store, showA, showB, clips } = await storeAfterBackwardStep( + "reparent_before_incumbent", + 2, + ); + const [first, second] = clips.map((clip) => requireDefined(clip)); + const attachments = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const incumbent = requireDefined( + attachments.find((edge) => edge.fromId === requireDefined(first).id), + "the first clip's attachment", + ); + const whole = { kind: "RiShow", id: showB.id } as const; + + // MUTATION: make `resolveCompositionMoveInstant` return a stated `at` + // unjudged. The window end then reaches the edge update, which refuses + // it with an inverted-window ValidationError instead. + const refusal = await store.nodes.RiClip.reparent( + requireDefined(first).id, + { whole, at: STEPPED_BACK }, + ).catch((error: unknown) => error); + expect(refusal).toBeInstanceOf(CompositionError); + expect(refusal).toMatchObject({ + code: "COMPOSITION_MOVE_BEFORE_INCUMBENT", + details: { + situation: "moveBeforeIncumbent", + partKind: "RiClip", + partId: requireDefined(first).id, + wholeKind: "RiShow", + wholeId: showB.id, + incumbentEdgeKind: "riClipOf", + incumbentEdgeId: incumbent.id, + incumbentValidFrom: ATTACHED_AT, + at: STEPPED_BACK, + }, + }); + + // A batch whose LAST item refuses moves none of them — not even the + // first, a legal defaulted move — also when the refusal is caught + // inside the caller's transaction and it commits. + await store.transaction(async (tx) => { + await expect( + tx.nodes.RiClip.bulkReparent([ + { id: requireDefined(second).id, options: { whole } }, + { + id: requireDefined(first).id, + options: { whole, at: STEPPED_BACK }, + }, + ]), + ).rejects.toMatchObject({ code: "COMPOSITION_MOVE_BEFORE_INCUMBENT" }); + }); + + const rows = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(rows).toHaveLength(2); + expect(rows.map((edge) => edge.toId)).toEqual([showA.id, showA.id]); + expect(rows.map((edge) => edge.meta.validTo)).toEqual([ + undefined, + undefined, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("moves at a stated `at` equal to the incumbent's start, retiring a zero-width window", async () => { + try { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(ATTACHED_AT)); + const [store] = await createStoreWithSchema( + buildGraph("reparent_at_incumbent_start"), + createTestBackend(), + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ); + + // MUTATION: refuse when the stated `at` is NOT LATER than the + // incumbent's start (`<=` instead of `<`) — this zero-width retire is + // then refused. + const moved = await store.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + at: ATTACHED_AT, + }); + expect(moved.edge.meta.validFrom).toBe(ATTACHED_AT); + const history = await store.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = requireDefined( + history.find((edge) => edge.toId === showA.id), + "the retired attachment", + ); + expect(retired.meta.validTo).toBe(ATTACHED_AT); + } finally { + vi.useRealTimers(); + } + }); + + it("leaves a population-one move at its own instant: the deleted incumbent has no window to end", async () => { + try { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(ATTACHED_AT)); + const [store] = await createStoreWithSchema( + buildGraph("reparent_one_backward_step"), + createTestBackend(), + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const showC = await store.nodes.RiShow.create({}); + const still = await store.nodes.RiStill.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ); + vi.setSystemTime(new Date(STEPPED_BACK)); + + // MUTATION: judge every incumbent, not only one whose window the move + // ends — the defaulted move then starts at the deleted incumbent's + // start, and the stated earlier `at` is refused. + const defaulted = await store.nodes.RiStill.reparent(still.id, { + whole: { kind: "RiShow", id: showB.id }, + }); + expect(defaulted.edge.meta.validFrom).toBe(STEPPED_BACK); + + const stated = "2026-03-01T00:00:01.000Z"; + const restated = await store.nodes.RiStill.reparent(still.id, { + whole: { kind: "RiShow", id: showC.id }, + at: stated, + }); + expect(restated.edge.meta.validFrom).toBe(stated); + const rows = await store.edges.riStillOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect(rows.map((edge) => edge.toId)).toEqual([showC.id]); + } finally { + vi.useRealTimers(); + } + }); +}); + +/** + * Steps the fake clock back by `stepMs` as the write transaction opens: after + * `reparent` has read its default instant, before any edge write is judged. + */ +function clockSteppingBackOnTransactionBackend( + base: GraphBackend, + stepMs: number, +): GraphBackend { + return deriveBackend(base, { + transaction: (run, options) => { + vi.setSystemTime(new Date(Date.now() - stepMs)); + return base.transaction(run, options); + }, + }); +} + +describe("the instant a move's edge writes are judged at", () => { + it("is the move instant, so a clock step between the move's clock read and its writes refuses nothing", async () => { + try { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(CLOCK_START)); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("reparent_step_mid_move"), + backend, + ); + const showA = await store.nodes.RiShow.create({}); + const showB = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: showA.id } } }, + ); + const readAt = "2026-03-01T00:01:00.000Z"; + vi.setSystemTime(new Date(readAt)); + const [steppingStore] = await createStoreWithSchema( + buildGraph("reparent_step_mid_move"), + clockSteppingBackOnTransactionBackend(backend, 30_000), + ); + + // MUTATION: judge the composition window rule at a fresh clock read + // only (`moveInstant ?? nowIso()` → `nowIso()` in + // `assertCompositionEdgeWindowCurrent`, src/store/operations/edge-operations.ts). + // The new edge's validFrom — the read taken before the step — is then + // refused as COMPOSITION_VALIDITY_FUTURE_START. + const moved = await steppingStore.nodes.RiClip.reparent(clip.id, { + whole: { kind: "RiShow", id: showB.id }, + }); + expect(moved.edge.meta.validFrom).toBe(readAt); + const history = await steppingStore.edges.riClipOf.find( + {}, + { temporalMode: "includeEnded" }, + ); + const retired = requireDefined( + history.find((edge) => edge.toId === showA.id), + "the retired attachment", + ); + expect(retired.meta.validTo).toBe(readAt); + } finally { + vi.useRealTimers(); + } + }); + + it("is not a caller option: an edge write stating one is still judged at the clock", async () => { + const [store] = await createStoreWithSchema( + buildGraph("move_instant_not_public"), + createTestBackend(), + ); + const show = await store.nodes.RiShow.create({}); + const otherShow = await store.nodes.RiShow.create({}); + const clip = await store.nodes.RiClip.create( + {}, + { partOf: { whole: { kind: "RiShow", id: show.id } } }, + ); + const [held] = await store.edges.riClipOf.find({}); + const later = "2999-01-01T00:00:00.000Z"; + + // MUTATION: forward the caller's update options into the internal ones — + // spread `options` into `buildUpdateEdgeInput`'s result + // (src/store/collections/edge-collection.ts) and pass that input as + // `executeEdgeUpdate`'s `EdgeUpdateOptions`. The stated instant then + // admits the future `validTo`, which reaches the existence rule instead. + const updateRefusal = await store.edges.riClipOf + .update( + requireDefined(held, "the clip's attachment").id, + {}, + { + validTo: later, + // @ts-expect-error a move's instant is internal; no edge write takes one + moveInstant: later, + }, + ) + .catch((error: unknown) => error); + expect(updateRefusal).toMatchObject({ + code: "COMPOSITION_VALIDITY_FUTURE_END", + }); + + const createRefusal = await store.edges.riClipOf + .create( + clip, + otherShow, + {}, + { + validFrom: later, + // @ts-expect-error a move's instant is internal; no edge write takes one + moveInstant: later, + }, + ) + .catch((error: unknown) => error); + expect(createRefusal).toMatchObject({ + code: "COMPOSITION_VALIDITY_FUTURE_START", + }); + }); +}); diff --git a/packages/typegraph/tests/composition-required-part-fence.test.ts b/packages/typegraph/tests/composition-required-part-fence.test.ts new file mode 100644 index 000000000..6c366e31f --- /dev/null +++ b/packages/typegraph/tests/composition-required-part-fence.test.ts @@ -0,0 +1,257 @@ +/** + * Every write that decides whether a required composition part keeps a live + * whole — restoring the part, or separating it from its whole through its + * composition edge — is a constrained write: fenced where the backend can hold + * the per-graph write fence, refused where it cannot. The fenced half is + * proven against a concurrent PostgreSQL connection + * (`tests/backends/postgres/concurrent-composition-required-part.test.ts`); + * this file pins the refused half on a backend with no transactions, and that + * optional parts and unconstrained edges pay nothing for it. + */ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + CompositionExistenceError, + createStore, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { generateSqliteDDL } from "../src/backend/drizzle/ddl"; +import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; +import { type GraphBackend } from "../src/backend/types"; +import { createTestBackend, matchingObject } from "./test-utils"; + +const Show = defineNode("Show", { schema: z.object({}) }); +const Clip = defineNode("Clip", { schema: z.object({ name: z.string() }) }); +const Note = defineNode("Note", { schema: z.object({}) }); +const clipOf = defineEdge("clipOf", { schema: z.object({}) }); +const noteOf = defineEdge("noteOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-required-part-fence", + nodes: { Show: { type: Show }, Clip: { type: Clip }, Note: { type: Note } }, + edges: { + clipOf: { + type: clipOf, + from: [Clip], + to: [Show], + cardinality: "oneActive", + }, + noteOf: { type: noteOf, from: [Note], to: [Show], cardinality: "one" }, + }, + ontology: [ + partOf(Clip, Show, { via: clipOf, existence: "required" }), + partOf(Note, Show, { via: noteOf }), + ], +}); + +const FENCE_REFUSAL = matchingObject({ + name: "ConfigurationError", + details: matchingObject({ + code: "CONSTRAINT_WRITE_FENCE_UNSUPPORTED", + constraint: "edgeComposition", + }), +}); + +/** + * A backend with no transactions, seeded through its raw members: every + * composition attach is itself a constrained write it refuses. + */ +async function withTransactionlessStore( + run: ( + store: ReturnType>, + backend: GraphBackend, + ) => Promise, +): Promise { + const sqlite = new Database(":memory:"); + for (const statement of generateSqliteDDL()) sqlite.exec(statement); + const backend = createSqliteBackend(drizzle(sqlite), { + executionProfile: { transactionMode: "none", isSync: true }, + }); + try { + const seed = (kind: string, id: string, props: Record) => + backend.insertNode({ graphId: graph.id, kind, id, props }); + await seed("Show", "show", {}); + await seed("Clip", "clip", { name: "Clip" }); + await seed("Note", "note", {}); + for (const [kind, id, fromKind, fromId] of [ + ["clipOf", "clip-of", "Clip", "clip"], + ["noteOf", "note-of", "Note", "note"], + ] as const) { + await backend.insertEdge({ + graphId: graph.id, + kind, + id, + fromKind, + fromId, + toKind: "Show", + toId: "show", + props: {}, + }); + } + await run(createStore(graph, backend), backend); + } finally { + sqlite.close(); + } +} + +describe("the required-part composition fence on a backend without transactions", () => { + it("refuses every detach of a required part's composition edge, and keeps the edge", async () => { + await withTransactionlessStore(async (store) => { + const edgeId = asEdgeId("clip-of"); + // MUTATION CHECK: answer `undefined` from + // `edgeDetachNeedsConstraintFence` (src/store/constraints.ts) — each + // detach then runs unfenced and is refused only by the part-liveness + // read, as `CompositionExistenceError`. + const detaches: readonly (() => Promise)[] = [ + () => store.edges.clipOf.delete(edgeId), + () => store.edges.clipOf.hardDelete(edgeId), + () => store.edges.clipOf.bulkDelete([edgeId]), + () => + store.edges.clipOf.update( + edgeId, + {}, + { validTo: new Date().toISOString() }, + ), + ]; + for (const detach of detaches) { + await expect(detach()).rejects.toThrow(FENCE_REFUSAL); + } + const edge = await store.edges.clipOf.getById(edgeId); + expect(edge?.meta.validTo).toBeUndefined(); + }); + }); + + it("refuses restoring a tombstoned required part, and leaves it tombstoned", async () => { + await withTransactionlessStore(async (store, backend) => { + await backend.deleteNode({ graphId: graph.id, kind: "Clip", id: "clip" }); + // MUTATION CHECK: answer `undefined` from + // `nodeRestoreNeedsConstraintFence` (src/store/constraints.ts) — every + // restore then succeeds unfenced, since the edge to the live show + // survived the tombstone. + const restores: readonly (() => Promise)[] = [ + () => store.nodes.Clip.upsertById("clip", { name: "Back" }), + () => + store.nodes.Clip.bulkUpsertById([ + { id: "clip", props: { name: "Back" } }, + ]), + () => + store.nodes.Clip.bulkReplaceById([ + { id: "clip", props: { name: "Back" } }, + ]), + ]; + for (const restore of restores) { + await expect(restore()).rejects.toThrow(FENCE_REFUSAL); + } + await expect( + store.nodes.Clip.getById("clip" as never), + ).resolves.toBeUndefined(); + }); + }); + + it("charges an optional part's detach and restore nothing", async () => { + await withTransactionlessStore(async (store, backend) => { + await store.edges.noteOf.delete(asEdgeId("note-of")); + await backend.deleteNode({ graphId: graph.id, kind: "Note", id: "note" }); + await store.nodes.Note.upsertById("note", {}); + await expect( + store.nodes.Note.getById("note" as never), + ).resolves.toBeDefined(); + }); + }); +}); + +type EdgeDetach = Readonly<{ + name: string; + run: ( + store: ReturnType>, + edgeId: ReturnType>, + ) => Promise; +}>; + +const SINGLE_EDGE_DETACHES: readonly EdgeDetach[] = [ + { name: "delete", run: (store, edgeId) => store.edges.clipOf.delete(edgeId) }, + { + name: "hardDelete", + run: (store, edgeId) => store.edges.clipOf.hardDelete(edgeId), + }, +]; + +describe("a single edge detach judged against the row the fence holds", () => { + for (const detach of SINGLE_EDGE_DETACHES) { + it(`${detach.name} refuses when the part was restored onto a reopened window after the delete read its gate row`, async () => { + const backend = createTestBackend(); + const edgeId = asEdgeId("clip-of"); + await backend.insertNode({ + graphId: graph.id, + kind: "Show", + id: "show", + props: {}, + }); + await backend.insertNode({ + graphId: graph.id, + kind: "Clip", + id: "clip", + props: { name: "Clip" }, + }); + // An ended attachment of a tombstoned part: detaching it orphans + // nothing, which is what the gate row read below shows. + await backend.insertEdge({ + graphId: graph.id, + kind: "clipOf", + id: edgeId, + fromKind: "Clip", + fromId: "clip", + toKind: "Show", + toId: "show", + props: {}, + validTo: "2020-01-01T00:00:00.000Z", + }); + await backend.deleteNode({ graphId: graph.id, kind: "Clip", id: "clip" }); + + const peer = createStore(graph, backend); + const interleaved = { done: false }; + // The peer runs to commit inside the detach's lock-free gate read, after + // that read has answered: SQLite admits one writer at a time, so this + // is the only point at which a write can land between the gate and + // the detach's fenced frame. + const detacher = createStore( + graph, + deriveBackend(backend, { + getEdge: async (graphId, id) => { + const row = await backend.getEdge(graphId, id); + if (!interleaved.done && id === edgeId) { + interleaved.done = true; + await peer.edges.clipOf.update( + edgeId, + {}, + { clearValidTo: true }, + ); + await peer.nodes.Clip.upsertById("clip", { name: "Back" }); + } + return row; + }, + }), + ); + + // MUTATION CHECK: return `gate` unconditionally from + // `readEdgeDetachVerdictRow` (src/store/operations/edge-operations.ts) + // — the detach then judges the ended gate row, succeeds, and leaves the + // restored clip with no live whole. + await expect(detach.run(detacher, edgeId)).rejects.toBeInstanceOf( + CompositionExistenceError, + ); + expect(interleaved.done).toBe(true); + const edge = await peer.edges.clipOf.getById(edgeId); + expect(edge?.meta.validTo).toBeUndefined(); + expect(await peer.verifyConstraintFences()).toEqual([]); + }); + } +}); diff --git a/packages/typegraph/tests/composition-restore-recipe.test.ts b/packages/typegraph/tests/composition-restore-recipe.test.ts new file mode 100644 index 000000000..110e23ec5 --- /dev/null +++ b/packages/typegraph/tests/composition-restore-recipe.test.ts @@ -0,0 +1,158 @@ +/** + * The "Undoing a cascading delete" recipe in the composition docs + * (apps/docs/src/content/docs/ontology.md), run step for step against a real + * store so the documented workaround cannot drift from the API. Keep the two + * `snapshot`/`restore` blocks below identical to the documented ones. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + type CompositionCascadedPart, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type Node, + partOf, +} from "../src"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string() }), +}); +const Episode = defineNode("Episode", { + schema: z.object({ title: z.string() }), +}); +const Segment = defineNode("Segment", { + schema: z.object({ title: z.string() }), +}); +const Topic = defineNode("Topic", { schema: z.object({ name: z.string() }) }); + +const episodeOf = defineEdge("episodeOf"); +const segmentOf = defineEdge("segmentOf"); +const covers = defineEdge("covers"); + +const graph = defineGraph({ + id: "composition_restore_recipe", + nodes: { + Podcast: { type: Podcast }, + Episode: { type: Episode, onDelete: "disconnect" }, + Segment: { type: Segment }, + Topic: { type: Topic }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "one", + }, + covers: { type: covers, from: [Episode], to: [Topic] }, + }, + ontology: [ + partOf(Episode, Podcast, { via: episodeOf }), + partOf(Segment, Episode, { via: segmentOf, existence: "required" }), + ], +}); + +/** The node's own properties, without its `kind`, `id` and `meta`. */ +function propsOf(node: N): Omit { + const { kind: _kind, id: _id, meta: _meta, ...props } = node; + return props; +} + +describe("composition restore recipe", () => { + it("brings back the whole and every cascaded part with its props and attachment, and nothing else", async () => { + const [store] = await createStoreWithSchema(graph, createTestBackend()); + const topic = await store.nodes.Topic.create({ name: "chess" }); + const podcast = await store.nodes.Podcast.create({ title: "Show" }); + const episodes = await Promise.all( + ["Openings", "Endgames"].map((title) => + store.nodes.Episode.create({ title }, { partOf: { whole: podcast } }), + ), + ); + for (const episode of episodes) { + await store.nodes.Segment.create( + { title: `${episode.title} intro` }, + { partOf: { whole: episode } }, + ); + } + await store.edges.covers.create(requireDefined(episodes[0]), topic, {}); + const originalPlan = requireDefined( + await store.nodes.Podcast.planDelete(podcast.id), + ); + await new Promise((resolve) => setTimeout(resolve, 5)); + + // --- documented: capture, then delete ------------------------------- + const snapshot = await store.transaction(async (tx) => { + const plan = await tx.nodes.Podcast.planDelete(podcast.id); + const whole = await tx.nodes.Podcast.getById(podcast.id); + if (plan === undefined || whole === undefined) return; + const parts: { + part: CompositionCascadedPart; + props: Record; + }[] = []; + for (const part of plan.cascadedParts) { + const node = await tx.getNodeByRef(part); + if (node !== undefined) parts.push({ part, props: propsOf(node) }); + } + await tx.nodes.Podcast.delete(podcast.id, { expect: plan }); + return { whole: propsOf(whole), parts }; + }); + + // --- documented: undo ------------------------------------------------- + if (snapshot !== undefined) { + await store.transaction(async (tx) => { + await tx.nodes.Podcast.create(snapshot.whole, { id: podcast.id }); + for (const { part, props } of snapshot.parts.toReversed()) { + const collection = tx.getNodeCollection(part.kind); + if (collection === undefined) throw new Error(part.kind); + await collection.create(props, { + id: part.id, + partOf: { whole: part.whole, via: part.viaEdgeKind }, + }); + } + }); + } + // --------------------------------------------------------------------- + + const captured = requireDefined(snapshot); + expect(captured.parts).toHaveLength(4); + const restoredWhole = requireDefined( + await store.nodes.Podcast.getById(podcast.id), + ); + expect(propsOf(restoredWhole)).toEqual({ title: "Show" }); + for (const { part, props } of captured.parts) { + const restored = requireDefined(await store.getNodeByRef(part)); + expect(propsOf(restored), `${part.kind}/${part.id}`).toEqual(props); + } + // Every part is attached where it was: the restored whole's delete would + // cascade through exactly the parts the original delete removed. + const replanned = requireDefined( + await store.nodes.Podcast.planDelete(podcast.id), + ); + expect(replanned.cascadedParts).toEqual( + captured.parts.map(({ part }) => part), + ); + + // The composition edges come back as new edges, not the deleted ones. + expect( + replanned.removedEdgeIds.filter((edgeId) => + originalPlan.removedEdgeIds.includes(edgeId), + ), + ).toEqual([]); + expect(restoredWhole.meta.validFrom).not.toBe(podcast.meta.validFrom); + + // What the recipe does not bring back: the ordinary edge the episode's + // `onDelete: "disconnect"` removed. + expect(await store.edges.covers.find()).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/composition-row-membership.test.ts b/packages/typegraph/tests/composition-row-membership.test.ts new file mode 100644 index 000000000..6716e0981 --- /dev/null +++ b/packages/typegraph/tests/composition-row-membership.test.ts @@ -0,0 +1,37 @@ +/** + * The composition membership rule has one statement for readers + * (`compositionPopulationMembership`, src/registry/composition-relation.ts) + * and is reached by the composition CLAIM through its cardinality axis + * (`axisRowMembership`, src/store/claims/edge-claims.ts). The claim SQL and + * the cascade, the acyclicity relation and the composition reads must agree + * on which rows bind, so the two are pinned together for every orientation + * and population a composition pair can take. + */ +import { describe, expect, it } from "vitest"; + +import { compositionPopulationMembership } from "../src/registry/composition-relation"; +import { axisRowMembership } from "../src/store/claims/edge-claims"; + +const POPULATIONS = ["one", "oneActive"] as const; +const DIRECTIONS = ["source", "target"] as const; + +describe("composition row membership", () => { + it("states the same rule as the composition claim's cardinality axis", () => { + // MUTATION CHECK: make `compositionPopulationMembership` answer + // "openRows" for "one" and this fails on both directions — verified and + // reverted. + for (const population of POPULATIONS) { + for (const direction of DIRECTIONS) { + expect( + compositionPopulationMembership(population), + `${direction}:${population}`, + ).toBe(axisRowMembership({ direction, cardinality: population })); + } + } + }); + + it("binds a population 'one' row for its whole life and a 'oneActive' row only while open", () => { + expect(compositionPopulationMembership("one")).toBe("allRows"); + expect(compositionPopulationMembership("oneActive")).toBe("openRows"); + }); +}); diff --git a/packages/typegraph/tests/composition-subgraph-depth.test.ts b/packages/typegraph/tests/composition-subgraph-depth.test.ts new file mode 100644 index 000000000..b3808bfda --- /dev/null +++ b/packages/typegraph/tests/composition-subgraph-depth.test.ts @@ -0,0 +1,181 @@ +/** + * `subgraph({ composition: true })` returns the COMPLETE owned unit at any + * depth — including a part chain longer than `MAX_EXPLICIT_RECURSIVE_DEPTH`, + * the hop ceiling every explicit traversal is capped at. + * + * A dedicated file rather than a case in the cross-backend suite: the chain + * has to be longer than 1000 hops to say anything, and paying that on every + * engine's lane buys no parity signal — the closure is ONE shared SQL builder + * (`buildExhaustiveDirectedReachableCte`), and the cross-backend suite already + * exercises it on every backend with shallow trees + * (`tests/backends/integration/composition-navigation.ts`). + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + ConfigurationError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { type GraphBackend } from "../src/backend/types"; +import { MAX_EXPLICIT_RECURSIVE_DEPTH } from "../src/query/compiler/recursive"; +import { type CompiledRowsSql } from "../src/query/sql-intent"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const SdFolder = defineNode("SdFolder", { + schema: z.object({ depth: z.number().int() }), +}); +const sdParentFolder = defineEdge("sdParentFolder", { schema: z.object({}) }); + +/** + * A reflexive composition pair: one kind whose parts are its own kind, which + * is the only shape that can build an arbitrarily deep part chain without + * declaring one node kind per level. + */ +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { SdFolder: { type: SdFolder } }, + edges: { + sdParentFolder: { + type: sdParentFolder, + from: [SdFolder], + to: [SdFolder], + cardinality: "one", + }, + }, + ontology: [ + partOf(SdFolder, SdFolder, { via: sdParentFolder, partSide: "from" }), + ], + }); +} + +/** + * An engine that stopped the statement, in the shape + * `isStatementCutShortError` classifies structurally (never by message): + * SQLite's `SQLITE_INTERRUPT`, which is how a statement timeout is delivered + * on this engine. + */ +function interruptingBackend( + base: GraphBackend, + shouldInterrupt: (text: string) => boolean, +): GraphBackend { + return deriveBackend(base, { + execute: async (compiled: CompiledRowsSql): Promise => { + const text = compiled.chunks + .map((chunk) => (chunk.kind === "text" ? chunk.value : "")) + .join(""); + if (shouldInterrupt(text)) { + throw Object.assign(new Error("interrupted"), { + code: "SQLITE_INTERRUPT", + }); + } + return base.execute(compiled); + }, + }); +} + +/** Two levels past the ceiling, so truncation at it is unmistakable. */ +const CHAIN_LENGTH = MAX_EXPLICIT_RECURSIVE_DEPTH + 2; + +describe("subgraph({ composition: true }) completeness", () => { + it(`returns every part of a chain ${CHAIN_LENGTH} levels deep`, async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("sd_deep_chain"), + backend, + ); + + // One batch: every row is inserted before any composition edge is + // attached, so each item can name the item above it as its whole. + const ids = Array.from( + { length: CHAIN_LENGTH }, + (_unused, index) => `sd-folder-${String(index).padStart(5, "0")}`, + ); + await store.nodes.SdFolder.bulkCreate( + ids.map((id, index) => ({ + id, + props: { depth: index }, + ...(index === 0 ? + {} + : { + partOf: { + whole: { + kind: "SdFolder" as const, + id: requireDefined(ids[index - 1]), + }, + }, + }), + })), + ); + + const root = asNodeId(requireDefined(ids[0])); + // MUTATION CHECK: bound the closure by hops again — pass + // `maxHops: MAX_EXPLICIT_RECURSIVE_DEPTH` (with `cyclePolicy: "prevent"`) + // through a hop-bounded builder in `buildSubgraphCompositionReachableCte` + // (src/store/subgraph.ts) instead of + // `buildExhaustiveDirectedReachableCte`. The unit then comes back + // truncated at the ceiling — 1001 nodes instead of 1002 — which is the + // silent partial export this test exists to forbid. + const unit = await store.subgraph( + { kind: "SdFolder", id: root }, + { edges: [], composition: true }, + ); + + expect(unit.nodes).toHaveLength(CHAIN_LENGTH); + // Every level's realizing edge came back with it. + expect(unit.edges).toHaveLength(CHAIN_LENGTH - 1); + expect(new Set(unit.edges.map((edge) => edge.kind))).toEqual( + new Set(["sdParentFolder"]), + ); + // The deepest leaf — the first row a hop ceiling drops — is present. + const depths = unit.nodes.map((node) => node.depth); + expect(Math.max(...depths)).toBe(CHAIN_LENGTH - 1); + }); + + it("refuses with a typed error when the engine cuts the closure statement short", async () => { + const base = createTestBackend(); + // Only the composition closure's own statement is interrupted. Its + // frontier is exactly `(id, kind)` — the set-semantics shape that makes it + // exhaustive — where the caller's `edges` traversal carries `depth` and + // `path` columns, so the column list tells the two apart. + const backend = interruptingBackend(base, (text) => + text.includes("RECURSIVE reachable(id, kind)"), + ); + const [store] = await createStoreWithSchema( + buildGraph("sd_cut_short"), + backend, + ); + + const parent = await store.nodes.SdFolder.create({ depth: 0 }); + await store.nodes.SdFolder.create( + { depth: 1 }, + { partOf: { whole: { kind: "SdFolder", id: parent.id } } }, + ); + + // MUTATION CHECK: drop the `isStatementCutShortError` arm from + // `fetchCompositionClosureNodes` (src/store/subgraph.ts) — the caller then + // gets the raw driver error, indistinguishable from a transport failure, + // and the "delivered or refused, never partial" promise has no refusal + // behind it. + const refusal = await store + .subgraph(parent, { edges: [], composition: true }) + .catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(ConfigurationError); + const details = (refusal as ConfigurationError).details; + expect(details["code"]).toBe("COMPOSITION_UNIT_INDETERMINATE"); + expect(details["rootKind"]).toBe("SdFolder"); + expect(details["rootId"]).toBe(parent.id); + expect((refusal as ConfigurationError).cause).toMatchObject({ + code: "SQLITE_INTERRUPT", + }); + }); +}); diff --git a/packages/typegraph/tests/constraint-enforcement.test.ts b/packages/typegraph/tests/constraint-enforcement.test.ts index a6b40f260..b34f51081 100644 --- a/packages/typegraph/tests/constraint-enforcement.test.ts +++ b/packages/typegraph/tests/constraint-enforcement.test.ts @@ -1426,6 +1426,28 @@ describe("names that could spell a reserved claim axis", () => { defineNode("PlainGhost", { schema: z.object({}) }), ).not.toThrow(); }); + + it("refuses an edge kind name at defineGraph", () => { + const Ghost = defineNode("ReservedEdgeGhost", { schema: z.object({}) }); + const haunts = defineEdge(`haunts${RESERVED}target`, { + schema: z.object({}), + }); + + expect(() => + defineGraph({ + id: "reserved_edge_kind_name", + nodes: { ReservedEdgeGhost: { type: Ghost } }, + edges: { + [`haunts${RESERVED}target`]: { + type: haunts, + from: [Ghost], + to: [Ghost], + }, + }, + ontology: [], + }), + ).toThrow(ConfigurationError); + }); }); describe("node kind names that could spell another node's path token", () => { diff --git a/packages/typegraph/tests/constraint-fence-capability.test.ts b/packages/typegraph/tests/constraint-fence-capability.test.ts index ddc366df5..c8dc5cffe 100644 --- a/packages/typegraph/tests/constraint-fence-capability.test.ts +++ b/packages/typegraph/tests/constraint-fence-capability.test.ts @@ -86,6 +86,7 @@ const Account = defineNode("Account", { const knows = defineEdge("knows", { schema: z.object({}) }); const reportsTo = defineEdge("reportsTo", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); const SHARED_SCOPE_UNIQUE = { name: "staff_email", @@ -123,6 +124,12 @@ const graph = defineGraph({ to: [Person], cardinality: "one", }, + dependsOn: { + type: dependsOn, + from: [Person], + to: [Person], + acyclic: true, + }, }, ontology: [ subClassOf(Employee, Worker), @@ -164,6 +171,34 @@ const disjointOnlyGraph = defineGraph({ ontology: [disjointWith(Wraith, Phantom)], }); +/** + * A graph whose ONLY declared hazard is a TARGET-side edge cardinality axis + * (issue #610): no unique constraint, no disjointness, and no source-side + * `cardinality` either — isolating the hazard `edgeWriteNeedsConstraintFence` + * must catch through {@link edgeCardinalityAxisReferences} rather than a + * `cardinality !== "many"` check that a target-only declaration would slip + * past. + */ +const TargetOnlyPerson = defineNode("TargetOnlyPerson", { + schema: z.object({ name: z.string() }), +}); +const targetOnlyAssigned = defineEdge("targetOnlyAssigned", { + schema: z.object({}), +}); + +const targetCardinalityOnlyGraph = defineGraph({ + id: "constraint_fence_capability_target_cardinality_only", + nodes: { TargetOnlyPerson: { type: TargetOnlyPerson } }, + edges: { + targetOnlyAssigned: { + type: targetOnlyAssigned, + from: [TargetOnlyPerson], + to: [TargetOnlyPerson], + targetCardinality: "one", + }, + }, +}); + const IMPORT_OPTIONS: ImportOptions = { onConflict: "error", refreshStatistics: false, @@ -213,6 +248,16 @@ describe("constrained writes on a backend that cannot fence them", () => { ); }); + it("refuses an edge create whose acyclicity it cannot enforce", async () => { + const store = createStore(graph, backend); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + + await expect(store.edges.dependsOn.create(alice, bob, {})).rejects.toThrow( + expectFenceRefusal("edgeAcyclicity"), + ); + }); + it("refuses a node create whose disjointness it cannot enforce", async () => { const store = createStore(graph, backend); @@ -357,6 +402,55 @@ describe("constrained writes on a backend that cannot fence them", () => { expect(liveClaimOwners()).toEqual([]); }); + it("refuses an import into a graph whose only hazard is a target-cardinality claim", async () => { + // Issue #610: a `targetCardinality`-only edge + // kind owes the same pre-write claim fence a source-`cardinality` kind + // does, up front, before the first row — even though nothing here + // declares a unique constraint, a disjoint partner, or a source-side + // `cardinality`. + const store = createStore(targetCardinalityOnlyGraph, backend); + for (const statement of generateSqliteDDL()) { + try { + sqlite.exec(statement); + } catch { + // The tables already exist from the shared fixture; the graph id is + // what separates the two graphs' rows. + } + } + + await expect( + importGraph( + store, + { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external", description: "fence capability" }, + nodes: [ + { + kind: "TargetOnlyPerson", + id: "target-only-a", + properties: { name: "A" }, + }, + ], + edges: [], + }, + IMPORT_OPTIONS, + ), + ).rejects.toThrow(expectFenceRefusal("edgeCardinality")); + + // Refused before the first row, so nothing landed and no reservation leaked. + expect( + sqlite.prepare("SELECT count(*) AS total FROM typegraph_nodes").get(), + ).toEqual({ total: 0 }); + expect(liveClaimOwners()).toEqual([]); + }); + // MUTATION CHECK (verified): in `edgeWriteNeedsConstraintFence` + // (`src/store/constraints.ts`), replace + // `edgeCardinalityAxisReferences(declarations).length === 0` with + // `(declarations.cardinality ?? "many") === "many"` (the source-only + // shape). `targetCardinality: "one"` is then invisible to the fold, the + // import proceeds unrefused, and this case fails. + it("refuses getOrCreateByEndpoints, whose convergence no key backs", async () => { const store = createStore(graph, backend); const alice = await store.nodes.Person.create({ name: "Alice" }); diff --git a/packages/typegraph/tests/constraint-write-fence.test.ts b/packages/typegraph/tests/constraint-write-fence.test.ts index 901637499..23c3ab3e6 100644 --- a/packages/typegraph/tests/constraint-write-fence.test.ts +++ b/packages/typegraph/tests/constraint-write-fence.test.ts @@ -79,6 +79,12 @@ const Team = defineNode("Team", { const knows = defineEdge("knows", { schema: z.object({}) }); /** Cardinality `one`: an application count probe no database key repeats. */ const reportsTo = defineEdge("reportsTo", { schema: z.object({}) }); +/** + * `cardinality: "many", acyclic: true`: the reachability probe + * has no database key behind it either — `CONSTRAINT_FENCE_BACKING. + * edgeAcyclicity === "lockOnly"` — so it is fenced the same way. + */ +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); const SHARED_SCOPE_UNIQUE = { name: "shared_email", @@ -115,6 +121,13 @@ const graph = defineGraph({ to: [Person], cardinality: "one", }, + dependsOn: { + type: dependsOn, + from: [Person], + to: [Person], + cardinality: "many", + acyclic: true, + }, }, ontology: [ subClassOf(Employee, Worker), @@ -204,6 +217,34 @@ describe("constrained writes take the per-graph write fence", () => { expect(graphWriteLockCount(statements)).toBe(0); }); + it("fences a many-cardinality ACYCLIC edge create and orders the lock before its reachability probe", async () => { + // Load-bearing: mutate `edgeWriteNeedsConstraintFence` + // (src/store/constraints.ts) to return `undefined` for the acyclic arm + // and this assertion drops from 1 to 0 — the exact regression + // that went unguarded (11/12 cross-backend cases still passed with the fence + // silently dropped, because none of them observed the lock STATEMENT + // itself). + const { store, statements, reset } = await createLoggedStore(); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + + reset(); + await store.edges.dependsOn.create(alice, bob, {}); + + expect(graphWriteLockCount(statements)).toBe(1); + const lockIndex = graphWriteLockIndex(statements); + const probeIndex = firstIndexMatching(statements, "WITH RECURSIVE"); + const insertIndex = firstIndexMatching( + statements, + 'INSERT INTO "typegraph_edges"', + ); + expect(lockIndex).toBeGreaterThanOrEqual(0); + // The reachability probe must run INSIDE the fence, or its verdict is + // computed outside the exclusion it depends on. + expect(probeIndex).toBeGreaterThan(lockIndex); + expect(insertIndex).toBeGreaterThan(probeIndex); + }); + it("fences getOrCreateByEndpoints even at cardinality many, because it converges on a match key", async () => { const { store, statements, reset } = await createLoggedStore(); const alice = await store.nodes.Person.create({ name: "Alice" }); @@ -288,7 +329,7 @@ describe("constrained writes take the per-graph write fence", () => { // would let that mutation live. const lockIndex = graphWriteLockIndex(statements); const probeIndex = firstIndexMatching(statements, "typegraph_nodes"); - const insertIndex = firstIndexMatching(statements, "INSERT INTO"); + const insertIndex = nodeInsertIndex(statements); expect(lockIndex).toBeGreaterThanOrEqual(0); expect(probeIndex).toBeGreaterThan(lockIndex); expect(insertIndex).toBeGreaterThan(probeIndex); @@ -536,6 +577,10 @@ describe("the lock reason survives its re-derivation from the claim sites", () = // The convergence key can include `matchOn` prop values, so no relation can // key it: still the lock alone, and stated as such. expect(CONSTRAINT_FENCE_BACKING.edgeMatchKeyConvergence).toBe("lockOnly"); + // A cycle spans a whole reachable subgraph, not a tuple: no key could + // ever refuse a second claimant, so this stays `lockOnly` forever, not + // just until a claim relation gets invented for it. + expect(CONSTRAINT_FENCE_BACKING.edgeAcyclicity).toBe("lockOnly"); }); it("takes no lock for a kind-scoped node UPDATE either, not just its create", async () => { diff --git a/packages/typegraph/tests/custom-table-names.test.ts b/packages/typegraph/tests/custom-table-names.test.ts index fa65e817e..e2375f142 100644 --- a/packages/typegraph/tests/custom-table-names.test.ts +++ b/packages/typegraph/tests/custom-table-names.test.ts @@ -65,6 +65,9 @@ describe("custom table names", () => { recordedIdentityAssertions: "app_recorded_identity_assertions", identityClosure: "app_identity_closure", identitySeparation: "app_identity_separation", + // Not overridden below (this test predates the transition log): defaults. + identityTransitions: "typegraph_identity_transitions", + identityTransitionRetention: "typegraph_identity_transition_retention", fences: "app_fences", indexMaterializations: "app_index_materializations", contributionMaterializations: "app_contribution_materializations", @@ -275,6 +278,8 @@ describe("custom table names", () => { recordedIdentityAssertions: "typegraph_recorded_identity_assertions", identityClosure: "typegraph_identity_closure", identitySeparation: "typegraph_identity_separation", + identityTransitions: "typegraph_identity_transitions", + identityTransitionRetention: "typegraph_identity_transition_retention", fences: "typegraph_fences", indexMaterializations: "typegraph_index_materializations", contributionMaterializations: "typegraph_contribution_materializations", diff --git a/packages/typegraph/tests/declaration-pipeline-contract.test.ts b/packages/typegraph/tests/declaration-pipeline-contract.test.ts new file mode 100644 index 000000000..bf16eea84 --- /dev/null +++ b/packages/typegraph/tests/declaration-pipeline-contract.test.ts @@ -0,0 +1,159 @@ +/** + * Pins the two-stage declaration pipeline: `tsc` emits declarations once into + * `scripts/declaration-emit.ts`'s directory, and tsup's rollup bundles each + * entrypoint's `.d.ts` out of THAT emit rather than deriving declarations from + * source inside the rollup graph. + * + * The distinction is all but invisible in the published artifacts — the routes + * differ only in the member order of two inferred enum literals on the + * `interchange` entrypoint — and visible in build memory: a source-rooted + * rollup peaked near 10 GiB for every entrypoint, while the two stages + * together peak near 1.5 GiB. Nothing else would fail if `dts: true` came + * back, so these assertions are the guard. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +import { + assertNoDeclarationOverride, + DECLARATION_EMIT_DIR, + DECLARATION_ENTRY_FILES, + DECLARATION_TSCONFIG, + PACKAGE_ROOT, +} from "../scripts/declaration-emit"; +import tsupConfig, { declarationEntryFor } from "../tsup.config"; + +type EntryMap = Readonly>; + +const BUILD_PIPELINE_PATH = fileURLToPath( + new URL("../scripts/build.ts", import.meta.url), +); + +function readEntryMaps(): Readonly<{ + entry: EntryMap; + declarationEntry: EntryMap; +}> { + expect(Array.isArray(tsupConfig)).toBe(false); + expect(typeof tsupConfig).toBe("object"); + + const config = tsupConfig as Readonly<{ + entry?: unknown; + dts?: unknown; + }>; + const entry = config.entry; + const dts = config.dts; + + expect(typeof entry).toBe("object"); + // `dts: true` is the regression this file exists to catch: it re-roots the + // rollup at `src`, which is what cost ~10 GiB. + expect(typeof dts).toBe("object"); + + const declarationEntry = (dts as Readonly<{ entry?: unknown }>).entry; + expect(typeof declarationEntry).toBe("object"); + + return { + entry: entry as EntryMap, + declarationEntry: declarationEntry as EntryMap, + }; +} + +describe("declaration pipeline", () => { + it("rolls every published entrypoint out of the emitted declaration tree", () => { + const { entry, declarationEntry } = readEntryMaps(); + + expect(Object.keys(declarationEntry).toSorted()).toEqual( + Object.keys(entry).toSorted(), + ); + + for (const [name, source] of Object.entries(entry)) { + const declaration = declarationEntry[name]; + expect(declaration).toBe(declarationEntryFor(source)); + expect( + path + .resolve(PACKAGE_ROOT, String(declaration)) + .startsWith(`${DECLARATION_EMIT_DIR}${path.sep}`), + ).toBe(true); + expect(declaration?.endsWith(".d.ts")).toBe(true); + } + }); + + it("waits on exactly the declaration files the rollup opens", () => { + // A watch session starts tsup once these files exist. Waiting on a + // narrower list would start the rollup before its inputs are all there; + // waiting on a wider one would hang a session forever. + const { declarationEntry } = readEntryMaps(); + + expect(DECLARATION_ENTRY_FILES.toSorted()).toEqual( + Object.values(declarationEntry) + .map((entry) => path.resolve(PACKAGE_ROOT, entry)) + .toSorted(), + ); + }); + + it("refuses a declaration entry that the emitted tree cannot contain", () => { + expect(() => declarationEntryFor("scripts/build.ts")).toThrow( + /does not start with "src\/"/, + ); + expect(() => declarationEntryFor("src/index.tsx")).toThrow( + /does not end with "\.ts"/, + ); + }); + + it("refuses a tsup declaration flag that would re-root the rollup at source", () => { + // These CLI flags replace the config's whole `dts` option, so forwarding + // one would discard the emitted roots and silently rebuild the ~10 GiB way. + for (const flag of [ + "--dts", + "--dts-only", + "--dts-resolve", + "--experimental-dts", + ]) { + expect(() => { + assertNoDeclarationOverride(["--silent", flag]); + }).toThrow(/re-rooting the declaration rollup/); + } + // `--no-dts` skips declarations outright rather than re-rooting them, and + // every unrelated flag is forwarded untouched. + expect(() => { + assertNoDeclarationOverride(["--no-dts", "--watch", "--silent"]); + }).not.toThrow(); + }); + + it("emits declarations only, into the pipeline's own disposable directory", () => { + const declarationTsconfig = JSON.parse( + readFileSync(DECLARATION_TSCONFIG, "utf8"), + ) as Readonly<{ + compilerOptions: Readonly>; + include: readonly string[]; + }>; + + expect(declarationTsconfig.compilerOptions["emitDeclarationOnly"]).toBe( + true, + ); + expect(declarationTsconfig.compilerOptions["declaration"]).toBe(true); + expect(declarationTsconfig.compilerOptions["declarationMap"]).toBe(false); + expect(declarationTsconfig.include).toEqual(["src"]); + + // `tsc` reads the emit directory from the tsconfig and the rollup reads it + // from the seam, so the two spellings must resolve to the same directory — + // otherwise the rollup bundles a stale tree, or none. + const configuredOutputDir = declarationTsconfig.compilerOptions["outDir"]; + expect(typeof configuredOutputDir).toBe("string"); + expect(path.resolve(PACKAGE_ROOT, String(configuredOutputDir))).toBe( + DECLARATION_EMIT_DIR, + ); + + // Not under `node_modules`: rollup-plugin-dts marks every path it + // resolves there as an external library import, which turns the rolled + // declarations into bare re-exports of files that do not ship. + expect( + path.relative(PACKAGE_ROOT, DECLARATION_EMIT_DIR).split(path.sep), + ).toEqual([".declaration-emit"]); + + const pipeline = readFileSync(BUILD_PIPELINE_PATH, "utf8"); + expect(pipeline).toContain("DECLARATION_TSCONFIG"); + }); +}); diff --git a/packages/typegraph/tests/degree-index-alignment.test.ts b/packages/typegraph/tests/degree-index-alignment.test.ts index e31648f81..05efe0a23 100644 --- a/packages/typegraph/tests/degree-index-alignment.test.ts +++ b/packages/typegraph/tests/degree-index-alignment.test.ts @@ -174,8 +174,8 @@ describe("degree() direction filter index alignment", () => { // stored from_kind is "Employee" — a filter derived from the declaration // alone must expand subclasses or these edges silently vanish from the // count. Deriving it from the node's own kind is exact by construction. - await store.edges.knows.create(employee as never, person); - await store.edges.memberOf.create(employee as never, team); + await store.edges.knows.create(employee, person); + await store.edges.memberOf.create(employee, team); expect( await store.algorithms.degree(employee.id, { direction: "out" }), @@ -215,8 +215,8 @@ describe("degree() direction filter index alignment", () => { const wide = createStore(buildGraph(), backend); const employee = await wide.nodes.Employee.create({ name: "emp" }); const person = await wide.nodes.Person.create({ name: "per" }); - await wide.edges.knows.create(person, employee as never); - await wide.edges.knows.create(employee as never, person); + await wide.edges.knows.create(person, employee); + await wide.edges.knows.create(employee, person); // The declaration then narrows to Employee-only endpoints. The rows on // disk still carry from_kind/to_kind = "Person" for `person`, which no diff --git a/packages/typegraph/tests/drizzle-claim-sites.test.ts b/packages/typegraph/tests/drizzle-claim-sites.test.ts index 4b8f6d9b0..29edead1e 100644 --- a/packages/typegraph/tests/drizzle-claim-sites.test.ts +++ b/packages/typegraph/tests/drizzle-claim-sites.test.ts @@ -42,13 +42,13 @@ const RECORDED_CLAIM_SITES: readonly RecordedClaimSite[] = [ }, { file: "apps/docs/src/content/docs/architecture.md", - line: 673, + line: 674, text: "graph DSL and its schema-derived types from the " + CLAIM_WORD + "-free", }, { file: "apps/docs/src/content/docs/architecture.md", - line: 675, + line: 676, text: "import the full " + CLAIM_WORD + @@ -61,7 +61,7 @@ const RECORDED_CLAIM_SITES: readonly RecordedClaimSite[] = [ }, { file: "apps/docs/src/content/docs/fulltext-search.md", - line: 671, + line: 725, text: "from the " + CLAIM_WORD + "-free backend-authoring entrypoint.", }, { diff --git a/packages/typegraph/tests/durable-edge-match-identity.test.ts b/packages/typegraph/tests/durable-edge-match-identity.test.ts index 6fbcc7173..cb2f9abb4 100644 --- a/packages/typegraph/tests/durable-edge-match-identity.test.ts +++ b/packages/typegraph/tests/durable-edge-match-identity.test.ts @@ -38,6 +38,7 @@ import { getActiveSchema, initializeSchema, migrateSchema, + rollbackSchema, } from "../src/schema/manager"; const Person = defineNode("Person", { @@ -1171,6 +1172,74 @@ describe("durable edge match identity", () => { } }); + it("probes an acyclic durable batch whole before its first row on the sequential fallback", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const graph = defineGraph({ + id: "durable_identity_acyclic_sequential_fallback", + nodes: { Person: { type: Person } }, + edges: { + knows: { + type: knows, + from: [Person], + to: [Person], + acyclic: true, + matchIdentity: { name: "knows-label", fields: ["label"] }, + }, + }, + }); + const [setup] = await createStoreWithSchema(graph, backend); + const alice = await setup.nodes.Person.create({ name: "Alice" }); + const bob = await setup.nodes.Person.create({ name: "Bob" }); + + function withoutDurableBatch(target: GraphBackend): GraphBackend; + function withoutDurableBatch( + target: TransactionBackend, + ): TransactionBackend; + function withoutDurableBatch( + target: GraphBackend | TransactionBackend, + ): GraphBackend | TransactionBackend { + return deriveBackend( + projectBackendWithout(target, ["insertEdgesDurableBatchReturning"]), + { commands: target.commands }, + ); + } + const fallback = deriveBackend(withoutDurableBatch(backend), { + transaction: (run, options) => + backend.transaction( + (target) => run(withoutDurableBatch(target)), + options, + ), + }); + const store = createStore(graph, fallback); + + const closingBatch = [ + { from: alice, to: bob, props: { label: "forward" } }, + { from: bob, to: alice, props: { label: "back" } }, + ]; + const writes = { + bulkCreate: (edges: typeof store.edges.knows) => + edges.bulkCreate(closingBatch), + bulkInsert: (edges: typeof store.edges.knows) => + edges.bulkInsert(closingBatch), + }; + for (const write of Object.values(writes)) { + const refusals: unknown[] = []; + await store.transaction(async (tx) => { + await write(tx.edges.knows).catch((error: unknown) => + refusals.push(error), + ); + }); + expect(refusals).toEqual([ + expect.objectContaining({ name: "EdgeAcyclicityError" }), + ]); + expect(await setup.edges.knows.find({})).toEqual([]); + } + } finally { + await backend.close(); + } + }); + it("does not relabel an unrelated unique-index violation", async () => { const uniqueNote = defineEdgeIndex(knows, { fields: ["note"], @@ -1270,6 +1339,36 @@ describe("durable edge match identity", () => { } }); + it("refuses a rollback that re-keys a populated edge kind, leaving the active version", async () => { + const graphId = "durable_identity_rollback_rekey"; + const { backend } = createLocalSqliteBackend(); + try { + await createStoreWithSchema(durableGraph(graphId), backend); + await migrateSchema(backend, legacyGraph(graphId), 1); + const store = createStore(legacyGraph(graphId), backend); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + await store.edges.knows.create(alice, bob, { label: "friend" }); + + await expect(rollbackSchema(backend, graphId, 1)).rejects.toMatchObject({ + details: { + reason: "edge-match-identity-rekey", + edgeKinds: ["knows"], + fromVersion: 2, + toVersion: 1, + }, + }); + const active = await backend.getActiveSchema(graphId); + expect(active?.version).toBe(2); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: passing `edgeMatchIdentity: undefined` to the composed + // rollback preflight (`prepareRollbackPreflight`, src/schema/manager.ts) + // reactivates the durable key declaration over edges that carry no key; + // reporting `active + 1` instead of the rollback target fails `toVersion`. + it("refuses first-schema identity adoption over populated unmanaged kinds", async () => { const { backend } = createLocalSqliteBackend({ tables: createSqliteTables({ edges: "legacy_app_edges" }), @@ -1323,6 +1422,67 @@ describe("durable edge match identity", () => { } }); + it("refuses a re-key on a backend without the atomic preflight by naming edge match identity", async () => { + const graphId = "durable_identity_rekey_without_preflight"; + const { backend } = createLocalSqliteBackend(); + try { + await createStoreWithSchema(legacyGraph(graphId), backend); + const narrowed = projectBackendWithout(backend, [ + "commitSchemaVersionWithPreflight", + ]); + + const error = await migrateSchema( + narrowed, + durableGraph(graphId), + 1, + ).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toEqual({ + code: "EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND", + graphId, + edgeKinds: ["knows"], + }); + await expect(backend.getActiveSchema(graphId)).resolves.toMatchObject({ + version: 1, + }); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: dropping the `edgeMatchIdentity` arm from + // `planSchemaCommitPreflight` commits the re-key through the plain + // primitive with no emptiness probe; answering it with identity's bag + // refuses with IDENTITY_REQUIRES_ATOMIC_BACKEND on a graph with no identity. + + it("refuses a re-keying rollback on a backend without the atomic preflight by naming edge match identity", async () => { + const graphId = "durable_identity_rollback_without_preflight"; + const { backend } = createLocalSqliteBackend(); + try { + await createStoreWithSchema(durableGraph(graphId), backend); + await migrateSchema(backend, legacyGraph(graphId), 1); + const narrowed = projectBackendWithout(backend, [ + "setActiveVersionWithPreflight", + ]); + + const error = await rollbackSchema(narrowed, graphId, 1).catch( + (error_: unknown) => error_, + ); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toEqual({ + code: "EDGE_MATCH_IDENTITY_REQUIRES_ATOMIC_BACKEND", + graphId, + edgeKinds: ["knows"], + }); + await expect(backend.getActiveSchema(graphId)).resolves.toMatchObject({ + version: 2, + }); + } finally { + await backend.close(); + } + }); + it("persists durable identity through a normal interchange import", async () => { const { backend } = createLocalSqliteBackend(); try { diff --git a/packages/typegraph/tests/edge-acyclicity.test.ts b/packages/typegraph/tests/edge-acyclicity.test.ts new file mode 100644 index 000000000..72f32db7f --- /dev/null +++ b/packages/typegraph/tests/edge-acyclicity.test.ts @@ -0,0 +1,1176 @@ +/** + * In-process unit and refusal tests for `acyclic: true`. + * + * Cross-backend query-and-constraint semantics live in + * `tests/backends/integration/edge-acyclicity.ts`, run on every backend. + * This file covers the pieces that need no live database at all: the + * compiled-SQL shape, the fence-reason predicate, and refusals that are + * decided before any statement runs. + */ +import { PGlite } from "@electric-sql/pglite"; +import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStore, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + EdgeAcyclicityIndeterminateError, +} from "../src"; +import { resolveRecursiveTraversal } from "../src/backend/capabilities/recursive-traversal"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { generateVectorlessPostgresMigrationSQL } from "../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../src/backend/postgres"; +import { type GraphBackend } from "../src/backend/types"; +import { + createSqlSchema, + DEFAULT_SQL_SCHEMA, +} from "../src/query/compiler/schema"; +import { getDialect, sqliteDialect } from "../src/query/dialect"; +import { countSqlParameters, renderSqlite } from "../src/query/sql-fragment"; +import { buildKindRegistry } from "../src/registry"; +import * as acyclicityModule from "../src/store/acyclicity"; +import { + type AcyclicEdgeRelation, + acyclicEdgeRelations, + acyclicRelationForEdgeKind, + assertEdgeRelationsAcyclic, + type ProposedRelationEdge, + readEdgeAcyclicityViolations, +} from "../src/store/acyclicity"; +import { edgeWriteNeedsConstraintFence } from "../src/store/constraints"; +import { uncapturedGraphWriteLock } from "../src/store/recorded-capture/clock"; +import { + type AcyclicityProbeSeed, + buildEdgeAcyclicityProbe, +} from "../src/store/recursive-cte"; +import { requireDefined } from "../src/utils/presence"; +import { advisoryOnlyBackend } from "./advisory-only-backend"; +import { createTestBackend, matchingObject } from "./test-utils"; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); +// A second node kind exists solely so the mixed-orientation relation test +// below can put a real Task/Milestone split on `blockedBy`'s endpoints — +// with a single node kind, the reversed member's from_kind +// and to_kind columns are both the literal "Task", so a CASE-arm swap on the +// projected kind columns is invisible. +const Milestone = defineNode("Milestone", { + schema: z.object({ name: z.string() }), +}); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); +const blockedBy = defineEdge("blockedBy", { schema: z.object({}) }); +const plainMany = defineEdge("plainMany", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "unit_acyclicity", + nodes: { Task: { type: Task }, Milestone: { type: Milestone } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + blockedBy: { + type: blockedBy, + from: [Task], + to: [Task, Milestone], + acyclic: true, + }, + plainMany: { type: plainMany, from: [Task], to: [Task] }, + }, +}); +const registry = buildKindRegistry(graph); + +/** `ownedBy` takes the per-graph fence (a cardinality claim) without being acyclic. */ +const ownedBy = defineEdge("ownedBy", { schema: z.object({}) }); +const fencedSiblingGraph = defineGraph({ + id: "unit_acyclicity_fenced_sibling", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + cardinality: "many", + acyclic: true, + }, + ownedBy: { type: ownedBy, from: [Task], to: [Task], cardinality: "one" }, + }, +}); + +describe("acyclicEdgeRelations / acyclicRelationForEdgeKind", () => { + it("collects every acyclic edge kind as its own singleton, forward relation", () => { + expect(acyclicEdgeRelations(graph, registry)).toEqual([ + { + name: "blockedBy", + members: [ + { edgeKind: "blockedBy", reversed: false, membership: "allRows" }, + ], + }, + { + name: "dependsOn", + members: [ + { edgeKind: "dependsOn", reversed: false, membership: "allRows" }, + ], + }, + ]); + }); + + it("answers undefined for a non-acyclic edge kind", () => { + expect( + acyclicRelationForEdgeKind(graph, registry, "plainMany"), + ).toBeUndefined(); + }); +}); + +// The single-create acyclicity gate must route through +// `edgeKindIsInAcyclicRelation` (the one owner, per `edgeAcyclic` in +// edge-operations.ts), not re-read `registration.acyclic === true` directly. +// Composed relations make a member kind participate in an +// acyclic relation while its OWN registration carries no `acyclic` key, +// a shape a plain `acyclic` fixture cannot produce, so this test simulates +// it by making the shared predicate answer `true` for a plain edge +// kind, the same thing a composed relation will do for real. It spies on +// `assertEdgeRelationsAcyclic` itself (rather than letting it run for real) +// because that function's own relation lookup is an intra-module call that +// a spy on the module's exports cannot intercept — the point here is +// narrower and precise: does the single-create path even REACH the probe. +describe("single-create acyclicity gate: routes through the one-owner predicate", () => { + it("calls assertEdgeRelationsAcyclic for a kind whose own registration has no acyclic flag, once the shared predicate says it is in a relation", async () => { + const backend = createTestBackend(); + const store = createStore(graph, backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + + // `plainMany` declares no `acyclic` key at all (see the graph fixture + // above) — exactly the composed-member shape. + expect( + acyclicRelationForEdgeKind(graph, registry, "plainMany"), + ).toBeUndefined(); + + const isInAcyclicRelationSpy = vi + .spyOn(acyclicityModule, "edgeKindIsInAcyclicRelation") + .mockImplementation( + (_graph, _registry, edgeKind) => edgeKind === "plainMany", + ); + const assertAcyclicSpy = vi + .spyOn(acyclicityModule, "assertEdgeRelationsAcyclic") + .mockResolvedValue(undefined); + + try { + await store.edges.plainMany.create(a, b); + expect(assertAcyclicSpy).toHaveBeenCalledTimes(1); + const proposed = assertAcyclicSpy.mock.calls[0]?.[1]; + expect(proposed).toEqual([ + expect.objectContaining({ edgeKind: "plainMany" }), + ]); + } finally { + isInAcyclicRelationSpy.mockRestore(); + assertAcyclicSpy.mockRestore(); + } + }); +}); + +describe("edgeWriteNeedsConstraintFence: fence-reason precedence", () => { + it("reports edgeCardinality first when both apply", () => { + expect( + edgeWriteNeedsConstraintFence({ cardinality: "one", acyclic: true }), + ).toBe("edgeCardinality"); + }); + + it("reports edgeAcyclicity for the common case: many cardinality, acyclic", () => { + expect( + edgeWriteNeedsConstraintFence({ cardinality: "many", acyclic: true }), + ).toBe("edgeAcyclicity"); + }); + + it("reports undefined for an unconstrained edge", () => { + expect( + edgeWriteNeedsConstraintFence({ cardinality: "many" }), + ).toBeUndefined(); + expect(edgeWriteNeedsConstraintFence({})).toBeUndefined(); + }); +}); + +describe("buildEdgeAcyclicityProbe: compiled-SQL pin", () => { + const relation = { + name: "dependsOn", + members: [ + { + edgeKind: "dependsOn", + reversed: false, + membership: "allRows" as const, + }, + ], + }; + const recursiveTraversal = resolveRecursiveTraversal( + createTestBackend().capabilities, + ); + + it("uses UNION (never UNION ALL) between the anchor and the recursive term", () => { + const fragment = buildEdgeAcyclicityProbe({ + graphId: "g", + members: relation.members, + seed: { + kind: "proposed", + edges: [ + { + edgeId: "e1", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: "a", + toKind: "Task", + toId: "b", + validTo: undefined, + }, + ], + }, + dialect: sqliteDialect, + schema: DEFAULT_SQL_SCHEMA, + recursiveTraversal, + operation: "test", + }); + const rendered = renderSqlite(fragment).sql; + + // The `ancestry` accumulator itself is UNION, never UNION ALL — that is + // what makes an unbounded recursion terminate with no depth bound. This + // pin's seed is `"proposed"`, so `ancestry` joins `typegraph_edges` + // directly (see `buildAcyclicityAncestryStepDirect`) — there is no + // compound `candidates` CTE in any seed form, so `UNION ALL` never + // appears anywhere in this rendered SQL. + expect(rendered).not.toContain("candidates("); + const ancestryTerm = rendered.slice( + rendered.indexOf("ancestry(origin_key"), + ); + expect(ancestryTerm).not.toContain("UNION ALL"); + expect(ancestryTerm).toMatch(/\bUNION\b/); + // No depth column and no depth predicate: MAX_EXPLICIT_RECURSIVE_DEPTH + // does not apply to this probe. + expect(rendered).not.toMatch(/\bdepth\b/i); + // The recursive term joins on both from_kind and from_id. + expect(rendered).toContain("from_kind"); + expect(rendered).toContain("from_id"); + }); + + it("compiles a VALUES row list for the proposed seed, not one SELECT per row", () => { + const manyOrigins = Array.from({ length: 5 }, (_unused, index) => ({ + edgeId: `e${String(index)}`, + edgeKind: "dependsOn", + fromKind: "Task", + fromId: `a${String(index)}`, + toKind: "Task", + toId: `b${String(index)}`, + validTo: undefined, + })); + const fragment = buildEdgeAcyclicityProbe({ + graphId: "g", + members: relation.members, + seed: { kind: "proposed", edges: manyOrigins }, + dialect: sqliteDialect, + schema: DEFAULT_SQL_SCHEMA, + recursiveTraversal, + operation: "test", + }); + const rendered = renderSqlite(fragment).sql; + expect(rendered).toContain("VALUES"); + expect(rendered).not.toContain("SELECT CAST"); + }); +}); + +describe("recursiveTraversal: { supported: false } refuses both the write and the audit", () => { + let backend: GraphBackend; + + beforeEach(() => { + const base = createTestBackend(); + backend = deriveBackend(base, { + capabilities: { + ...base.capabilities, + recursiveTraversal: { + supported: false, + reason: "test: recursive traversal disabled", + }, + }, + }); + }); + + it("refuses the write-path probe with RECURSIVE_TRAVERSAL_UNSUPPORTED", async () => { + await expect( + assertEdgeRelationsAcyclic( + { + graphId: graph.id, + graph, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + lock: uncapturedGraphWriteLock(), + operation: "test", + }, + [ + { + edgeId: "e1", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: "a", + toKind: "Task", + toId: "b", + validTo: undefined, + }, + ], + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ code: "RECURSIVE_TRAVERSAL_UNSUPPORTED" }), + }), + ); + }); + + it("refuses the audit reader too, rather than reporting an empty (falsely clean) result", async () => { + await expect( + readEdgeAcyclicityViolations( + { + graphId: graph.id, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + operation: "test", + }, + acyclicEdgeRelations(graph, registry), + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ code: "RECURSIVE_TRAVERSAL_UNSUPPORTED" }), + }), + ); + }); +}); + +// ============================================================ +// Typed terminals: the engine-cut-short indeterminate error and +// the fresh-snapshot isolation refusal. Both existed with zero coverage. +// ============================================================ + +/** A backend whose `execute` always throws an error carrying `code`. */ +function backendWhoseExecuteThrows(code: unknown): GraphBackend { + const base = createTestBackend(); + return deriveBackend(base, { + execute: () => { + throw Object.assign( + new Error(`simulated statement failure: ${String(code)}`), + { code }, + ); + }, + }); +} + +/** + * A backend whose `execute` throws the shape a real Drizzle-wrapped driver + * failure has: the top-level error's `.message` is the query text + * and carries no `code`, and the real driver error — carrying `code` — sits + * one `.cause` link down, exactly how `DrizzleQueryError` wraps node-postgres + * / postgres-js (see `src/utils/sql-errors.ts`'s `errorChain` doc comment). + * `backendWhoseExecuteThrows` puts `code` on the top-level error instead, + * which never exercises the chain walk `isStatementCutShortError` exists + * for. + */ +function backendWhoseExecuteThrowsNestedCause(code: unknown): GraphBackend { + const base = createTestBackend(); + return deriveBackend(base, { + execute: () => { + const driverError = Object.assign( + new Error(`driver error: ${String(code)}`), + { + code, + }, + ); + throw new Error("Failed query: simulated statement failure", { + cause: driverError, + }); + }, + }); +} + +function acyclicityContext(backend: GraphBackend) { + return { + graphId: graph.id, + graph, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + lock: uncapturedGraphWriteLock(), + operation: "test", + }; +} + +describe("engine cut-short mid-probe: EdgeAcyclicityIndeterminateError vs a propagated error", () => { + const PROPOSED_EDGE = { + edgeId: "e1", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: "a", + toKind: "Task", + toId: "b", + validTo: undefined, + }; + + // PostgreSQL query_canceled / program_limit_exceeded, and SQLite's + // SQLITE_INTERRUPT / SQLITE_TOOBIG — the exact code set + // `isStatementCutShortError` recognizes (`src/utils/sql-errors.ts`). + const CUT_SHORT_CODES = [ + "57014", + "54000", + "SQLITE_INTERRUPT", + "SQLITE_TOOBIG", + ] as const; + + for (const code of CUT_SHORT_CODES) { + it(`reports EdgeAcyclicityIndeterminateError when the probe is cut short with ${code}`, async () => { + const backend = backendWhoseExecuteThrows(code); + await expect( + assertEdgeRelationsAcyclic(acyclicityContext(backend), [PROPOSED_EDGE]), + ).rejects.toThrow(EdgeAcyclicityIndeterminateError); + }); + } + + it("reports EdgeAcyclicityIndeterminateError when the cut-short code sits one `.cause` link down (Drizzle-wrapped shape)", async () => { + const backend = backendWhoseExecuteThrowsNestedCause("57014"); + await expect( + assertEdgeRelationsAcyclic(acyclicityContext(backend), [PROPOSED_EDGE]), + ).rejects.toThrow(EdgeAcyclicityIndeterminateError); + }); + + it("propagates an unrecognized error code unchanged rather than reporting 'no cycle'", async () => { + const backend = backendWhoseExecuteThrows("42P01"); + let caught: unknown; + try { + await assertEdgeRelationsAcyclic(acyclicityContext(backend), [ + PROPOSED_EDGE, + ]); + } catch (error) { + caught = error; + } + expect(caught).toBeDefined(); + expect(caught).not.toBeInstanceOf(EdgeAcyclicityIndeterminateError); + expect((caught as Error).message).toContain("42P01"); + }); +}); + +const SNAPSHOT_ISOLATION_LEVELS = [ + { setting: "repeatable read", isolation: "repeatable_read" }, + { setting: "serializable", isolation: "serializable" }, +] as const; + +/** + * A store over a port whose graph-write fence is an advisory lock alone (see + * `advisoryOnlyBackend`): it advances no fence row, so nothing at the fence + * detects a stale snapshot. + */ +async function withAdvisoryOnlyStore( + run: ( + store: ReturnType>, + setIsolation: (level: string) => Promise, + ) => Promise, +): Promise { + const client = await PGlite.create(); + try { + await client.exec(generateVectorlessPostgresMigrationSQL()); + const backend = advisoryOnlyBackend( + createPostgresBackend(drizzlePglite(client), { vector: false }), + ); + await run(createStore(fencedSiblingGraph, backend), async (level) => { + await client.exec(`SET default_transaction_isolation = '${level}'`); + }); + } finally { + await client.close(); + } +} + +describe("EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT: the isolation guard's refusal branch", () => { + // MUTATION CHECK: answering `true` for a snapshot isolation whose + // acquisition advanced no fence row (`observesPostFenceCommits`) lets every + // refusal in this block resolve — verified and reverted. + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "refuses an acyclic edge create at $isolation on a port whose fence advances no fence row", + async ({ setting, isolation }) => { + await withAdvisoryOnlyStore(async (store, setIsolation) => { + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + await setIsolation(setting); + + const attempt = store.edges.dependsOn.create(a, b); + await expect(attempt).rejects.toBeInstanceOf(ConfigurationError); + await expect(attempt).rejects.toMatchObject({ + details: { + code: "EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT", + isolation, + fenceRow: "none", + }, + }); + + await setIsolation("read committed"); + await expect(store.edges.dependsOn.create(a, b)).resolves.toBeDefined(); + }); + }, + ); + + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "enforces acyclicity at $isolation on the bundled backend, whose fence advances the fence row", + async ({ setting }) => { + const client = await PGlite.create(); + try { + await client.exec(generateVectorlessPostgresMigrationSQL()); + await client.exec(`SET default_transaction_isolation = '${setting}'`); + const store = createStore( + graph, + createPostgresBackend(drizzlePglite(client), { vector: false }), + ); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + + await expect(store.edges.dependsOn.create(a, b)).resolves.toBeDefined(); + await expect(store.edges.dependsOn.create(b, a)).rejects.toBeInstanceOf( + EdgeAcyclicityError, + ); + } finally { + await client.close(); + } + }, + ); + + // The guard reads the isolation off the lock token, and a frame that does + // not acquire the per-graph lock itself — a nested managed write, or a + // sibling started while a peer's acquisition is in flight — must carry the + // acquiring frame's token rather than an evidence-free one. + // + // MUTATION CHECK (both cases): handing a non-acquiring frame + // `uncapturedGraphWriteLock()` again in `runInWriteTransactionAttempt` lets + // each write resolve — verified and reverted. + it("refuses a bulkGetOrCreateByEndpoints batch that only resurrects", async () => { + await withAdvisoryOnlyStore(async (store, setIsolation) => { + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const retired = await store.edges.dependsOn.create(b, a); + await store.edges.dependsOn.delete(retired.id); + await setIsolation("repeatable read"); + + await expect( + store.edges.dependsOn.bulkGetOrCreateByEndpoints([ + { from: b, to: a, props: {} }, + ]), + ).rejects.toMatchObject({ + details: { + code: "EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT", + isolation: "repeatable_read", + }, + }); + }); + }); + + it("refuses an acyclic create racing a sibling write that takes the fence first", async () => { + await withAdvisoryOnlyStore(async (store, setIsolation) => { + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + await setIsolation("repeatable read"); + + await expect( + store.transaction((tx) => + Promise.all([ + tx.edges.ownedBy.create(a, b), + tx.edges.dependsOn.create(a, b), + ]), + ), + ).rejects.toMatchObject({ + details: { + code: "EDGE_ACYCLICITY_REQUIRES_FRESH_SNAPSHOT", + isolation: "repeatable_read", + }, + }); + }); + }); +}); + +// ============================================================ +// The oriented (`reversed: true`) member. Acyclicity itself never +// constructs one — every real graph declares standalone, forward-only +// relations — but the composition contract does, and the ~45 lines of +// generated SQL that walk a reversed member had zero execution before this. +// ============================================================ + +describe("buildEdgeAcyclicityProbe / readEdgeAcyclicityViolations: a mixed-orientation relation", () => { + const relation: AcyclicEdgeRelation = { + name: "mixed-orientation", + members: [ + { edgeKind: "dependsOn", reversed: false, membership: "allRows" }, + { edgeKind: "blockedBy", reversed: true, membership: "allRows" }, + ], + }; + const recursiveTraversal = resolveRecursiveTraversal( + createTestBackend().capabilities, + ); + + function seedFragment(seed: AcyclicityProbeSeed) { + return buildEdgeAcyclicityProbe({ + graphId: "g", + members: relation.members, + seed, + dialect: sqliteDialect, + schema: DEFAULT_SQL_SCHEMA, + recursiveTraversal, + operation: "test", + }); + } + + it("compiles the reversed branch of a proposed seed row without throwing (pin)", () => { + const rendered = renderSqlite( + seedFragment({ + kind: "proposed", + edges: [ + { + edgeId: "e1", + edgeKind: "blockedBy", + fromKind: "Task", + fromId: "x", + toKind: "Task", + toId: "y", + validTo: undefined, + }, + ], + }), + ).sql; + expect(rendered).toContain("VALUES"); + }); + + it("compiles the CASE-oriented direct join for the relation-wide (audit) seed, no candidates CTE (pin)", () => { + const rendered = renderSqlite(seedFragment({ kind: "relation" })).sql; + expect(rendered).toMatch(/CASE WHEN/i); + // The `"relation"` (audit) seed form joins `typegraph_edges` directly — + // no `candidates` CTE — so a mixed-orientation relation's `ancestry` + // recursive step is ONE join with an OR of two index-seekable arms (see + // buildAcyclicityAncestryStepDirect's docblock), never a compound. + expect(rendered).not.toContain("candidates("); + const ancestryTerm = rendered.slice( + rendered.indexOf("ancestry(origin_key"), + ); + expect(ancestryTerm).toMatch(/\bOR\b/); + expect(ancestryTerm).toContain("from_kind"); + expect(ancestryTerm).toContain("to_kind"); + }); + + it("walks the reversed member in its TRUE relation direction, not its stored (from, to) direction", async () => { + const backend = createTestBackend(); + const store = createStore(graph, backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + // `c` is deliberately the OTHER node kind: with every node the same kind, + // a from_kind/to_kind CASE-arm swap on the reversed member is invisible + // (both columns read "Task" either way). + const c = await store.nodes.Milestone.create({ name: "c" }); + + // Given `blockedBy` is the RELATION's reversed member, these two stored + // rows compose the relation-direction graph c -> a -> b (not a cycle): + // dependsOn(a -> b) [forward] => relation edge a -> b + // blockedBy(a -> c) [reversed: swapped] => relation edge c -> a + await backend.insertEdge({ + graphId: graph.id, + id: "e-forward", + kind: "dependsOn", + fromKind: "Task", + fromId: a.id, + toKind: "Task", + toId: b.id, + props: {}, + }); + await backend.insertEdge({ + graphId: graph.id, + id: "e-reversed", + kind: "blockedBy", + fromKind: "Task", + fromId: a.id, + toKind: "Milestone", + toId: c.id, + props: {}, + }); + + const ctx = { + graphId: graph.id, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + operation: "test", + }; + expect(await readEdgeAcyclicityViolations(ctx, [relation])).toEqual([]); + + // Closing edge, in RELATION-direction terms: b -> c completes the cycle + // c -> a -> b -> c. If the reversed member were walked in its STORED + // direction instead (the defect this test is built to catch — flip + // either `proposedSeedRow`'s or `buildAcyclicitySeed`'s CASE polarity to + // reproduce it), the composed graph would instead be a->b, a->c, b->c — + // no cycle — and this would silently report no violation. + await backend.insertEdge({ + graphId: graph.id, + id: "e-closing", + kind: "dependsOn", + fromKind: "Task", + fromId: b.id, + toKind: "Milestone", + toId: c.id, + props: {}, + }); + + const violations = await readEdgeAcyclicityViolations(ctx, [relation]); + expect(violations).toHaveLength(1); + expect(violations[0]?.relation).toBe("mixed-orientation"); + // All three edges close the cycle c -> a -> b -> c; asserting only + // `toContain("e-reversed")` leaves the reversed arm's next-node CASE + // projection unguarded — e-forward and e-closing are only reachable + // through a correctly-oriented reversed hop. + expect([...(violations[0]?.edgeIds ?? [])].toSorted()).toEqual([ + "e-closing", + "e-forward", + "e-reversed", + ]); + }); +}); + +// ============================================================ +// Plan-time cycle detection. A cycle formed ENTIRELY from rows with nothing +// live yet has no stored edge to walk. `assertEdgeRelationsAcyclic` (the +// write-path predicate) passes `"proposed"`, which asserts its rows are +// ALREADY inserted whenever it proposes more than one (see +// `AcyclicityProbeSeed`'s docblock, `src/store/recursive-cte.ts`), so the two +// tests below insert the edges directly through the backend first. +// `readProposedEdgeAcyclicityViolations` probes genuinely unwritten rows for +// the graph-merge plan-time preview (`src/graph-merge/merge.ts`): it reads +// the reaches among them with the `"unwritten"` form and finds the cycle in +// memory, exactly as a batch create does through +// `assertUnwrittenEdgeRelationsAcyclic`, covered by the describe block after +// this one. +// +// Mutation check: making `readProposedEdgeAcyclicityViolations` (in +// `src/store/acyclicity.ts`) read `"proposed"` violations instead of +// composing the unwritten reaches makes the +// "readProposedEdgeAcyclicityViolations reports the same cycle" test below +// fail — a genuinely unwritten three-edge cycle then resolves with `[]`, +// because no single unwritten row's `to` reaches its own `from` over stored +// edges. +// ============================================================ + +async function seedThreeNodes( + backend: GraphBackend, +): Promise> { + const store = createStore(graph, backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const c = await store.nodes.Task.create({ name: "c" }); + return { a: a.id, b: b.id, c: c.id }; +} + +function threeEdgeCycle(nodes: Readonly<{ a: string; b: string; c: string }>) { + return [ + { + edgeId: "ab", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: nodes.a, + toKind: "Task", + toId: nodes.b, + validTo: undefined, + }, + { + edgeId: "bc", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: nodes.b, + toKind: "Task", + toId: nodes.c, + validTo: undefined, + }, + { + edgeId: "ca", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: nodes.c, + toKind: "Task", + toId: nodes.a, + validTo: undefined, + }, + ]; +} + +/** + * Inserts every edge in `edges` directly through the backend, bypassing the + * acyclicity fence — the same pattern the mixed-orientation and + * `verifyConstraintFences` tests above use to put rows in the table without + * routing through the (fenced) store API — the state + * `assertEdgeRelationsAcyclic` requires of a caller naming more than one + * `"proposed"` row. + */ +async function insertEdgesDirectly( + backend: GraphBackend, + edges: readonly ProposedRelationEdge[], +): Promise { + for (const edge of edges) { + await backend.insertEdge({ + graphId: graph.id, + id: edge.edgeId, + kind: edge.edgeKind, + fromKind: edge.fromKind, + fromId: edge.fromId, + toKind: edge.toKind, + toId: edge.toId, + props: {}, + }); + } +} + +describe('the `"proposed"` form\'s direct join sees a cycle among ALREADY-INSERTED rows', () => { + it("assertEdgeRelationsAcyclic refuses a three-edge cycle already inserted in this transaction", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + const edges = threeEdgeCycle(nodes); + // `"proposed"` asserts its rows are already inserted whenever it names + // more than one. + await insertEdgesDirectly(backend, edges); + + await expect( + assertEdgeRelationsAcyclic( + { + graphId: graph.id, + graph, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + lock: uncapturedGraphWriteLock(), + operation: "test", + }, + edges, + ), + ).rejects.toThrow(expect.objectContaining({ name: "EdgeAcyclicityError" })); + }); + + it("does NOT refuse two already-inserted edges that do not close a cycle", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + const chain = threeEdgeCycle(nodes).slice(0, 2); // a->b, b->c only + await insertEdgesDirectly(backend, chain); + + await expect( + assertEdgeRelationsAcyclic( + { + graphId: graph.id, + graph, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + lock: uncapturedGraphWriteLock(), + operation: "test", + }, + chain, + ), + ).resolves.toBeUndefined(); + }); + + it("readProposedEdgeAcyclicityViolations reports the same cycle with no lock (the graph-merge plan-time preview)", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + + const violations = + await acyclicityModule.readProposedEdgeAcyclicityViolations( + { + graphId: graph.id, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + operation: "test", + }, + graph, + threeEdgeCycle(nodes), + ); + + expect(violations).toEqual([ + { + family: "edgeAcyclicity", + relation: "dependsOn", + edgeIds: ["ab", "bc", "ca"], + }, + ]); + }); + + it("readProposedEdgeAcyclicityViolations reports a proposed self-loop directly, with no probe", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + + const violations = + await acyclicityModule.readProposedEdgeAcyclicityViolations( + { + graphId: graph.id, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + operation: "test", + }, + graph, + [ + { + edgeId: "self", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: nodes.a, + toKind: "Task", + toId: nodes.a, + validTo: undefined, + }, + ], + ); + + expect(violations).toEqual([ + { family: "edgeAcyclicity", relation: "dependsOn", edgeIds: ["self"] }, + ]); + }); +}); + +function probeContext(backend: GraphBackend) { + return { + graphId: graph.id, + graph, + registry, + schema: createSqlSchema(backend.tableNames), + dialect: getDialect(backend.dialect), + target: backend, + lock: uncapturedGraphWriteLock(), + operation: "test", + }; +} + +describe("assertUnwrittenEdgeRelationsAcyclic: a batch probed before its insert", () => { + it("refuses a three-edge cycle none of whose rows is written", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + threeEdgeCycle(nodes), + ), + ).rejects.toThrow( + expect.objectContaining({ + name: "EdgeAcyclicityError", + details: matchingObject({ edgeId: "ab", selfLoop: false }), + }), + ); + }); + + it("refuses a cycle that alternates stored and unwritten edges", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + const [ab, bc, ca] = threeEdgeCycle(nodes); + if (ab === undefined || bc === undefined || ca === undefined) { + throw new Error("expected three edges"); + } + await insertEdgesDirectly(backend, [bc]); + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + [ab, ca], + ), + ).rejects.toThrow(expect.objectContaining({ name: "EdgeAcyclicityError" })); + }); + + it("names the edge on the cycle, not an unwritten edge that merely leads into it", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + const store = createStore(graph, backend); + const outside = await store.nodes.Task.create({ name: "outside" }); + const [ab, bc, ca] = threeEdgeCycle(nodes); + if (ab === undefined || bc === undefined || ca === undefined) { + throw new Error("expected three edges"); + } + const leadIn = { + edgeId: "lead-in", + edgeKind: "dependsOn", + fromKind: "Task", + fromId: outside.id, + toKind: "Task", + toId: nodes.a, + validTo: undefined, + }; + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + [leadIn, ab, bc, ca], + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ edgeId: "ab" }), + }), + ); + }); + + it("accepts an unwritten chain and a diamond that close no cycle", async () => { + const backend = createTestBackend(); + const nodes = await seedThreeNodes(backend); + const [ab, bc] = threeEdgeCycle(nodes); + if (ab === undefined || bc === undefined) { + throw new Error("expected two edges"); + } + const ac = { ...ab, edgeId: "ac", toId: nodes.c }; + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + [ab, bc, ac], + ), + ).resolves.toBeUndefined(); + }); +}); + +// ============================================================ +// A batch larger than one statement's bind budget. Every other batch +// statement is sliced against `capabilities.maxBindParameters`; the probe +// must be too, and still see a cycle whose rows land in different slices. +// ============================================================ + +function ringEdges(nodeIds: readonly string[]): ProposedRelationEdge[] { + return nodeIds.map((fromId, index) => ({ + edgeId: `ring-${String(index)}`, + edgeKind: "dependsOn", + fromKind: "Task", + fromId, + toKind: "Task", + toId: requireDefined(nodeIds[(index + 1) % nodeIds.length]), + validTo: undefined, + })); +} + +describe("the batch acyclicity probe fits the engine's bind budget", () => { + const BIND_BUDGET = 100; + const RING_SIZE = 25; + + async function budgetedBackend(): Promise< + Readonly<{ + backend: GraphBackend; + nodeIds: readonly string[]; + statementBinds: number[]; + }> + > { + const raw = createTestBackend(); + const store = createStore(graph, raw); + const tasks = await store.nodes.Task.bulkCreate( + Array.from({ length: RING_SIZE }, (_unused, index) => ({ + props: { name: `t${String(index)}` }, + })), + ); + const statementBinds: number[] = []; + const backend = deriveBackend(raw, { + capabilities: { ...raw.capabilities, maxBindParameters: BIND_BUDGET }, + execute: (statement) => { + statementBinds.push(countSqlParameters(statement)); + return raw.execute(statement); + }, + }); + return { backend, nodeIds: tasks.map((task) => task.id), statementBinds }; + } + + // MUTATION CHECK (both cases): sending every row in one statement again + // (`readUnwrittenEdgeReaches` never slicing) binds 202 parameters for these + // 25 rows and fails the budget assertion — verified and reverted. + it("accepts a 25-row chain without exceeding a 100-parameter budget", async () => { + const { backend, nodeIds, statementBinds } = await budgetedBackend(); + const chain = ringEdges(nodeIds).slice(0, -1); + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + chain, + ), + ).resolves.toBeUndefined(); + + expect(statementBinds.length).toBeGreaterThan(1); + expect(Math.max(...statementBinds)).toBeLessThanOrEqual(BIND_BUDGET); + }); + + it("still refuses a cycle whose rows fall in different statements", async () => { + const { backend, nodeIds, statementBinds } = await budgetedBackend(); + + await expect( + acyclicityModule.assertUnwrittenEdgeRelationsAcyclic( + probeContext(backend), + ringEdges(nodeIds), + ), + ).rejects.toThrow( + expect.objectContaining({ + name: "EdgeAcyclicityError", + details: matchingObject({ edgeId: "ring-0" }), + }), + ); + expect(Math.max(...statementBinds)).toBeLessThanOrEqual(BIND_BUDGET); + }); + + // MUTATION CHECK: sending every `"proposed"` row in one statement again + // (`readProposedViolatingOriginKeys` never slicing) binds 125 parameters for + // these 25 rows and fails the budget assertion — verified and reverted. + it("probes already-inserted rows in statements that fit, and still names the cycle", async () => { + const raw = createTestBackend(); + const store = createStore(graph, raw); + const tasks = await store.nodes.Task.bulkCreate( + Array.from({ length: RING_SIZE }, (_unused, index) => ({ + props: { name: `t${String(index)}` }, + })), + ); + const ring = ringEdges(tasks.map((task) => task.id)); + await insertEdgesDirectly(raw, ring); + const statementBinds: number[] = []; + const backend = deriveBackend(raw, { + capabilities: { ...raw.capabilities, maxBindParameters: BIND_BUDGET }, + execute: (statement) => { + statementBinds.push(countSqlParameters(statement)); + return raw.execute(statement); + }, + }); + + await expect( + assertEdgeRelationsAcyclic(probeContext(backend), ring), + ).rejects.toThrow( + expect.objectContaining({ + name: "EdgeAcyclicityError", + details: matchingObject({ edgeId: "ring-0" }), + }), + ); + expect(Math.max(...statementBinds)).toBeLessThanOrEqual(BIND_BUDGET); + }); + + it("stores a batch too large for one statement on the default budget", async () => { + const store = createStore(graph, createTestBackend()); + const leafCount = 7000; + const hub = await store.nodes.Task.create({ name: "hub" }); + const leaves = await store.nodes.Task.bulkCreate( + Array.from({ length: leafCount }, (_unused, index) => ({ + props: { name: `leaf${String(index)}` }, + })), + ); + + // MUTATION CHECK: unsliced, these rows bind more parameters than SQLite + // accepts and the call fails with "too many SQL variables" — verified + // and reverted. + await store.edges.dependsOn.bulkCreate( + leaves.map((leaf) => ({ from: leaf, to: hub })), + ); + + expect(await store.edges.dependsOn.count()).toBe(leafCount); + }); +}); diff --git a/packages/typegraph/tests/edge-claim-rows.ts b/packages/typegraph/tests/edge-claim-rows.ts new file mode 100644 index 000000000..f8b51af4d --- /dev/null +++ b/packages/typegraph/tests/edge-claim-rows.ts @@ -0,0 +1,65 @@ +import { expect } from "vitest"; + +import { type GraphBackend } from "../src/backend/types"; +import { createSqlSchema } from "../src/query/compiler/schema"; +import { sql } from "../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../src/query/sql-intent"; +import { + edgeCardinalityAxisReferences, + edgeCardinalityClaims, + edgeCardinalityClaimTarget, + type EdgeCardinalityDeclarations, + type EdgeClaimSubject, +} from "../src/store/claims/edge-claims"; + +export type EdgeClaimRow = Readonly<{ + axis: string; + key: string; + edge_id: string; +}>; + +/** + * Reads the raw `typegraph_edge_claims` rows of one graph, so a test can + * assert what the claim relation holds rather than infer it from a later + * write succeeding (a claim naming an edge that never existed is taken over + * by design, so "a later create works" cannot see leftover residue). + */ +export async function readEdgeClaimRows( + backend: Pick, + graphId: string, +): Promise { + const schema = createSqlSchema(backend.tableNames); + return backend.execute( + asCompiledRowsSql(sql` + SELECT axis, key, edge_id + FROM ${sql.identifier(schema.tables.edgeClaims)} + WHERE graph_id = ${graphId} + ORDER BY axis, key + `), + ); +} + +/** + * Asserts the claim relation holds exactly the rows `edges` reserve under + * `declarations` — one per constrained axis per edge, each naming its edge — + * and nothing else. The expected rows come from the claim owner, so a test + * does not re-spell the axis encoding. + */ +export function expectOnlyClaimsOf( + rows: readonly EdgeClaimRow[], + graphId: string, + declarations: EdgeCardinalityDeclarations, + edges: readonly Omit[], +): void { + const expected = edges.flatMap((edge) => + edgeCardinalityClaims(edgeCardinalityAxisReferences(declarations), { + ...edge, + graphId, + }).map((claim): EdgeClaimRow => { + const target = edgeCardinalityClaimTarget(claim); + return { axis: target.axis, key: target.key, edge_id: edge.id }; + }), + ); + expect(rows).toHaveLength(expected.length); + expect(rows).toEqual(expect.arrayContaining(expected)); +} diff --git a/packages/typegraph/tests/edge-claims.test.ts b/packages/typegraph/tests/edge-claims.test.ts new file mode 100644 index 000000000..4228ab253 --- /dev/null +++ b/packages/typegraph/tests/edge-claims.test.ts @@ -0,0 +1,263 @@ +/** + * Unit pins for the edge cardinality decision layer (issue #610). + * + * `edgeCardinalityAxisReferences` is THE fold every layer that decides "is + * this write constrained, and by what" shares; `edgeCardinalityAxis` is THE + * axis string every claim row and every fence statement reads. A change to + * either that this file does not catch is a change no other layer can catch + * either, since they all read through these two functions. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineEdge, defineGraph, defineNode } from "../src"; +import { createSqlSchema } from "../src/query/compiler/schema"; +import { sql } from "../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../src/query/sql-intent"; +import { + type ClaimTarget, + compareClaimTargets, + edgeCardinalityAxis, +} from "../src/store/claims/axis"; +import { + activeOnlyAxisReferences, + type EdgeCardinalityAxisRef, + edgeCardinalityAxisReferences, + edgeCardinalityClaims, + edgeCardinalityClaimTarget, +} from "../src/store/claims/edge-claims"; +import { createTestBackend } from "./test-utils"; + +async function claimRowCount( + backend: ReturnType, + edgeId: string, +): Promise { + const schema = createSqlSchema(backend.tableNames); + const rows = await backend.execute<{ count: number }>( + asCompiledRowsSql(sql` + SELECT COUNT(*) as count + FROM ${sql.identifier(schema.tables.edgeClaims)} + WHERE edge_id = ${edgeId} + `), + ); + return rows[0]?.count ?? 0; +} + +describe("edgeCardinalityAxisReferences", () => { + it("returns [] when both options are many or absent", () => { + expect(edgeCardinalityAxisReferences({})).toEqual([]); + expect( + edgeCardinalityAxisReferences({ + cardinality: "many", + targetCardinality: "many", + }), + ).toEqual([]); + }); + + it("declares source before target when both are constrained", () => { + expect( + edgeCardinalityAxisReferences({ + cardinality: "one", + targetCardinality: "oneActive", + }), + ).toEqual([ + { direction: "source", cardinality: "one" }, + { direction: "target", cardinality: "oneActive" }, + ]); + }); + + // All twelve `cardinality` x `targetCardinality` combinations from the + // plan's composition matrix. + const sourceValues = ["many", "one", "unique", "oneActive"] as const; + const targetValues = ["many", "one", "oneActive"] as const; + for (const cardinality of sourceValues) { + for (const targetCardinality of targetValues) { + it(`accepts cardinality=${cardinality}, targetCardinality=${targetCardinality}`, () => { + const references = edgeCardinalityAxisReferences({ + cardinality, + targetCardinality, + }); + const expected: EdgeCardinalityAxisRef[] = []; + if (cardinality !== "many") { + expected.push({ direction: "source", cardinality }); + } + if (targetCardinality !== "many") { + expected.push({ + direction: "target", + cardinality: targetCardinality, + }); + } + expect(references).toEqual(expected); + }); + } + } +}); + +describe("activeOnlyAxisReferences", () => { + // The single owner of "does this declaration carry an active-only axis?" + // The update path's reentry probe/claim split + // and the write-fence eligibility gate both fold through this function + // instead of re-spelling `claimsWhenBornEnded === false` inline. `oneActive` + // is the only cardinality (on either side) with `claimsWhenBornEnded: + // false`, so the expected set below is independent of the spec table's own + // internals — it would fail exactly the same way whether the bug lived in + // this function or in either of its two call sites. + const sourceValues = ["many", "one", "unique", "oneActive"] as const; + const targetValues = ["many", "one", "oneActive"] as const; + for (const cardinality of sourceValues) { + for (const targetCardinality of targetValues) { + it(`cardinality=${cardinality}, targetCardinality=${targetCardinality}`, () => { + const expected: EdgeCardinalityAxisRef[] = []; + if (cardinality === "oneActive") { + expected.push({ direction: "source", cardinality }); + } + if (targetCardinality === "oneActive") { + expected.push({ + direction: "target", + cardinality: targetCardinality, + }); + } + expect( + activeOnlyAxisReferences({ cardinality, targetCardinality }), + ).toEqual(expected); + }); + } + } + + it("is always a subset of edgeCardinalityAxisReferences, in the same order", () => { + const declarations = { + cardinality: "oneActive", + targetCardinality: "oneActive", + } as const; + expect(activeOnlyAxisReferences(declarations)).toEqual( + edgeCardinalityAxisReferences(declarations), + ); + }); +}); + +describe("edgeCardinalityAxis", () => { + it("keeps source axis strings byte-identical to their original spelling (no migration)", () => { + // These three literals are the actual bytes stored in + // `typegraph_edge_claims.axis` for every pre-existing source claim. + // Changing them orphans every row a live database already holds. + expect( + edgeCardinalityAxis({ direction: "source", cardinality: "one" }, "knows"), + ).toBe("one:knows"); + expect( + edgeCardinalityAxis( + { direction: "source", cardinality: "unique" }, + "knows", + ), + ).toBe("unique:knows"); + expect( + edgeCardinalityAxis( + { direction: "source", cardinality: "oneActive" }, + "knows", + ), + ).toBe("oneActive:knows"); + }); + + it("prefixes a target axis with the reserved U+001E device, never colliding with a source axis", () => { + const targetOne = edgeCardinalityAxis( + { direction: "target", cardinality: "one" }, + "knows", + ); + const targetActive = edgeCardinalityAxis( + { direction: "target", cardinality: "oneActive" }, + "knows", + ); + expect(targetOne).toBe("\u001Eto\u001Eone:knows"); + expect(targetActive).toBe("\u001Eto\u001EoneActive:knows"); + // No source axis literal — for ANY cardinality/edge-kind spelling — can + // begin with the reserved separator, so the two namespaces cannot + // collide. + expect(targetOne.startsWith("\u001E")).toBe(true); + expect(targetOne).not.toBe("one:knows"); + }); +}); + +describe("edgeCardinalityClaims claim order", () => { + const subject = { + graphId: "g", + id: "e1", + kind: "knows", + fromKind: "Person", + fromId: "alice", + toKind: "Person", + toId: "bob", + }; + + it("orders a two-axis claim set by compareClaimTargets, not declaration order", () => { + const claims = edgeCardinalityClaims( + edgeCardinalityAxisReferences({ + cardinality: "one", + targetCardinality: "one", + }), + subject, + ); + expect(claims).toHaveLength(2); + const targets: ClaimTarget[] = claims.map((claim) => + edgeCardinalityClaimTarget(claim), + ); + const sorted = targets.toSorted((left, right) => + compareClaimTargets(left, right), + ); + expect(targets).toEqual(sorted); + }); + + it("is stable across two independent calls for the same declaration and subject", () => { + const declarations = { + cardinality: "unique", + targetCardinality: "one", + } as const; + const axisReferences = edgeCardinalityAxisReferences(declarations); + const first = edgeCardinalityClaims(axisReferences, subject).map( + (claim) => claim.direction, + ); + const second = edgeCardinalityClaims(axisReferences, subject).map( + (claim) => claim.direction, + ); + expect(first).toEqual(second); + }); +}); + +describe("claim housekeeping reaps both axes of a two-axis kind", () => { + const Housekeeping = defineNode("HousekeepingPerson", { + schema: z.object({}), + }); + const both = defineEdge("housekeepingBoth", { schema: z.object({}) }); + const housekeepingGraph = defineGraph({ + id: "edge_claim_housekeeping", + nodes: { HousekeepingPerson: { type: Housekeeping } }, + edges: { + housekeepingBoth: { + type: both, + from: [Housekeeping], + to: [Housekeeping], + cardinality: "one", + targetCardinality: "one", + }, + }, + }); + + it("hard-deleting a two-axis edge purges both its claim rows", async () => { + const backend = createTestBackend(); + const { createStoreWithSchema } = await import("../src/store"); + const [store] = await createStoreWithSchema(housekeepingGraph, backend); + const alice = await store.nodes.HousekeepingPerson.create({}); + const bob = await store.nodes.HousekeepingPerson.create({}); + const edge = await store.edges.housekeepingBoth.create(alice, bob, {}); + + expect(await claimRowCount(backend, edge.id)).toBe(2); + + await store.edges.housekeepingBoth.delete(edge.id); + await store.edges.housekeepingBoth.hardDelete(edge.id); + + expect(await claimRowCount(backend, edge.id)).toBe(0); + }); + // MUTATION CHECK (verified): hardcode `holdsCardinalityClaim: false` at + // its computation site in `executeEdgeHardDelete` + // (`src/store/operations/edge-operations.ts`), blind to + // `edgeCardinalityAxisReferences`. The purge is then skipped entirely and + // both rows survive — `claimRowCount` reads 2, not 0. +}); diff --git a/packages/typegraph/tests/edge-convergence-command.test.ts b/packages/typegraph/tests/edge-convergence-command.test.ts index 36844d323..adc01e010 100644 --- a/packages/typegraph/tests/edge-convergence-command.test.ts +++ b/packages/typegraph/tests/edge-convergence-command.test.ts @@ -32,6 +32,7 @@ import { lockRecordedGraphWrite, } from "../src/store/recorded-capture"; import { requireDefined } from "../src/utils/presence"; +import { advisoryOnlyBackend } from "./advisory-only-backend"; function convergenceCommand( id: string, @@ -90,38 +91,84 @@ async function executeConvergence( }); } +const SNAPSHOT_ISOLATION_LEVELS = [ + { setting: "repeatable read", isolation: "repeatable_read" }, + { setting: "serializable", isolation: "serializable" }, +] as const; + describe("PostgreSQL edge convergence command", () => { - it("refuses convergence under a repeatable-read server default", async () => { - const client = await PGlite.create(); - try { - await client.exec(generatePostgresDDL().join("\n\n")); - await client.exec( - "SET default_transaction_isolation = 'repeatable read'", - ); - const backend = createPostgresBackend(drizzlePglite(client), { - vector: false, - }); - const store = createAdapterStore(graph, backend); - const alice = await store.nodes.Person.create({ name: "Alice" }); - const bob = await store.nodes.Person.create({ name: "Bob" }); + // MUTATION CHECK: answering `true` for a snapshot isolation whose + // acquisition advanced no fence row (`observesPostFenceCommits`) lets both + // refusals below resolve — verified and reverted. + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "refuses convergence at $isolation on a port whose fence advances no fence row", + async ({ setting, isolation }) => { + const client = await PGlite.create(); + try { + await client.exec(generatePostgresDDL().join("\n\n")); + await client.exec(`SET default_transaction_isolation = '${setting}'`); + const backend = advisoryOnlyBackend( + createPostgresBackend(drizzlePglite(client), { vector: false }), + ); + const store = createAdapterStore(graph, backend); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); + + await expect( + store.edges.knows.getOrCreateByEndpoints( + alice, + bob, + { label: "unsafe-default" }, + { matchOn: ["label"] }, + ), + ).rejects.toMatchObject({ + details: { + code: "MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT", + isolation, + fenceRow: "none", + }, + }); + } finally { + await client.close(); + } + }, + ); + + it.each(SNAPSHOT_ISOLATION_LEVELS)( + "converges at $isolation on the bundled backend, whose fence advances the fence row", + async ({ setting }) => { + const client = await PGlite.create(); + try { + await client.exec(generatePostgresDDL().join("\n\n")); + await client.exec(`SET default_transaction_isolation = '${setting}'`); + const backend = createPostgresBackend(drizzlePglite(client), { + vector: false, + }); + const store = createAdapterStore(graph, backend); + const alice = await store.nodes.Person.create({ name: "Alice" }); + const bob = await store.nodes.Person.create({ name: "Bob" }); - await expect( - store.edges.knows.getOrCreateByEndpoints( + const created = await store.edges.knows.getOrCreateByEndpoints( alice, bob, - { label: "unsafe-default" }, + { label: "snapshot" }, { matchOn: ["label"] }, - ), - ).rejects.toMatchObject({ - details: { - code: "MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT", - isolation: "repeatable_read", - }, - }); - } finally { - await client.close(); - } - }); + ); + const found = await store.edges.knows.getOrCreateByEndpoints( + alice, + bob, + { label: "snapshot" }, + { matchOn: ["label"] }, + ); + + expect(created.action).toBe("created"); + expect(found.action).toBe("found"); + expect(found.edge.id).toBe(created.edge.id); + } finally { + await client.close(); + } + }, + ); it("allows an adopted transaction to return an existing match", async () => { const client = await PGlite.create(); @@ -172,7 +219,7 @@ describe("PostgreSQL edge convergence command", () => { } }); - it("refuses an adopted repeatable-read bulk create", async () => { + it("creates through an adopted repeatable-read transaction, whose fence advances the fence row", async () => { const client = await PGlite.create(); try { await client.exec(generatePostgresDDL().join("\n\n")); @@ -192,19 +239,14 @@ describe("PostgreSQL edge convergence command", () => { { from: alice, to: bob, - props: { label: "unsafe-bulk-create" }, + props: { label: "adopted-bulk-create" }, }, ], { matchOn: ["label"] }, ), { isolationLevel: "repeatable read" }, ), - ).rejects.toMatchObject({ - details: { - code: "MATCH_KEY_CONVERGENCE_REQUIRES_FRESH_SNAPSHOT", - isolation: "repeatable_read", - }, - }); + ).resolves.toMatchObject([{ action: "created" }]); } finally { await client.close(); } @@ -491,7 +533,7 @@ describe("PostgreSQL edge convergence command", () => { options, ), }); - const store = createStore(graph, counted); + const store = createStore(graph, advisoryOnlyBackend(counted)); const alice = await store.nodes.Person.create({ name: "Alice" }); const bob = await store.nodes.Person.create({ name: "Bob" }); diff --git a/packages/typegraph/tests/edge-integrity-option-values.test.ts b/packages/typegraph/tests/edge-integrity-option-values.test.ts new file mode 100644 index 000000000..7384288d1 --- /dev/null +++ b/packages/typegraph/tests/edge-integrity-option-values.test.ts @@ -0,0 +1,140 @@ +/** + * `cardinality`, `targetCardinality`, `endpointExistence` and `acyclic` are refused at + * `defineGraph` when stated outside their domain. + * + * Every reader of these options compares by string or strict boolean and + * treats an unrecognized value as "undeclared", so an untyped caller's typo + * would otherwise produce a graph whose declared constraint silently does not + * hold, and a stored schema the loader's own enum rejects. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, +} from "../src"; +import { + CARDINALITY_VALUES, + ENDPOINT_EXISTENCE_VALUES, + TARGET_CARDINALITY_VALUES, +} from "../src/core/edge-integrity-options"; +import { serializeSchema } from "../src/schema"; +import { parseSerializedSchema } from "../src/schema/manager"; +import { createTestBackend } from "./test-utils"; + +const Item = defineNode("Item", { schema: z.object({}) }); +const linksTo = defineEdge("linksTo", { schema: z.object({}) }); + +function graphDeclaring(options: Readonly>) { + return defineGraph({ + id: "edge_integrity_option_values", + nodes: { Item: { type: Item } }, + edges: { + linksTo: { type: linksTo, from: [Item], to: [Item], ...options } as never, + }, + }); +} + +function refusalOf(options: Readonly>): unknown { + try { + graphDeclaring(options); + return "accepted"; + } catch (error) { + return error; + } +} + +const INVALID_STATEMENTS = [ + { option: "cardinality", value: "bogus" }, + { option: "cardinality", value: "One" }, + { option: "cardinality", value: 1 }, + // eslint-disable-next-line unicorn/no-null -- an untyped caller can state null + { option: "cardinality", value: null }, + { option: "targetCardinality", value: "unique" }, + { option: "targetCardinality", value: "bogus" }, + { option: "targetCardinality", value: true }, + { option: "endpointExistence", value: "bogus" }, + { option: "endpointExistence", value: "Ever" }, + { option: "endpointExistence", value: true }, + { option: "acyclic", value: "yes" }, + { option: "acyclic", value: "true" }, + { option: "acyclic", value: 1 }, + // eslint-disable-next-line unicorn/no-null -- an untyped caller can state null + { option: "acyclic", value: null }, +] as const; + +describe("edge integrity option values", () => { + it.each(INVALID_STATEMENTS)( + "refuses $option: $value at defineGraph", + ({ option, value }) => { + const refusal = refusalOf({ [option]: value }); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toMatchObject({ + code: "EDGE_INTEGRITY_OPTION_INVALID", + edgeKind: "linksTo", + option, + }); + }, + ); + + it("accepts every declared value, and the schema it serializes loads again", () => { + const statements = [ + {}, + ...CARDINALITY_VALUES.map((cardinality) => ({ cardinality })), + ...TARGET_CARDINALITY_VALUES.map((targetCardinality) => ({ + targetCardinality, + })), + ...ENDPOINT_EXISTENCE_VALUES.map((endpointExistence) => ({ + endpointExistence, + })), + { acyclic: true }, + { acyclic: false }, + ]; + const reloadedDeclarations = statements.map((statement) => { + const document = serializeSchema(graphDeclaring(statement), 1); + const edge = parseSerializedSchema(JSON.stringify(document)).edges[ + "linksTo" + ]; + return { + cardinality: edge?.cardinality, + targetCardinality: edge?.targetCardinality, + endpointExistence: edge?.endpointExistence, + acyclic: edge?.acyclic ?? false, + }; + }); + + expect(reloadedDeclarations).toEqual( + statements.map((statement) => ({ + cardinality: "many", + targetCardinality: "many", + endpointExistence: "notDeleted", + acyclic: false, + ...statement, + })), + ); + }); + + it("never opens a store whose declared acyclicity would go unenforced", async () => { + const graph = defineGraph({ + id: "edge_integrity_option_values_acyclic", + nodes: { Item: { type: Item } }, + edges: { + linksTo: { type: linksTo, from: [Item], to: [Item], acyclic: true }, + }, + }); + const [store] = await createStoreWithSchema(graph, createTestBackend()); + const first = await store.nodes.Item.create({}); + const second = await store.nodes.Item.create({}); + await store.edges.linksTo.create(first, second, {}); + + await expect( + store.edges.linksTo.create(second, first, {}), + ).rejects.toMatchObject({ name: "EdgeAcyclicityError" }); + expect(refusalOf({ acyclic: "yes" })).toBeInstanceOf(ConfigurationError); + }); +}); diff --git a/packages/typegraph/tests/engine-member-dependency-compat.test.ts b/packages/typegraph/tests/engine-member-dependency-compat.test.ts index ba1bc8c7c..03159cd8e 100644 --- a/packages/typegraph/tests/engine-member-dependency-compat.test.ts +++ b/packages/typegraph/tests/engine-member-dependency-compat.test.ts @@ -31,6 +31,35 @@ describe("engine member dependency compatibility", () => { expect(ensureTable).not.toHaveBeenCalled(); }); + it("accepts base-schema deps without transition-log DDL and refuses version-6 adoption naming it", async () => { + const ensureTable = vi.fn((_ddl: string): Promise => + Promise.resolve(), + ); + const deps: CreateBaseSchemaMembersDeps = { + baseSchemaVersionsTableDdl: "CREATE TABLE versions", + ensureTable, + executeDdl: (_ddl: string): Promise => Promise.resolve(), + generateDdl: () => [], + readVersion: () => Promise.resolve(5), + writeVersion: (version) => Promise.resolve(version), + ensureGraphTemplatesTable: () => Promise.resolve(), + fencesTableDdl: "CREATE TABLE fences", + ensureEdgeMatchIdentityStorage: () => Promise.resolve(), + sinceIndexDdl: [], + identityTransitionsTableDdl: ["CREATE TABLE transitions"], + }; + const members = createBaseSchemaMembers(deps); + + await expect(members.adoptBaseSchema()).rejects.toMatchObject({ + code: "CONFIGURATION_ERROR", + details: { + missingDependencies: ["identityTransitionRetentionTableDdl"], + adoptionVersion: 6, + }, + }); + expect(ensureTable).not.toHaveBeenCalledWith("CREATE TABLE transitions"); + }); + it("accepts legacy identity deps and refuses journal setup before partial DDL", async () => { const ensureTable = vi.fn((_ddl: string): Promise => Promise.resolve(), diff --git a/packages/typegraph/tests/engine-native-recorded-time.test.ts b/packages/typegraph/tests/engine-native-recorded-time.test.ts index b402b0dab..f72e756fb 100644 --- a/packages/typegraph/tests/engine-native-recorded-time.test.ts +++ b/packages/typegraph/tests/engine-native-recorded-time.test.ts @@ -31,7 +31,10 @@ import { defineEdge, defineGraph, defineNode, + hasPart, migrateLegacyRecordedTime, + partOf, + pruneIdentityTransitions, recordedRelation, UnsupportedBackendCapabilityError, } from "../src"; @@ -56,9 +59,11 @@ import { createEngineRecordedInstant, createRecordedInstant, } from "../src/core/temporal"; -import { ConfigurationError } from "../src/errors"; +import { ConfigurationError, IdentityReplayError } from "../src/errors"; import { MergePlanCapabilityError } from "../src/graph-merge/errors"; import { planMerge } from "../src/graph-merge/merge"; +import { exportGraphStream, importGraphStream } from "../src/interchange"; +import { type GraphInterchangeChunk } from "../src/interchange/types"; import { createQueryBuilder } from "../src/query/builder"; import { compileQuery } from "../src/query/compiler"; import { @@ -753,6 +758,84 @@ describe("engine-native recorded time: transaction receipts", () => { }); }); +// Engine-native `history: true` gets none of TypeGraph's recorded capture, so +// no identity transition log exists to replay. The refusal must name THAT +// state: "open with history: true" is wrong advice to a store that already did. +describe("engine-native recorded time: identity replay", () => { + const engineNativeReplayRefusal = { + details: { + code: "IDENTITY_REPLAY_ENGINE_NATIVE_UNSUPPORTED", + graphId: identityGraph.id, + }, + }; + + it("refuses transitionsOf, replay and prune with the engine-native code, on the store and inside a transaction", async () => { + const { backend } = createEngineNativeBackend([]); + const [store] = await createStoreWithSchema(identityGraph, backend, { + history: true, + }); + const person = await store.nodes.PersonRecord.create({ name: "Ada" }); + const ref = { kind: "PersonRecord", id: person.id } as const; + + await expect(store.identity.transitionsOf(ref)).rejects.toBeInstanceOf( + IdentityReplayError, + ); + await expect(store.identity.transitionsOf(ref)).rejects.toMatchObject( + engineNativeReplayRefusal, + ); + await expect(store.identity.replay(ref)).rejects.toMatchObject( + engineNativeReplayRefusal, + ); + await expect( + pruneIdentityTransitions(store, { + beforeRecorded: "2026-01-01T00:00:00.000Z", + }), + ).rejects.toMatchObject(engineNativeReplayRefusal); + await expect( + store.transaction((tx) => tx.identity.transitionsOf(ref)), + ).rejects.toMatchObject(engineNativeReplayRefusal); + }); + + // Load-bearing: the upfront archival-restore guard must gate on TypeGraph + // capture, as the store's own backstop does. Revert check: gate it on the + // public `store.historyEnabled` again (true for engine-native history) and + // the guard passes, so the stream writes its nodes before the backstop + // refuses at the final section. + it("refuses an archival-transitions restore before writing any node", async () => { + const [source] = await createStoreWithSchema( + identityGraph, + createTestBackend(), + { history: true }, + ); + const person = await source.nodes.PersonRecord.create({ name: "Ada" }); + const author = await source.nodes.AuthorRecord.create({ + penName: "A. Lovelace", + }); + await source.identity.assertSame(person, author); + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push(chunk); + } + + const { backend } = createEngineNativeBackend([]); + const [target] = await createStoreWithSchema(identityGraph, backend, { + history: true, + }); + async function* replayChunks(): AsyncIterable { + for (const chunk of chunks) { + await Promise.resolve(); + yield chunk; + } + } + await expect( + importGraphStream(target, replayChunks(), { onConflict: "skip" }), + ).rejects.toMatchObject(engineNativeReplayRefusal); + expect(await target.nodes.PersonRecord.count()).toBe(0); + }); +}); + describe("engine-native recorded time: revisionNow", () => { /** * `revisionNow()` is another consumer of `#engineRecordedInstant`, @@ -991,6 +1074,103 @@ describe("engine-native recorded time: live point read and scan", () => { const scanned = await view.nodes.Widget.scan(); expect(scanned.data.map((node) => node.id)).toEqual([widget.id]); }); + + /** + * A composition relation mixing `part -> whole` (`engineOf`) and + * `whole -> part` (`carHasWheel`) realizing edges makes `parts()` compile + * the recursive walk's narrowed `_directed_edges` CTE, and makes the + * `subgraph` closure walk both orientations at once. MUTATION-PROOF: make + * `createTemporalFilterPass` (src/query/compiler/passes/temporal.ts) report + * `RECORDED_TEMPORAL_COLUMNS` for every recorded-pinned read again, + * ignoring `carriesInterval`, and the `parts()` read fails with SQLite "no + * such column: e.recorded_from" — the narrowed CTE projects a column the + * engine-native source does not have — and the windowed `subgraph` read + * below fails the same way on its windowed edge source. + */ + it("walks a mixed-orientation composition through the engine-native source", async () => { + const Car = defineNode("EnCar", { schema: z.object({}) }); + const Engine = defineNode("EnEngine", { schema: z.object({}) }); + const Wheel = defineNode("EnWheel", { schema: z.object({}) }); + const engineOf = defineEdge("enEngineOf", { schema: z.object({}) }); + const carHasWheel = defineEdge("enCarHasWheel", { schema: z.object({}) }); + const carGraph = defineGraph({ + id: "engine_native_recorded_time_composition", + nodes: { + EnCar: { type: Car }, + EnEngine: { type: Engine }, + EnWheel: { type: Wheel }, + }, + edges: { + enEngineOf: { + type: engineOf, + from: [Engine], + to: [Car], + cardinality: "one", + }, + enCarHasWheel: { + type: carHasWheel, + from: [Car], + to: [Wheel], + targetCardinality: "one", + }, + }, + ontology: [ + partOf(Engine, Car, { via: engineOf }), + hasPart(Car, Wheel, { via: carHasWheel }), + ], + }); + const sqlite = new RealDatabase(":memory:"); + cleanups.push(() => { + sqlite.close(); + }); + const profile = buildSqliteEngineProfile(drizzleSqlite(sqlite), { + executionProfile: { isSync: true }, + }); + attachEngineNativeRecordedTime( + profile.provisioning, + liveTableDressedRecordedTime(), + scriptedLineage(), + ); + const [store] = await createStoreWithSchema( + carGraph, + createSqlBackend(profile), + { history: true }, + ); + const car = await store.nodes.EnCar.create({}); + const engine = await store.nodes.EnEngine.create({}); + const wheel = await store.nodes.EnWheel.create({}); + await store.edges.enEngineOf.create(engine, car, {}); + await store.edges.enCarHasWheel.create(car, wheel, {}); + const view = store.asOfRecorded( + createEngineRecordedInstant( + LIVE_REVISION.revision, + LIVE_REVISION.recordedAt, + ), + ); + + const parts = await view + .query() + .from("EnCar", "c") + .whereNode("c", (node) => node.id.eq(car.id)) + .parts("p") + .select((ctx) => ctx.p.id) + .execute(); + expect(parts.toSorted()).toEqual([engine.id, wheel.id].toSorted()); + + const unit = await view.subgraph(car, { edges: [], composition: true }); + expect(unit.nodes.map((node) => node.id).toSorted()).toEqual( + [car.id, engine.id, wheel.id].toSorted(), + ); + expect(unit.edges).toHaveLength(2); + + // A windowed edge source keeps the recorded columns its row filter reads; + // an engine-native source has none to keep. + const windowed = await view.subgraph(car, { + edges: ["enCarHasWheel"], + edgeWindows: { enCarHasWheel: { limit: 5 } }, + }); + expect(windowed.edges.map((edge) => edge.kind)).toEqual(["enCarHasWheel"]); + }); }); describe("engine-native recorded time: historical identity refusal", () => { diff --git a/packages/typegraph/tests/errors.test.ts b/packages/typegraph/tests/errors.test.ts index 36f856b66..a00f82984 100644 --- a/packages/typegraph/tests/errors.test.ts +++ b/packages/typegraph/tests/errors.test.ts @@ -5,6 +5,8 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import type { CardinalityErrorDetails, + CompositionCycleErrorDetails, + CompositionExistenceErrorDetails, ContributionUnavailableErrorDetails, DatabaseOperationErrorDetails, DisjointErrorDetails, @@ -31,6 +33,8 @@ import type { import { CardinalityError, CompilerInvariantError, + CompositionCycleError, + CompositionExistenceError, ConfigurationError, ContributionUnavailableError, DatabaseOperationError, @@ -344,6 +348,115 @@ describe("RestrictedDeleteError", () => { }); }); +describe("CompositionCycleError", () => { + it("formats message with whole and revisited node", () => { + const error = new CompositionCycleError({ + wholeKind: "Section", + wholeId: "section-1", + revisitedKind: "Section", + revisitedId: "section-3", + }); + expect(error.message).toContain("Section/section-1"); + expect(error.message).toContain("Section/section-3"); + expect(error.code).toBe("COMPOSITION_CYCLE_DETECTED"); + expect(error.name).toBe("CompositionCycleError"); + expect(error.category).toBe("constraint"); + }); + + it("stores cycle context", () => { + const details = { + wholeKind: "Section", + wholeId: "section-1", + revisitedKind: "Section", + revisitedId: "section-3", + }; + const error = new CompositionCycleError(details); + expect(error.details).toEqual(details); + }); + + it("exposes details typed as CompositionCycleErrorDetails, no cast needed", () => { + const error = new CompositionCycleError({ + wholeKind: "Section", + wholeId: "section-1", + revisitedKind: "Section", + revisitedId: "section-3", + }); + expectTypeOf(error.details).toEqualTypeOf(); + expectTypeOf(error.details.wholeKind).toBeString(); + expectTypeOf(error.details.revisitedId).toBeString(); + }); +}); + +describe("CompositionExistenceError", () => { + it("carries a code per situation, each with its own message and way out", () => { + const create = new CompositionExistenceError({ + partKind: "Segment", + situation: "create", + }); + expect(create.code).toBe("COMPOSITION_WHOLE_REQUIRED"); + expect(create.message).toContain("no `partOf` was given"); + + const detach = new CompositionExistenceError({ + partKind: "Segment", + partId: "segment-1", + situation: "detach", + edgeKind: "segmentOf", + edgeId: "edge-1", + }); + expect(detach.code).toBe("COMPOSITION_DETACH_REFUSED"); + expect(detach.suggestion).toContain("reparent"); + + const existing = new CompositionExistenceError({ + partKind: "Segment", + partId: "segment-1", + situation: "existing", + currentWhole: { kind: "Episode", id: "episode-1" }, + requestedWhole: { kind: "Episode", id: "episode-2" }, + requestedVia: "segmentOf", + }); + expect(existing.code).toBe("COMPOSITION_WHOLE_CONFLICT"); + expect(existing.message).toContain("Episode/episode-2"); + + const props = new CompositionExistenceError({ + partKind: "Segment", + partId: "segment-1", + situation: "props", + edgeKind: "segmentOf", + edgeId: "edge-1", + currentProps: { order: 1 }, + requestedProps: { order: 2 }, + }); + expect(props.code).toBe("COMPOSITION_PROPS_CONFLICT"); + expect(props.suggestion).toContain("store.edges.segmentOf.update"); + + // Four situations, four codes — no two share one. + expect( + new Set([create.code, detach.code, existing.code, props.code]).size, + ).toBe(4); + for (const error of [create, detach, existing, props]) { + expect(error.name).toBe("CompositionExistenceError"); + expect(error.category).toBe("constraint"); + } + }); + // MUTATION: return one shared code from any arm of + // `describeCompositionExistenceRefusal` (src/errors/index.ts) — the + // distinctness assertion drops to 3 and that arm's own expectation fails. + + it("keeps situation as the discriminant for the populated details", () => { + const error = new CompositionExistenceError({ + partKind: "Segment", + partId: "segment-1", + situation: "detach", + edgeKind: "segmentOf", + edgeId: "edge-1", + }); + expectTypeOf( + error.details, + ).toEqualTypeOf(); + expect(error.details.situation).toBe("detach"); + }); +}); + describe("SchemaMismatchError", () => { it("formats message with graph id", () => { const error = new SchemaMismatchError({ @@ -676,6 +789,9 @@ describe("CardinalityError", () => { edgeKind: "HasProfile", fromKind: "User", fromId: "user-1", + direction: "source", + toKind: "HasProfileTarget", + toId: "target-1", cardinality: "one", existingCount: 1, }); @@ -687,10 +803,13 @@ describe("CardinalityError", () => { }); it("stores cardinality context", () => { - const details = { + const details: CardinalityErrorDetails = { edgeKind: "BelongsTo", fromKind: "Post", fromId: "post-1", + direction: "source", + toKind: "BelongsToTarget", + toId: "target-1", cardinality: "one", existingCount: 2, }; @@ -703,12 +822,49 @@ describe("CardinalityError", () => { edgeKind: "BelongsTo", fromKind: "Post", fromId: "post-1", + direction: "source", + toKind: "BelongsToTarget", + toId: "target-1", cardinality: "one", existingCount: 2, }); expectTypeOf(error.details).toEqualTypeOf(); expectTypeOf(error.details.existingCount).toBeNumber(); }); + + // The suggestion must name the option the caller + // actually set. A `direction: "target"` violation is never fixed by + // `cardinality` (already its default, "many"), so the suggestion has to + // name `targetCardinality` instead. + it("suggests targetCardinality, not cardinality, for a target-direction violation", () => { + const error = new CardinalityError({ + edgeKind: "ownedBy", + fromKind: "Item", + fromId: "item-1", + direction: "target", + toKind: "Owner", + toId: "owner-1", + cardinality: "one", + existingCount: 1, + }); + expect(error.suggestion).toContain("targetCardinality"); + expect(error.suggestion).not.toContain('use cardinality "many"'); + }); + + it("suggests cardinality for a source-direction violation", () => { + const error = new CardinalityError({ + edgeKind: "ownedBy", + fromKind: "Item", + fromId: "item-1", + direction: "source", + toKind: "Owner", + toId: "owner-1", + cardinality: "unique", + existingCount: 1, + }); + expect(error.suggestion).toContain('use cardinality "many"'); + expect(error.suggestion).not.toContain("targetCardinality"); + }); }); describe("EndpointError", () => { @@ -1118,6 +1274,9 @@ describe("error inheritance chain", () => { edgeKind: "E", fromKind: "K", fromId: "id", + direction: "source", + toKind: "ETarget", + toId: "target-1", cardinality: "one", existingCount: 1, }), diff --git a/packages/typegraph/tests/fence-row-scope.test.ts b/packages/typegraph/tests/fence-row-scope.test.ts new file mode 100644 index 000000000..5b32c6b2e --- /dev/null +++ b/packages/typegraph/tests/fence-row-scope.test.ts @@ -0,0 +1,466 @@ +/** + * WHO WRITES THE GRAPH'S FENCE ROW, AND WHO DOES NOT. + * + * The per-graph write fence is taken by every history-capturing, + * revision-tracking or constraint-fenced write, but the graph's row in + * `typegraph_fences` is advanced only by a writer that makes a decision + * nothing but the fence protects: an edge-acyclicity probe, a composition + * whole's parts closure, a match-key convergence. That row is one hot row per + * graph, so every other holder of the fence must leave it alone — and must not + * pay a statement for the privilege. + * + * Asserted from the emitted SQL, on PGlite: which statements name the fences + * relation, how many statements each write issues, and what the row's + * generation is afterwards. + * + * - A graph declaring no acyclic and no composition relation issues the same + * NUMBER of statements per write as it did before the fence row existed + * (`STATEMENTS_WITHOUT_A_FENCE_ROW`, counted on the commit preceding the fence + * row) and none of them names the fences relation. + * - In a graph that does declare one, only the deciding write advances the + * row, in the statement that takes the fence; a write that decides nothing + * leaves it alone. + * - A frame that reaches the decision under evidence of the exclusion alone — + * a transaction whose fence was taken by a write that decided nothing, or an + * edge kind whose named constraint is its cardinality — advances the row + * behind the lock it already holds, once. + * + * MUTATION CHECKS, each verified and reverted: + * - `lockRecordedGraphWrite` issuing `acquireKeyedSnapshotFence` (every holder + * of the portable fence advances the row) fails the revision-tracking "writes + * no fence row" case; the fused statement ignoring `fenceRow: "leave"` fails + * the other two and "leaves the row alone". + * - `writeDecidesUnderLockOnly` answering `true` for every named reason fails + * all three "writes no fence row" cases, on their cardinality write. + * - `writeDecidesUnderLockOnly` answering `false` fails "folds the advance into + * the acquisition" (the row is then advanced by a second statement) and both + * match-key convergence cases (nothing advances the row). + * - `fenceLockOnlyDecision` skipping `lockRecordedGraphDecision` fails + * "advances the row … at the decision itself". + * - The frame keeping the evidence it found instead of upgrading it fails both + * "advances the row behind the lock a transaction already holds" cases. + * - A transaction that exposes `tx.sql` keeping its fence evidence across + * writes fails "takes the fence again … after the caller rolls back the + * savepoint": the second decision then issues no fence statement and the + * row is never advanced. + * - The write session leasing the schema fence whatever its fence evidence + * fails "takes the schema fence again after the caller rolls back the + * savepoint"; it never leasing fails "takes the schema fence once". + */ +import { sql as drizzleSql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStore, + createAdapterStoreWithSchema, + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, +} from "../src"; +import type { GraphBackend } from "../src/backend/types"; +import { sql as fragment } from "../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../src/query/sql-intent"; +import { + createRecordedPostgresStore, + type LoggedStatement, +} from "./statement-recorder"; + +const Task = defineNode("Task", { + schema: z.object({ name: z.string(), slug: z.string().optional() }), +}); +const relatesTo = defineEdge("relatesTo", { schema: z.object({}) }); +const assignedTo = defineEdge("assignedTo", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); +const blocks = defineEdge("blocks", { schema: z.object({}) }); + +const taskRegistration = { + type: Task, + unique: [ + { + name: "task_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], +} as const; + +/** Constrained, history-capable, and declaring no lock-only-fenced relation. */ +const undeclaredGraph = defineGraph({ + id: "fence-row-scope-undeclared", + nodes: { Task: taskRegistration }, + edges: { + relatesTo: { type: relatesTo, from: [Task], to: [Task] }, + assignedTo: { + type: assignedTo, + from: [Task], + to: [Task], + cardinality: "one", + }, + }, +}); + +const acyclicGraph = defineGraph({ + id: "fence-row-scope-acyclic", + nodes: { Task: taskRegistration }, + edges: { + relatesTo: { type: relatesTo, from: [Task], to: [Task] }, + dependsOn: { type: dependsOn, from: [Task], to: [Task], acyclic: true }, + // Names `edgeCardinality` as its fence reason, and is acyclic as well. + blocks: { + type: blocks, + from: [Task], + to: [Task], + cardinality: "one", + acyclic: true, + }, + }, +}); + +const FENCES_RELATION = '"typegraph_fences"'; +const GRAPH_WRITE_FENCE_NAMESPACE = "typegraph:recorded-graph-write"; + +/** The statements that name the fences relation: the fence-row writes. */ +function fenceRowWrites( + statements: readonly LoggedStatement[], +): readonly LoggedStatement[] { + return statements.filter((statement) => + statement.query.includes(FENCES_RELATION), + ); +} + +/** The statements that take the per-graph write fence. */ +function fenceAcquisitions( + statements: readonly LoggedStatement[], +): readonly LoggedStatement[] { + return statements.filter( + (statement) => + statement.query.includes("pg_advisory_xact_lock") && + statement.params.includes(GRAPH_WRITE_FENCE_NAMESPACE), + ); +} + +async function fenceRowGeneration( + backend: GraphBackend, + graphId: string, +): Promise { + const rows = await backend.execute<{ generation: string | number }>( + asCompiledRowsSql( + fragment`SELECT generation FROM "typegraph_fences" WHERE key = ${`${GRAPH_WRITE_FENCE_NAMESPACE}:${graphId}`}`, + ), + ); + const [row] = rows; + return row === undefined ? undefined : Number(row.generation); +} + +type StoreFlavor = "history" | "revisionTracking" | "schemaManaged"; + +/** + * Statements each write issues on a graph declaring no lock-only-fenced + * relation, per store flavor — counted on the commit that precedes the fence + * row, so an equal count here is "no new statement". + */ +const STATEMENTS_WITHOUT_A_FENCE_ROW = { + history: { + nodeCreate: 8, + nodeUpdate: 9, + unconstrainedEdgeCreate: 8, + cardinalityEdgeCreate: 8, + nodeDelete: 13, + }, + revisionTracking: { + nodeCreate: 5, + nodeUpdate: 6, + unconstrainedEdgeCreate: 5, + cardinalityEdgeCreate: 5, + nodeDelete: 9, + }, + schemaManaged: { + nodeCreate: 5, + nodeUpdate: 6, + unconstrainedEdgeCreate: 5, + cardinalityEdgeCreate: 5, + nodeDelete: 9, + }, +} as const satisfies Record>; + +/** A capturing store of `undeclaredGraph` on a recorded backend. */ +async function undeclaredStore(flavor: StoreFlavor) { + const recorded = await createRecordedPostgresStore(undeclaredGraph); + switch (flavor) { + case "history": { + const [store] = await createStoreWithSchema( + undeclaredGraph, + recorded.backend, + { history: true }, + ); + return { ...recorded, store }; + } + case "revisionTracking": { + const store = createStore(undeclaredGraph, recorded.backend, { + revisionTracking: true, + }); + return { ...recorded, store }; + } + case "schemaManaged": { + const [store] = await createStoreWithSchema( + undeclaredGraph, + recorded.backend, + { revisionTracking: true }, + ); + return { ...recorded, store }; + } + } +} + +/** A schema-managed, revision-tracked store of `acyclicGraph`, with three tasks. */ +async function acyclicStore() { + const recorded = await createRecordedPostgresStore(acyclicGraph); + const [store] = await createStoreWithSchema(acyclicGraph, recorded.backend, { + revisionTracking: true, + }); + const a = await store.nodes.Task.create({ name: "a", slug: "a" }); + const b = await store.nodes.Task.create({ name: "b", slug: "b" }); + const c = await store.nodes.Task.create({ name: "c", slug: "c" }); + return { ...recorded, store, a, b, c }; +} + +describe("the graph's fence row is written only by a lock-only decision", () => { + describe.each(["history", "revisionTracking", "schemaManaged"] as const)( + "a %s store of a graph declaring no acyclic or composition relation", + (flavor) => { + it("writes no fence row and pays no statement for one", async () => { + const recorded = await undeclaredStore(flavor); + const { store } = recorded; + const counts: Record = {}; + const issued: LoggedStatement[] = []; + const measure = async ( + name: string, + write: () => Promise, + ): Promise => { + recorded.reset(); + const result = await write(); + counts[name] = recorded.statements.length; + issued.push(...recorded.statements); + return result; + }; + + const a = await measure("nodeCreate", () => + store.nodes.Task.create({ name: "a", slug: "a" }), + ); + const b = await store.nodes.Task.create({ name: "b", slug: "b" }); + const c = await store.nodes.Task.create({ name: "c", slug: "c" }); + await measure("nodeUpdate", () => + store.nodes.Task.update(a.id, { name: "a2" }), + ); + await measure("unconstrainedEdgeCreate", () => + store.edges.relatesTo.create(a, b), + ); + await measure("cardinalityEdgeCreate", () => + store.edges.assignedTo.create(a, b), + ); + await measure("nodeDelete", () => store.nodes.Task.delete(c.id)); + + // Every one of these writes takes the fence: the store captures. + expect(fenceAcquisitions(issued).length).toBeGreaterThan(0); + expect(fenceRowWrites(issued)).toEqual([]); + expect(counts).toEqual(STATEMENTS_WITHOUT_A_FENCE_ROW[flavor]); + expect( + await fenceRowGeneration(recorded.backend, undeclaredGraph.id), + ).toBeUndefined(); + }); + }, + ); + + it("leaves the row alone for a write that decides nothing, in a graph that declares an acyclic relation", async () => { + const recorded = await acyclicStore(); + const { store, a, b } = recorded; + recorded.reset(); + + await store.nodes.Task.update(a.id, { name: "a2" }); + await store.edges.relatesTo.create(a, b); + + expect(fenceAcquisitions(recorded.statements)).toHaveLength(2); + expect(fenceRowWrites(recorded.statements)).toEqual([]); + expect( + await fenceRowGeneration(recorded.backend, acyclicGraph.id), + ).toBeUndefined(); + }); + + it("folds the advance into the acquisition of a write that names an acyclic relation", async () => { + const recorded = await acyclicStore(); + const { store, a, b, c } = recorded; + recorded.reset(); + + await store.edges.dependsOn.create(a, b); + + // One statement takes the fence and it is the one that advances the row: + // the decision costs a row write, not a round trip. + const fenceStatements = fenceAcquisitions(recorded.statements); + expect(fenceStatements).toHaveLength(1); + expect(fenceRowWrites(recorded.statements)).toEqual(fenceStatements); + expect(await fenceRowGeneration(recorded.backend, acyclicGraph.id)).toBe(1); + + await store.edges.dependsOn.create(b, c); + expect(await fenceRowGeneration(recorded.backend, acyclicGraph.id)).toBe(2); + }); + + it("advances the row behind the lock a transaction already holds, once, when a later write decides", async () => { + const recorded = await acyclicStore(); + const { store, a, b, c } = recorded; + recorded.reset(); + + await store.transaction(async (tx) => { + // Takes the fence for the revision clock and decides nothing. + await tx.nodes.Task.update(a.id, { name: "a2" }); + expect(fenceRowWrites(recorded.statements)).toEqual([]); + + await tx.edges.dependsOn.create(a, b); + expect(fenceRowWrites(recorded.statements)).toHaveLength(1); + + // The transaction's evidence now says the row was advanced. + await tx.edges.dependsOn.create(b, c); + expect(fenceRowWrites(recorded.statements)).toHaveLength(1); + }); + + expect(await fenceRowGeneration(recorded.backend, acyclicGraph.id)).toBe(1); + }); + + it("takes the fence again and advances the row again after the caller rolls back the savepoint that held the first decision", async () => { + const recorded = await createRecordedPostgresStore(acyclicGraph); + const store = createAdapterStore(acyclicGraph, recorded.backend); + const a = await store.nodes.Task.create({ name: "a", slug: "a" }); + const b = await store.nodes.Task.create({ name: "b", slug: "b" }); + const c = await store.nodes.Task.create({ name: "c", slug: "c" }); + // The standalone creates above decided nothing, so no row exists yet. + expect( + await fenceRowGeneration(recorded.backend, acyclicGraph.id), + ).toBeUndefined(); + + await store.transaction(async (tx) => { + if (tx.sqlAvailability !== "available") { + throw new Error("The adapter transaction did not expose SQL"); + } + await tx.sql.execute(drizzleSql`SAVEPOINT first_decision`); + await tx.edges.dependsOn.create(a, b); + // Undoes the edge, the row advance and the advisory lock taken with it. + await tx.sql.execute(drizzleSql`ROLLBACK TO SAVEPOINT first_decision`); + recorded.reset(); + + await tx.edges.dependsOn.create(b, c); + const fenceStatements = fenceAcquisitions(recorded.statements); + expect(fenceStatements).toHaveLength(1); + expect(fenceRowWrites(recorded.statements)).toEqual(fenceStatements); + }); + + expect(await fenceRowGeneration(recorded.backend, acyclicGraph.id)).toBe(1); + expect(await store.edges.dependsOn.findFrom(a)).toEqual([]); + }); + + it("advances the row behind the lock at the decision itself when the frame named another constraint", async () => { + const recorded = await acyclicStore(); + const { store, a, b } = recorded; + recorded.reset(); + + // The frame names `edgeCardinality`, so it takes the fence without the + // row; the acyclicity probe is what advances it. + await store.edges.blocks.create(a, b); + + expect(fenceRowWrites(recorded.statements)).toHaveLength(1); + expect(await fenceRowGeneration(recorded.backend, acyclicGraph.id)).toBe(1); + }); + + it("advances the row for a match-key convergence, a lock-only decision in any graph", async () => { + const recorded = await createRecordedPostgresStore(undeclaredGraph); + const store = createStore(undeclaredGraph, recorded.backend); + const a = await store.nodes.Task.create({ name: "a", slug: "a" }); + const b = await store.nodes.Task.create({ name: "b", slug: "b" }); + recorded.reset(); + + await store.edges.relatesTo.getOrCreateByEndpoints(a, b, {}); + + const fenceStatements = fenceAcquisitions(recorded.statements); + expect(fenceStatements).toHaveLength(1); + expect(fenceRowWrites(recorded.statements)).toEqual(fenceStatements); + expect(await fenceRowGeneration(recorded.backend, undeclaredGraph.id)).toBe( + 1, + ); + }); + it("advances the row behind the lock a transaction already holds when a later write converges on a match key", async () => { + const recorded = await createRecordedPostgresStore(undeclaredGraph); + const store = createStore(undeclaredGraph, recorded.backend, { + revisionTracking: true, + }); + const a = await store.nodes.Task.create({ name: "a", slug: "a" }); + const b = await store.nodes.Task.create({ name: "b", slug: "b" }); + recorded.reset(); + + await store.transaction(async (tx) => { + await tx.nodes.Task.update(a.id, { name: "a2" }); + expect(fenceRowWrites(recorded.statements)).toEqual([]); + + // A convergence has no decision site of its own to advance the row at: + // its frame does, under the evidence the transaction already holds. + await tx.edges.relatesTo.getOrCreateByEndpoints(a, b, {}); + expect(fenceRowWrites(recorded.statements)).toHaveLength(1); + }); + + expect(await fenceRowGeneration(recorded.backend, undeclaredGraph.id)).toBe( + 1, + ); + }); +}); + +/** The statements that take the schema fence: a share lock on the active row. */ +function schemaFenceAcquisitions( + statements: readonly LoggedStatement[], +): readonly LoggedStatement[] { + return statements.filter( + (statement) => + statement.query.includes('"typegraph_schema_versions"') && + statement.query.includes("FOR SHARE"), + ); +} + +describe("a transaction reuses the schema fence only when its callback holds no raw SQL", () => { + it("takes the schema fence once for the writes of a transaction with no tx.sql", async () => { + const recorded = await createRecordedPostgresStore(undeclaredGraph); + const [store] = await createStoreWithSchema( + undeclaredGraph, + recorded.backend, + ); + recorded.reset(); + + await store.transaction(async (tx) => { + await tx.nodes.Task.create({ name: "a", slug: "a" }); + await tx.nodes.Task.create({ name: "b", slug: "b" }); + }); + + expect(schemaFenceAcquisitions(recorded.statements)).toHaveLength(1); + }); + + it("takes the schema fence again after the caller rolls back the savepoint that held the first write", async () => { + const recorded = await createRecordedPostgresStore(undeclaredGraph); + const [store] = await createAdapterStoreWithSchema( + undeclaredGraph, + recorded.backend, + ); + + await store.transaction(async (tx) => { + if (tx.sqlAvailability !== "available") { + throw new Error("The adapter transaction did not expose SQL"); + } + await tx.sql.execute(drizzleSql`SAVEPOINT first_write`); + await tx.nodes.Task.create({ name: "a", slug: "a" }); + // Releases the share lock the first write took on the schema row. + await tx.sql.execute(drizzleSql`ROLLBACK TO SAVEPOINT first_write`); + recorded.reset(); + + await tx.nodes.Task.create({ name: "b", slug: "b" }); + expect(schemaFenceAcquisitions(recorded.statements)).toHaveLength(1); + }); + }); +}); diff --git a/packages/typegraph/tests/field-tracker-proxy.test.ts b/packages/typegraph/tests/field-tracker-proxy.test.ts index 417a34b8a..38733c3fb 100644 --- a/packages/typegraph/tests/field-tracker-proxy.test.ts +++ b/packages/typegraph/tests/field-tracker-proxy.test.ts @@ -45,9 +45,10 @@ const schemaIntrospector = createSchemaIntrospector( function createTestState(): QueryBuilderState { return { startKinds: ["Person"], + startRequestedKinds: ["Person"], startAlias: "p", currentAlias: "p", - includeSubClasses: false, + startExpansion: "exact", traversals: [], predicates: [], projection: [], diff --git a/packages/typegraph/tests/fixtures/golden-schema-document.json b/packages/typegraph/tests/fixtures/golden-schema-document.json new file mode 100644 index 000000000..2bcc75855 --- /dev/null +++ b/packages/typegraph/tests/fixtures/golden-schema-document.json @@ -0,0 +1,328 @@ +{ + "graphId": "schema_golden_document", + "annotations": { "domain": "library", "revision": 1 }, + "version": 1, + "generatedAt": "2026-11-03T00:00:00.000Z", + "nodes": { + "GoldenLibrary": { + "kind": "GoldenLibrary", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "name": { "type": "string" }, + "code": { "type": "string" } + }, + "required": ["name", "code"], + "additionalProperties": false + }, + "uniqueConstraints": [ + { + "name": "golden_library_name", + "fields": ["name"], + "scope": "kindWithSubClasses", + "collation": "binary" + }, + { + "name": "golden_library_code", + "fields": ["code"], + "scope": "kind", + "collation": "caseInsensitive" + } + ], + "onDelete": "restrict", + "annotations": { "owner": "catalog" } + }, + "GoldenShelf": { + "kind": "GoldenShelf", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "label": { "type": "string", "minLength": 1 } }, + "required": ["label"], + "additionalProperties": false + }, + "uniqueConstraints": [], + "onDelete": "restrict" + }, + "GoldenBook": { + "kind": "GoldenBook", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { "type": "string" }, + "isbn": { "type": "string" } + }, + "required": ["title"], + "additionalProperties": false + }, + "uniqueConstraints": [ + { + "name": "golden_book_isbn", + "fields": ["isbn"], + "where": "{\"field\":\"isbn\",\"op\":\"isNotNull\"}", + "scope": "kindWithSubClasses", + "collation": "binary" + } + ], + "onDelete": "restrict" + }, + "GoldenRareBook": { + "kind": "GoldenRareBook", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { + "title": { "type": "string" }, + "isbn": { "type": "string" }, + "vault": { "type": "string" } + }, + "required": ["title", "vault"], + "additionalProperties": false + }, + "uniqueConstraints": [], + "onDelete": "restrict" + }, + "GoldenPatron": { + "kind": "GoldenPatron", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "email": { "type": "string" } }, + "required": ["email"], + "additionalProperties": false + }, + "uniqueConstraints": [], + "onDelete": "restrict" + }, + "GoldenLegacy": { + "kind": "GoldenLegacy", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "uniqueConstraints": [], + "onDelete": "restrict" + }, + "GoldenSection": { + "kind": "GoldenSection", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "uniqueConstraints": [], + "onDelete": "restrict" + }, + "GoldenArchive": { + "kind": "GoldenArchive", + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "box": { "type": "string" } }, + "required": ["box"], + "additionalProperties": false + }, + "uniqueConstraints": [ + { + "name": "golden_archive_box", + "fields": ["box"], + "scope": "kind", + "collation": "binary" + } + ], + "onDelete": "restrict" + } + }, + "edges": { + "goldenShelfIn": { + "kind": "goldenShelfIn", + "fromKinds": ["GoldenShelf"], + "toKinds": ["GoldenLibrary"], + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "cardinality": "one", + "endpointExistence": "notDeleted" + }, + "goldenHolds": { + "kind": "goldenHolds", + "fromKinds": ["GoldenShelf"], + "toKinds": ["GoldenBook"], + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "cardinality": "many", + "targetCardinality": "oneActive", + "endpointExistence": "notDeleted" + }, + "goldenCites": { + "kind": "goldenCites", + "fromKinds": ["GoldenBook"], + "toKinds": ["GoldenBook"], + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "cardinality": "many", + "endpointExistence": "currentlyValid", + "acyclic": true + }, + "goldenNests": { + "kind": "goldenNests", + "fromKinds": ["GoldenSection"], + "toKinds": ["GoldenSection"], + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": {}, + "additionalProperties": false + }, + "cardinality": "one", + "endpointExistence": "notDeleted" + }, + "goldenBorrows": { + "kind": "goldenBorrows", + "fromKinds": ["GoldenPatron"], + "toKinds": ["GoldenBook"], + "properties": { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + "properties": { "loanCode": { "type": "string" } }, + "required": ["loanCode"], + "additionalProperties": false + }, + "cardinality": "many", + "endpointExistence": "notDeleted", + "matchIdentity": { "name": "golden-loan", "fields": ["loanCode"] } + } + }, + "ontology": { + "metaEdges": { + "subClassOf": { + "name": "subClassOf", + "description": "Type inheritance (Podcast subClassOf Media)" + }, + "disjointWith": { + "name": "disjointWith", + "description": "Mutually exclusive types" + }, + "partOf": { "name": "partOf", "description": "X is part of Y" }, + "hasPart": { "name": "hasPart", "description": "Y has part X" } + }, + "relations": [ + { + "metaEdge": "subClassOf", + "from": "GoldenRareBook", + "to": "GoldenBook" + }, + { + "metaEdge": "disjointWith", + "from": "GoldenPatron", + "to": "GoldenBook" + }, + { + "metaEdge": "partOf", + "from": "GoldenShelf", + "to": "GoldenLibrary", + "via": "goldenShelfIn", + "existence": "required" + }, + { + "metaEdge": "hasPart", + "from": "GoldenShelf", + "to": "GoldenBook", + "via": "goldenHolds" + }, + { + "metaEdge": "partOf", + "from": "GoldenSection", + "to": "GoldenSection", + "via": "goldenNests", + "partSide": "from" + } + ], + "closures": { + "subClassAncestors": { "GoldenRareBook": ["GoldenBook"] }, + "subClassDescendants": { "GoldenBook": ["GoldenRareBook"] }, + "broaderClosure": {}, + "narrowerClosure": {}, + "equivalenceSets": {}, + "disjointPairs": [ + "GoldenBook|GoldenPatron", + "GoldenPatron|GoldenRareBook" + ], + "partOfClosure": { + "GoldenShelf": ["GoldenLibrary"], + "GoldenLibrary": [], + "GoldenBook": ["GoldenShelf", "GoldenLibrary"], + "GoldenSection": ["GoldenSection"] + }, + "hasPartClosure": { + "GoldenLibrary": ["GoldenShelf", "GoldenBook"], + "GoldenShelf": ["GoldenBook"], + "GoldenSection": ["GoldenSection"] + }, + "iriToKind": {}, + "edgeInverses": {}, + "edgeImplicationsClosure": {}, + "edgeImplyingClosure": {} + } + }, + "defaults": { "onNodeDelete": "restrict", "temporalMode": "current" }, + "identity": { "sameIdAcrossKinds": "ignore" }, + "indexes": [ + { + "entity": "node", + "kind": "GoldenBook", + "name": "idx_tg_node_goldenbook_title_1878ea1", + "fields": ["/title"], + "fieldValueTypes": ["string"], + "coveringFields": [], + "coveringFieldValueTypes": [], + "unique": false, + "scope": "graphAndKind" + }, + { + "entity": "node", + "kind": "GoldenPatron", + "name": "idx_tg_node_goldenpatron_email_1jwim40", + "fields": ["/email"], + "fieldValueTypes": ["string"], + "coveringFields": [], + "coveringFieldValueTypes": [], + "unique": false, + "scope": "graphAndKind", + "where": { + "__type": "index_where_null_check", + "operand": { + "__type": "index_operand_prop", + "field": "email", + "valueType": "string" + }, + "op": "isNotNull" + } + } + ], + "extension": { + "nodes": { + "GoldenArchive": { + "properties": { "box": { "type": "string" } }, + "unique": [{ "name": "golden_archive_box", "fields": ["box"] }] + } + } + }, + "deprecatedKinds": ["GoldenLegacy"] +} diff --git a/packages/typegraph/tests/get-or-create-convergence.test.ts b/packages/typegraph/tests/get-or-create-convergence.test.ts index 120f6ab59..8267d563e 100644 --- a/packages/typegraph/tests/get-or-create-convergence.test.ts +++ b/packages/typegraph/tests/get-or-create-convergence.test.ts @@ -21,6 +21,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { + CardinalityError, DatabaseOperationError, defineEdge, defineGraph, @@ -102,6 +103,28 @@ const uniqueGraph = defineGraph({ edges: {}, }); +const Member = defineNode("Member", { + schema: z.object({ email: z.string(), note: z.string().optional() }), +}); + +const noteGraph = defineGraph({ + id: "get_or_create_convergence_note", + nodes: { + Member: { + type: Member, + unique: [ + { + name: "member_email", + fields: ["email"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: {}, +}); + /** Cardinality `one`: the only constraint a bulk batch can lose a race to. */ const cardinalGraph = defineGraph({ id: "get_or_create_convergence_cardinal", @@ -420,8 +443,8 @@ describe("getOrCreateByEndpoints convergence", () => { (target) => fn({ ...target, - countEdgesFrom: async (params) => { - if (reported) return target.countEdgesFrom(params); + countEdgesAtEndpoint: async (params) => { + if (reported) return target.countEdgesAtEndpoint(params); reported = true; return 1; }, @@ -483,6 +506,278 @@ describe("getOrCreateByEndpoints convergence", () => { expect(reported).toBe(true); }); + it("does not re-run the node bulk constraint path over a matched update it already wrote", async () => { + // The convergence retry re-runs the batch on the SAME transaction. A + // reservation lost after a matched item's update landed (a racing peer + // the fence does not exclude) must therefore surface, not be retried: + // the retry would write the update a second time inside the caller's + // transaction, which keeps the first. + // + // MUTATION CHECK: drop the `progress.wrote` condition from the retry in + // `executeNodeBulkGetOrCreateByConstraint` + // (src/store/operations/node-operations.ts) — the batch then succeeds on + // its second attempt and the matched row is updated twice. + const setup = createStore(noteGraph, raw); + const member = await setup.nodes.Member.create({ + email: "a@example.com", + note: "before", + }); + + let reported = false; + let matchedUpdates = 0; + const store = createStore( + noteGraph, + deriveBackend(raw, { + transaction: (fn, options) => + raw.transaction( + (target) => + fn( + deriveBackend(target, { + updateNode: async (params) => { + if (params.id === member.id) matchedUpdates += 1; + return target.updateNode(params); + }, + insertUniqueBatch: async (entries) => { + const claimsNewRow = entries.some((entry) => + entry.key.includes("new@example.com"), + ); + if (reported || !claimsNewRow) { + return target.insertUniqueBatch?.(entries); + } + reported = true; + throw new UniquenessError({ + constraintName: "member_email", + kind: "Member", + existingId: "winner", + newId: "loser", + fields: ["email"], + }); + }, + }), + ), + options, + ), + }), + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.Member.bulkGetOrCreateByConstraint( + "member_email", + [ + { props: { email: "a@example.com", note: "after" } }, + { props: { email: "new@example.com" } }, + ], + { ifExists: "update" }, + ); + } catch (error) { + caught = error; + } + }); + + expect(reported).toBe(true); + expect(caught).toBeInstanceOf(UniquenessError); + expect(matchedUpdates).toBe(1); + const after = requireDefined(await setup.nodes.Member.getById(member.id)); + expect(after.meta.version).toBe(member.meta.version + 1); + }); + + it("re-runs the node bulk constraint path in a transaction of its own after a matched update wrote", async () => { + // The same lost reservation on a batch that opened its own transaction: + // the refusal rolled the matched update back, so the retry starts from + // nothing and writes it once. + // + // MUTATION CHECK: drop the `transactionMode !== "opened"` condition from + // the retry in `executeNodeBulkGetOrCreateByConstraint` + // (src/store/operations/node-operations.ts) — the refusal then reaches + // the caller instead of converging. + const setup = createStore(noteGraph, raw); + const member = await setup.nodes.Member.create({ + email: "a@example.com", + note: "before", + }); + + let reported = false; + const store = createStore( + noteGraph, + deriveBackend(raw, { + transaction: (fn, options) => + raw.transaction( + (target) => + fn( + deriveBackend(target, { + insertUniqueBatch: async (entries) => { + const claimsNewRow = entries.some((entry) => + entry.key.includes("new@example.com"), + ); + if (reported || !claimsNewRow) { + return target.insertUniqueBatch?.(entries); + } + reported = true; + throw new UniquenessError({ + constraintName: "member_email", + kind: "Member", + existingId: "winner", + newId: "loser", + fields: ["email"], + }); + }, + }), + ), + options, + ), + }), + ); + + const results = await store.nodes.Member.bulkGetOrCreateByConstraint( + "member_email", + [ + { props: { email: "a@example.com", note: "after" } }, + { props: { email: "new@example.com" } }, + ], + { ifExists: "update" }, + ); + + expect(reported).toBe(true); + expect(results.map((result) => result.action)).toEqual([ + "updated", + "created", + ]); + const after = requireDefined(await setup.nodes.Member.getById(member.id)); + expect(after.note).toBe("after"); + expect(after.meta.version).toBe(member.meta.version + 1); + }); + + it("does not re-run the bulk endpoint path inside a caller's transaction once a leg wrote", async () => { + // The edge analogue. The create batch lands, then the resurrection loses + // a cardinality verdict at its write; re-running the batch on the + // caller's transaction would find the edge it just created and report a + // batch that threw as one that succeeded. + // + // MUTATION CHECK: drop the `progress.wrote` condition from the retry in + // `executeEdgeBulkGetOrCreateByEndpoints` + // (src/store/operations/edge-operations.ts) — the batch then succeeds on + // its second attempt and no refusal reaches the caller. + const setup = createStore(graph, raw); + const alice = await setup.nodes.Person.create({ name: "Alice" }); + const bob = await setup.nodes.Person.create({ name: "Bob" }); + const carol = await setup.nodes.Person.create({ name: "Carol" }); + const ended = await setup.edges.knows.create(alice, carol, { + since: "2020", + }); + await setup.edges.knows.delete(ended.id); + + let reported = false; + const store = createStore( + graph, + deriveBackend(raw, { + transaction: (fn, options) => + raw.transaction( + (target) => + fn( + deriveBackend(target, { + updateEdge: async (params) => { + if (reported || params.id !== ended.id) { + return target.updateEdge(params); + } + reported = true; + throw new CardinalityError({ + edgeKind: "knows", + direction: "source", + fromKind: "Person", + fromId: alice.id, + toKind: "Person", + toId: carol.id, + cardinality: "one", + existingCount: 1, + }); + }, + }), + ), + options, + ), + }), + ); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.knows.bulkGetOrCreateByEndpoints([ + { from: alice, to: bob, props: { since: "2024" } }, + { from: alice, to: carol, props: { since: "2020" } }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(reported).toBe(true); + expect(caught).toBeInstanceOf(CardinalityError); + }); + + it("re-runs the bulk endpoint path in a transaction of its own after a leg wrote", async () => { + // The same refusal on a batch whose frame opened the transaction: the + // refusal rolled the create back, so the retry starts from nothing. + // + // MUTATION CHECK: drop the `transactionMode !== "opened"` condition from + // the retry in `executeEdgeBulkGetOrCreateByEndpoints` + // (src/store/operations/edge-operations.ts) — the refusal then reaches + // the caller instead of converging. + const setup = createStore(graph, raw); + const alice = await setup.nodes.Person.create({ name: "Alice" }); + const bob = await setup.nodes.Person.create({ name: "Bob" }); + const carol = await setup.nodes.Person.create({ name: "Carol" }); + const ended = await setup.edges.knows.create(alice, carol, { + since: "2020", + }); + await setup.edges.knows.delete(ended.id); + + let reported = false; + const store = createStore( + graph, + deriveBackend(raw, { + transaction: (fn, options) => + raw.transaction( + (target) => + fn( + deriveBackend(target, { + updateEdge: async (params) => { + if (reported || params.id !== ended.id) { + return target.updateEdge(params); + } + reported = true; + throw new CardinalityError({ + edgeKind: "knows", + direction: "source", + fromKind: "Person", + fromId: alice.id, + toKind: "Person", + toId: carol.id, + cardinality: "one", + existingCount: 1, + }); + }, + }), + ), + options, + ), + }), + ); + + const results = await store.edges.knows.bulkGetOrCreateByEndpoints([ + { from: alice, to: bob, props: { since: "2024" } }, + { from: alice, to: carol, props: { since: "2020" } }, + ]); + + expect(reported).toBe(true); + expect(results.map((result) => result.action)).toEqual([ + "created", + "resurrected", + ]); + expect(await setup.edges.knows.findFrom(alice)).toHaveLength(2); + }); + it("reports a raced attempt as an unchanged completion, not an error", async () => { // Finding the authoritative incumbent is an expected get-or-create result, // not a failed operation. The hook result carries the same did-write @@ -780,9 +1075,9 @@ describe("getOrCreateByEndpoints convergence", () => { endpointProbes += 1; return target.getNode(graphId, nodeKind, id); }, - countEdgesFrom: async (params) => { + countEdgesAtEndpoint: async (params) => { cardinalityProbes += 1; - return target.countEdgesFrom(params); + return target.countEdgesAtEndpoint(params); }, }), ), diff --git a/packages/typegraph/tests/graph-extension-ontology-keys.test.ts b/packages/typegraph/tests/graph-extension-ontology-keys.test.ts new file mode 100644 index 000000000..db1c5688b --- /dev/null +++ b/packages/typegraph/tests/graph-extension-ontology-keys.test.ts @@ -0,0 +1,86 @@ +/** + * Canonical-key injectivity for `src/graph-extension/ontology-keys.ts`. + * + * Both keys used to be a delimiter join (`${metaEdge}|${from}|${to}`), which + * collides for any kind name containing the delimiter and is blind to a + * `via`/`partSide` change. Both are now `encodeTupleKey([...])`. + * + * MUTATION CHECK: reverted both + * functions to the delimiter-join form — "does not collide two distinct + * relations..." and "folds via/partSide into the key..." both flipped to + * failing; "agree for the same relation" kept passing (a false negative on + * agreement is not what the join breaks). Restored after the check. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { subClassOf } from "../src"; +import { defineNode } from "../src/core/node"; +import { type ExtensionOntologyRelation } from "../src/graph-extension/extension-types"; +import { + compileTimeOntologyKey, + graphExtensionOntologyKey, +} from "../src/graph-extension/ontology-keys"; + +const emptySchema = z.object({}); + +describe("ontology relation canonical keys", () => { + it("compileTimeOntologyKey and graphExtensionOntologyKey agree for the same relation", () => { + const A = defineNode("A|B", { schema: emptySchema }); + const B = defineNode("C:D", { schema: emptySchema }); + const relation = subClassOf(A, B); + const extensionEntry: ExtensionOntologyRelation = { + metaEdge: "subClassOf", + from: "A|B", + to: "C:D", + }; + expect(compileTimeOntologyKey(relation)).toBe( + graphExtensionOntologyKey(extensionEntry), + ); + }); + + it("does not collide two distinct relations a naive delimiter join would conflate", () => { + // `${metaEdge}|${from}|${to}` (the old key) produces + // "subClassOf|A|B|C" for BOTH of these. + const left: ExtensionOntologyRelation = { + metaEdge: "subClassOf", + from: "A|B", + to: "C", + }; + const right: ExtensionOntologyRelation = { + metaEdge: "subClassOf", + from: "A", + to: "B|C", + }; + expect(graphExtensionOntologyKey(left)).not.toBe( + graphExtensionOntologyKey(right), + ); + }); + + it("folds `via`/`partSide` into the key", () => { + const plain: ExtensionOntologyRelation = { + metaEdge: "partOf", + from: "Part", + to: "Whole", + }; + const composition: ExtensionOntologyRelation = { + ...plain, + via: "realizes", + }; + expect(graphExtensionOntologyKey(plain)).not.toBe( + graphExtensionOntologyKey(composition), + ); + + const forward: ExtensionOntologyRelation = { + ...composition, + partSide: "from", + }; + const reverse: ExtensionOntologyRelation = { + ...composition, + partSide: "to", + }; + expect(graphExtensionOntologyKey(forward)).not.toBe( + graphExtensionOntologyKey(reverse), + ); + }); +}); diff --git a/packages/typegraph/tests/graph-extension.test.ts b/packages/typegraph/tests/graph-extension.test.ts index bdaca9447..f878c9e48 100644 --- a/packages/typegraph/tests/graph-extension.test.ts +++ b/packages/typegraph/tests/graph-extension.test.ts @@ -661,6 +661,42 @@ describe("edge compilation", () => { expect(typeof relation.to).toBe("object"); }); + it("compiles a declarative sameAs relation to the internal sameAs meta-edge", () => { + // `sameAs` has no public factory (it was removed), but a + // declarative graph extension can still name it — `ALL_META_EDGE_NAMES` + // stays closed, not narrowed. `compileOntologyRelation` resolves the + // string via the internal `metaEdgesByName` record; this pins that the + // resolved object is actually named `sameAs`, not silently folded into + // `equivalentTo` by a mis-keyed lookup table. + const compiled = compileGraphExtension( + defineGraphExtension({ + nodes: { + Podcast: { properties: { title: { type: "string" } } }, + Media: { properties: { title: { type: "string" } } }, + }, + ontology: [{ metaEdge: "sameAs", from: "Podcast", to: "Media" }], + }), + ); + expect(compiled.ontology).toHaveLength(1); + expect(requireDefined(compiled.ontology[0]).metaEdge.name).toBe("sameAs"); + }); + + it("compiles a declarative differentFrom relation to the internal differentFrom meta-edge", () => { + const compiled = compileGraphExtension( + defineGraphExtension({ + nodes: { + Podcast: { properties: { title: { type: "string" } } }, + Media: { properties: { title: { type: "string" } } }, + }, + ontology: [{ metaEdge: "differentFrom", from: "Podcast", to: "Media" }], + }), + ); + expect(compiled.ontology).toHaveLength(1); + expect(requireDefined(compiled.ontology[0]).metaEdge.name).toBe( + "differentFrom", + ); + }); + it("ontology endpoints that don't resolve fall through as IRI strings", () => { const compiled = compileGraphExtension( defineGraphExtension({ @@ -1573,6 +1609,55 @@ describe("document format versioning", () => { ); }); + it("accepts cardinality and targetCardinality in STRICT mode (issue #610)", () => { + const result = validateGraphExtension( + { + nodes: { + Person: { properties: { name: { type: "string" } } }, + }, + edges: { + knows: { + from: ["Person"], + to: ["Person"], + properties: {}, + cardinality: "one", + targetCardinality: "oneActive", + }, + }, + }, + { strict: true }, + ); + expect(result.success).toBe(true); + }); + // MUTATION CHECK (verified): remove `"cardinality"` and + // `"targetCardinality"` from `EDGE_BODY_KEYS` + // (`src/graph-extension/validation.ts`). This test fails — strict mode + // rejects both keys as unknown. + + it("refuses an unknown targetCardinality value with the document-shape error", () => { + const result = validateGraphExtension({ + nodes: { + Person: { properties: { name: { type: "string" } } }, + }, + edges: { + knows: { + from: ["Person"], + to: ["Person"], + properties: {}, + targetCardinality: "atMostFive", + }, + }, + }); + expect(result.success).toBe(false); + if (result.success) throw new Error("expected failure"); + expect(result.error.issues).toContainEqual( + expect.objectContaining({ + path: "/edges/knows/targetCardinality", + code: "INVALID_DOCUMENT_SHAPE", + }), + ); + }); + it("accepts unknown edge-level keys in loose mode (forward-compat)", () => { const result = validateGraphExtension({ nodes: { diff --git a/packages/typegraph/tests/graph-merge/apply-path-parity.test.ts b/packages/typegraph/tests/graph-merge/apply-path-parity.test.ts new file mode 100644 index 000000000..eee924e33 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/apply-path-parity.test.ts @@ -0,0 +1,241 @@ +/** + * Every apply entry point lands what `merge()` lands for the same staged + * work: `applyMergePlan` (TypeGraph owns the transaction) and + * `applyMergePlanInTransaction` (the caller does) replay a plan artifact + * through the same write ordering the direct merge uses. + * + * Fixture: the target holds people `a` and `b` asserted to be one person. The + * branch retracts that assertion and then ends `b`'s validity window — the + * only order the store accepts, because a current assertion's endpoint cannot + * have its window ended. An apply that wrote the node row before the + * retraction would be refused with the endpoint-validity conflict. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + asNodeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type Store, +} from "../../src"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import { branch } from "../../src/graph-merge/branch"; +import { + applyMergePlan, + applyMergePlanInTransaction, + merge, + planMerge, +} from "../../src/graph-merge/merge"; +import { unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); + +const graph = defineGraph({ + id: "apply_path_parity", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const WINDOW_END = "2030-01-01T00:00:00.000Z"; +const PERSON_A = { kind: "Person", id: "a" } as const; +const PERSON_B = { kind: "Person", id: "b" } as const; + +const cleanups: (() => Promise)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +function openBackend() { + const { backend } = createLocalSqliteBackend(); + cleanups.push(() => backend.close()); + return backend; +} + +async function branchRetractingThenEndingAnEndpoint() { + const [target] = await createStoreWithSchema(graph, openBackend(), { + history: true, + }); + await target.nodes.Person.create({ name: "a" }, { id: PERSON_A.id }); + await target.nodes.Person.create({ name: "b" }, { id: PERSON_B.id }); + const { assertion } = await target.identity.assertSame(PERSON_A, PERSON_B); + const source = unwrap( + await branch(target, () => Promise.resolve(openBackend()), { + id: asBranchId("branch-a"), + }), + ); + await source.store.identity.retractAssertion(assertion.id); + await source.store.nodes.Person.update( + asNodeId(PERSON_B.id), + {}, + { validTo: WINDOW_END }, + ); + return { target, source }; +} + +async function landedState(target: Store) { + const ended = await target.nodes.Person.getById(asNodeId(PERSON_B.id)); + const state = { + areSame: await target.identity.areSame(PERSON_A, PERSON_B), + validTo: ended?.meta.validTo, + violations: await target.verifyConstraintFences(), + }; + console.info("landed state", JSON.stringify(state)); + return state; +} + +const LANDED = { areSame: false, validTo: WINDOW_END, violations: [] }; +const MERGED = { nodes: 1, edges: 0, identity: { asserted: 0, retracted: 1 } }; + +describe("a retraction paired with an endpoint window end", () => { + it("lands through merge()", async () => { + const { target, source } = await branchRetractingThenEndingAnEndpoint(); + const report = unwrap(await merge(target, [source])); + + expect(report.merged).toEqual(MERGED); + expect(await landedState(target)).toEqual(LANDED); + }); + + it("lands through applyMergePlan with the same counts", async () => { + const { target, source } = await branchRetractingThenEndingAnEndpoint(); + const plan = unwrap(await planMerge(target, [source])); + const report = unwrap(await applyMergePlan(target, plan)); + + expect(report.merged).toEqual(MERGED); + expect(await landedState(target)).toEqual(LANDED); + }); + + it("lands through applyMergePlanInTransaction with the same counts", async () => { + const { target, source } = await branchRetractingThenEndingAnEndpoint(); + const plan = unwrap(await planMerge(target, [source])); + const report = await target.transaction((tx) => + applyMergePlanInTransaction(target, tx, plan), + ); + + expect(report.merged).toEqual(MERGED); + expect(await landedState(target)).toEqual(LANDED); + }); +}); + +/** + * A reopening — `update(id, {}, { clearValidTo: true })` — is a window mutation + * a plan artifact must carry as faithfully as an ending: a wire write that + * lost the clear would leave the row ended while the report claimed otherwise. + */ +const Follows = defineEdge("follows", { schema: z.object({}) }); + +const reopenGraph = defineGraph({ + id: "apply_path_parity_reopen", + nodes: { Person: { type: Person } }, + edges: { + follows: { type: Follows, from: [Person], to: [Person] }, + }, +}); + +const FOLLOWS_ID = asEdgeId("follows-ab"); + +async function branchReopeningEndedRows() { + const [target] = await createStoreWithSchema(reopenGraph, openBackend(), { + history: true, + }); + await target.nodes.Person.create( + { name: "a" }, + { id: PERSON_A.id, validTo: WINDOW_END }, + ); + await target.nodes.Person.create({ name: "b" }, { id: PERSON_B.id }); + await target.edges.follows.create( + { kind: "Person", id: PERSON_B.id }, + { kind: "Person", id: PERSON_A.id }, + {}, + { id: FOLLOWS_ID, validTo: WINDOW_END }, + ); + const source = unwrap( + await branch(target, () => Promise.resolve(openBackend()), { + id: asBranchId("branch-a"), + }), + ); + await source.store.nodes.Person.update( + asNodeId(PERSON_A.id), + {}, + { clearValidTo: true }, + ); + await source.store.edges.follows.update( + FOLLOWS_ID, + {}, + { clearValidTo: true }, + ); + return { target, source }; +} + +async function reopenedState(target: Store) { + const person = await target.nodes.Person.getById(asNodeId(PERSON_A.id)); + const edge = await target.edges.follows.getById(FOLLOWS_ID); + const state = { + personValidTo: person?.meta.validTo, + edgeValidTo: edge?.meta.validTo, + violations: await target.verifyConstraintFences(), + }; + console.info("reopened state", JSON.stringify(state)); + return state; +} + +const REOPENED = { + personValidTo: undefined, + edgeValidTo: undefined, + violations: [], +}; + +describe("a branch that reopens an ended node and edge window", () => { + it("lands through merge()", async () => { + const { target, source } = await branchReopeningEndedRows(); + unwrap(await merge(target, [source])); + + expect(await reopenedState(target)).toEqual(REOPENED); + }); + + it("lands through applyMergePlan", async () => { + const { target, source } = await branchReopeningEndedRows(); + const plan = unwrap(await planMerge(target, [source])); + unwrap(await applyMergePlan(target, plan)); + + expect(await reopenedState(target)).toEqual(REOPENED); + }); + + it("lands through applyMergePlan after a JSON round trip of the plan", async () => { + const { target, source } = await branchReopeningEndedRows(); + const plan = unwrap(await planMerge(target, [source])); + const replayed = JSON.parse(JSON.stringify(plan)) as typeof plan; + unwrap(await applyMergePlan(target, replayed)); + + expect(await reopenedState(target)).toEqual(REOPENED); + }); + + it("lands through applyMergePlanInTransaction", async () => { + const { target, source } = await branchReopeningEndedRows(); + const plan = unwrap(await planMerge(target, [source])); + await target.transaction((tx) => + applyMergePlanInTransaction(target, tx, plan), + ); + + expect(await reopenedState(target)).toEqual(REOPENED); + }); + + it("reports the reopenings the artifact carries", async () => { + const { target, source } = await branchReopeningEndedRows(); + const plan = unwrap(await planMerge(target, [source])); + + expect(plan.writes.nodeUpserts).toContainEqual( + expect.objectContaining({ id: PERSON_A.id, clearValidTo: true }), + ); + expect(plan.writes.edgeUpserts).toContainEqual( + expect.objectContaining({ id: FOLLOWS_ID, clearValidTo: true }), + ); + expect(plan.review.validityEnds).toHaveLength(2); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/branch.test.ts b/packages/typegraph/tests/graph-merge/branch.test.ts index 01847e0ae..76920b4fd 100644 --- a/packages/typegraph/tests/graph-merge/branch.test.ts +++ b/packages/typegraph/tests/graph-merge/branch.test.ts @@ -4,6 +4,7 @@ import { defineEdge, defineGraph, defineNode, + subClassOf, } from "@nicia-ai/typegraph"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; @@ -28,7 +29,10 @@ import { enumerateAllNodes, } from "../../src/graph-merge/state-diff"; import { asBranchId } from "../../src/graph-merge/types"; -import { cloneWorkingCopyStrategy } from "../../src/graph-merge/working-copy"; +import { + cloneIngestionWorkingCopyStrategy, + cloneWorkingCopyStrategy, +} from "../../src/graph-merge/working-copy"; import { exportGraph, importGraph } from "../../src/interchange"; import { backendMatrix, @@ -55,6 +59,17 @@ const graph = defineGraph({ type G = typeof graph; +const Media = defineNode("Media", { schema: z.object({ title: z.string() }) }); +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); +const catalogGraph = defineGraph({ + id: "branch-query-defaults-test", + nodes: { Media: { type: Media }, Podcast: { type: Podcast } }, + edges: {}, + ontology: [subClassOf(Podcast, Media)], +}); + const WorkItem = defineNode("WorkItem", { schema: z.object({ title: z.string(), @@ -586,6 +601,87 @@ describe.each(backendMatrix())("branch [$name]", (entry) => { expect(closeCount).toBe(1); }); + describe("query defaults", () => { + const QUERY_DEFAULTS = { + expansion: "exact", + traversalExpansion: "none", + } as const; + + async function seedCatalog() { + const [baseStore] = await createStoreWithSchema( + catalogGraph, + await makeBackend(), + { queryDefaults: QUERY_DEFAULTS }, + ); + await baseStore.nodes.Media.create({ title: "plain" }); + await baseStore.nodes.Podcast.create({ title: "cast", rssUrl: "u" }); + return baseStore; + } + + async function mediaKinds( + store: Store, + ): Promise { + const kinds = await store + .query() + .from("Media", "media") + .select((ctx) => ctx.media.kind) + .execute(); + return kinds.toSorted(); + } + + it("answers a supertype query on the clone as the base store does", async () => { + const baseStore = await seedCatalog(); + const created = unwrap(await branch(baseStore, () => makeBackend())); + + const baseKinds = await mediaKinds(baseStore); + const cloneKinds = await mediaKinds(created.store); + console.log("supertype query kinds", { baseKinds, cloneKinds }); + + expect(baseKinds).toEqual(["Media"]); + expect(cloneKinds).toEqual(baseKinds); + expect(created.store.workingCopyOptions.queryDefaults).toEqual( + QUERY_DEFAULTS, + ); + }); + + it("carries them onto an ingestion working copy", async () => { + const baseStore = await seedCatalog(); + const created = unwrap( + await branch( + baseStore, + () => Promise.reject(new Error("default factory must not run")), + undefined, + cloneIngestionWorkingCopyStrategy(() => makeBackend()), + ), + ); + + expect(await mediaKinds(created.store)).toEqual(["Media"]); + expect(created.store.workingCopyOptions.queryDefaults).toEqual( + QUERY_DEFAULTS, + ); + }); + + it("leaves the clone on the library defaults when the base states none", async () => { + const { baseStore } = await seedBase(); + const created = unwrap(await branch(baseStore, () => makeBackend())); + + expect(created.store.workingCopyOptions).not.toHaveProperty( + "queryDefaults", + ); + }); + + it("does not carry the base's hooks or upsert coalescing", async () => { + const [baseStore] = await createStoreWithSchema( + graph, + await makeBackend(), + { coalesceUnchangedUpserts: true, hooks: {} }, + ); + const created = unwrap(await branch(baseStore, () => makeBackend())); + + expect(created.store.workingCopyOptions).toEqual({}); + }); + }); + it("accepts an explicit working-copy strategy override", async () => { const { baseStore, aliceId } = await seedBase(); const strategy = cloneWorkingCopyStrategy(() => makeBackend()); diff --git a/packages/typegraph/tests/graph-merge/candidate-planning-scope.test.ts b/packages/typegraph/tests/graph-merge/candidate-planning-scope.test.ts new file mode 100644 index 000000000..411c08898 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/candidate-planning-scope.test.ts @@ -0,0 +1,573 @@ +/** + * The candidate planning scope decision: one case per reason a candidate is + * planned against the whole target, plus the candidate scope itself. + * + * Each case pins three behaviors of `planCandidateWriteSet`: the default call + * still plans (or fails) exactly as the whole-target path does and reports the + * decided scope beside the plan; `planningScope: "candidate"` refuses with + * `CandidatePlanningScopeError` naming the same reason before any staging + * backend is created; and the static decision agrees with + * `candidatePlanningScope` for the causes it can see without the candidate. + * The cross-row causes (composition, acyclic, target-side cardinality) are + * also pinned on every backend in the shared review suite. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { projectBackendWithout } from "../../src/backend/derive-backend"; +import type { MergeError } from "../../src/graph-merge"; +import { + type CandidatePlanningScope, + CandidatePlanningScopeError, + type CandidateWriteSet, + CandidateWriteSetError, + type CandidateWriteSetPlan, + captureCandidateWriteSetTarget, + InvalidMergeOptionsError, + MergePlanCapabilityError, + planCandidateWriteSet, + planCandidateWriteSetReview, + revalidateCandidateWriteSetReview, +} from "../../src/graph-merge"; +import { canonicalMergePlanJson } from "../../src/graph-merge/plan-canonical"; +import { isErr, type Result } from "../../src/graph-merge/result"; +import { candidatePlanningScope } from "../../src/graph-merge/sparse-candidate-branch"; +import type { GraphDef } from "../../src/graph-merge/typegraph-internal"; +import type { MakeBackend } from "../../src/graph-merge/working-copy"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { requireDefined } from "../../src/utils/presence"; +import { createSqliteMergeBackend } from "./test-utils"; + +const validFrom = "2026-01-01T00:00:00.000Z"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); +const link = defineEdge("link", { schema: z.object({}) }); +const matched = defineEdge("matched", { + schema: z.object({ code: z.string() }), +}); + +const plainGraph = defineGraph({ + id: "scope_plain", + nodes: { Person: { type: Person } }, + edges: {}, +}); +const compositionGraph = defineGraph({ + id: "scope_composition", + nodes: { Person: { type: Person } }, + edges: { + link: { type: link, from: [Person], to: [Person], cardinality: "one" }, + }, + ontology: [partOf(Person, Person, { via: link, partSide: "from" })], +}); +const acyclicGraph = defineGraph({ + id: "scope_acyclic", + nodes: { Person: { type: Person } }, + edges: { + link: { type: link, from: [Person], to: [Person], acyclic: true }, + }, +}); +const targetCardinalityGraph = defineGraph({ + id: "scope_target_cardinality", + nodes: { Person: { type: Person } }, + edges: { + link: { + type: link, + from: [Person], + to: [Person], + targetCardinality: "one", + }, + }, +}); +const oneActiveGraph = defineGraph({ + id: "scope_one_active", + nodes: { Person: { type: Person } }, + edges: { + link: { + type: link, + from: [Person], + to: [Person], + cardinality: "oneActive", + }, + }, +}); +const matchIdentityGraph = defineGraph({ + id: "scope_match_identity", + nodes: { Person: { type: Person } }, + edges: { + matched: { + type: matched, + from: [Person], + to: [Person], + matchIdentity: { name: "code", fields: ["code"] }, + }, + }, +}); +const identityGraph = defineGraph({ + id: "scope_identity", + identity: { sameIdAcrossKinds: "fold" }, + nodes: { Person: { type: Person } }, + edges: {}, +}); + +type PlanArgs = Readonly<{ + makeBackend: MakeBackend; + planningScope?: "candidate"; +}>; + +/** A target and candidate, closed over so cases with different graphs share one table. */ +type Arranged = Readonly<{ + staticScope: () => CandidatePlanningScope; + plan: (args: PlanArgs) => Promise>; +}>; + +async function arranged( + target: Store, + candidate: Omit, +): Promise { + const writeSet: CandidateWriteSet = { + formatVersion: 1, + sourceId: "scope-source", + target: await captureCandidateWriteSetTarget(target), + ...candidate, + }; + return { + staticScope: () => candidatePlanningScope(target), + plan: (args) => planCandidateWriteSet({ target, writeSet, ...args }), + }; +} + +const PERSON_CANDIDATE = { + nodes: [ + { + kind: "Person", + id: "candidate", + properties: { name: "Candidate" }, + validFrom, + }, + ], + edges: [], +} as const satisfies Omit< + CandidateWriteSet, + "formatVersion" | "sourceId" | "target" +>; + +function matchedCandidate(code: string | number) { + return { + nodes: [], + edges: [ + { + kind: "matched", + id: "candidate-edge", + from: { kind: "Person", id: "source" }, + to: { kind: "Person", id: "peer" }, + properties: { code }, + validFrom, + }, + ], + }; +} + +async function matchIdentityTarget(backend: GraphBackend) { + const [target] = await createStoreWithSchema(matchIdentityGraph, backend, { + revisionTracking: true, + }); + const source = await target.nodes.Person.create( + { name: "Source" }, + { id: "source" }, + ); + const peer = await target.nodes.Person.create( + { name: "Peer" }, + { id: "peer" }, + ); + return { target, source, peer }; +} + +type ScopeCase = Readonly<{ + name: string; + expected: CandidatePlanningScope; + /** Whether the static decision already names `expected`, before the candidate is read. */ + decidedStatically: boolean; + arrange: (backend: GraphBackend) => Promise; +}>; + +async function revisionTrackedTarget( + graph: G, + backend: GraphBackend, +): Promise> { + const [target] = await createStoreWithSchema(graph, backend, { + revisionTracking: true, + }); + return target; +} + +const SCOPE_CASES: readonly ScopeCase[] = [ + { + name: "a revision-tracked graph without cross-row constraints", + expected: { scope: "candidate" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget(plainGraph, backend), + PERSON_CANDIDATE, + ), + }, + { + name: "a composition pair", + expected: { scope: "whole-target", reason: "composition" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget(compositionGraph, backend), + PERSON_CANDIDATE, + ), + }, + { + name: "an acyclic edge", + expected: { scope: "whole-target", reason: "acyclic-relation" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget(acyclicGraph, backend), + PERSON_CANDIDATE, + ), + }, + { + name: "a target-side cardinality", + expected: { scope: "whole-target", reason: "target-side-cardinality" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget(targetCardinalityGraph, backend), + PERSON_CANDIDATE, + ), + }, + { + name: "a oneActive edge on a backend without the active source read", + expected: { scope: "whole-target", reason: "missing-active-source-read" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget( + oneActiveGraph, + projectBackendWithout(backend, ["findActiveEdgesBySourceV1"]), + ), + PERSON_CANDIDATE, + ), + }, + { + name: "a match-identity edge on a backend without the keyed owner read", + expected: { scope: "whole-target", reason: "missing-match-identity-read" }, + decidedStatically: true, + arrange: async (backend) => + arranged( + await revisionTrackedTarget( + matchIdentityGraph, + projectBackendWithout(backend, ["findEdgesByMatchIdentity"]), + ), + PERSON_CANDIDATE, + ), + }, + { + name: "an identity graph whose runtime lacks the scoped identity reads", + expected: { + scope: "whole-target", + reason: "missing-scoped-identity-reads", + }, + decidedStatically: true, + arrange: async (backend) => { + const target = await revisionTrackedTarget(identityGraph, backend); + const runtime = storeRuntime(target); + Reflect.deleteProperty(runtime, "identityAssertionsTouchingAtTarget"); + Reflect.deleteProperty( + runtime, + "interchangeIdentityAssertionsByIdsAtTarget", + ); + return arranged(target, PERSON_CANDIDATE); + }, + }, + { + name: "a match-identity owner excluded from the clone projection", + expected: { + scope: "whole-target", + reason: "match-identity-owner-not-clone-visible", + }, + decidedStatically: false, + arrange: async (backend) => { + const { target, source, peer } = await matchIdentityTarget(backend); + const deletedOwner = await target.edges.matched.create( + source, + peer, + { code: "reusable" }, + { id: "deleted-owner" }, + ); + await target.edges.matched.delete(deletedOwner.id); + return arranged(target, matchedCandidate("reusable")); + }, + }, +]; + +const weightedLink = defineEdge("link", { + schema: z.object({ weight: z.number() }), +}); +const weightedGraph = defineGraph({ + id: "scope_weighted", + nodes: { Person: { type: Person } }, + edges: { link: { type: weightedLink, from: [Person], to: [Person] } }, +}); + +type MalformedCase = Readonly<{ + name: string; + /** A fragment of the import's own validation error. */ + expectedError: string; + arrange: (backend: GraphBackend) => Promise; +}>; + +function describeRefusal(error: MergeError): string { + const cause = error.cause instanceof Error ? error.cause.message : ""; + return `${error.message} ${JSON.stringify(error.details)} ${cause}`; +} + +/** Candidates the target's schema refuses; none of them is a planning-scope cause. */ +const MALFORMED_CASES: readonly MalformedCase[] = [ + { + name: "an edge with invalid properties on a graph without match identity", + expectedError: "weight: Invalid input: expected number, received string", + arrange: async (backend) => { + const target = await revisionTrackedTarget(weightedGraph, backend); + await target.nodes.Person.create({ name: "Source" }, { id: "source" }); + await target.nodes.Person.create({ name: "Peer" }, { id: "peer" }); + return arranged(target, { + nodes: [], + edges: [ + { + kind: "link", + id: "candidate-edge", + from: { kind: "Person", id: "source" }, + to: { kind: "Person", id: "peer" }, + properties: { weight: "heavy" }, + validFrom, + }, + ], + }); + }, + }, + { + name: "a match-identity edge whose owner key cannot be derived", + expectedError: "code: Invalid input: expected string, received number", + arrange: async (backend) => { + const { target } = await matchIdentityTarget(backend); + return arranged(target, matchedCandidate(42)); + }, + }, + { + name: "a match-identity edge whose owner key exceeds the storage limit", + expectedError: "exceeds the portable storage limit", + arrange: async (backend) => { + const { target } = await matchIdentityTarget(backend); + return arranged(target, matchedCandidate("x".repeat(3000))); + }, + }, + { + name: "an edge of a kind the graph does not declare", + expectedError: "Unknown edge kind: undeclared", + arrange: async (backend) => { + const { target } = await matchIdentityTarget(backend); + const candidate = matchedCandidate("code"); + return arranged(target, { + ...candidate, + edges: candidate.edges.map((edge) => ({ ...edge, kind: "undeclared" })), + }); + }, + }, +]; + +describe("candidate planning scope", () => { + let cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups) await cleanup(); + cleanups = []; + }); + + function sqliteBackend(): GraphBackend { + const fixture = createSqliteMergeBackend(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + function stagingBackend() { + return vi.fn(() => Promise.resolve(sqliteBackend())); + } + + describe.each(SCOPE_CASES)("$name", (testCase) => { + it("reports the decided scope beside the default plan", async () => { + const subject = await testCase.arrange(sqliteBackend()); + expect(subject.staticScope()).toEqual( + testCase.decidedStatically ? testCase.expected : { scope: "candidate" }, + ); + + const planned = await subject.plan({ makeBackend: stagingBackend() }); + if (isErr(planned)) throw planned.error; + expect(planned.data.planningScope).toEqual(testCase.expected); + }); + + it("applies or refuses an explicit candidate planning scope", async () => { + const subject = await testCase.arrange(sqliteBackend()); + const makeBackend = stagingBackend(); + + const scoped = await subject.plan({ + makeBackend, + planningScope: "candidate", + }); + + if (testCase.expected.scope === "candidate") { + if (isErr(scoped)) throw scoped.error; + const unscoped = await subject.plan({ makeBackend: stagingBackend() }); + if (isErr(unscoped)) throw unscoped.error; + expect(scoped.data.planningScope).toEqual(testCase.expected); + expect(canonicalMergePlanJson(scoped.data.plan)).toBe( + canonicalMergePlanJson(unscoped.data.plan), + ); + return; + } + if (!isErr(scoped)) { + throw new Error( + `expected a planning scope refusal, planned with ${JSON.stringify(scoped.data.planningScope)}`, + ); + } + expect(scoped.error).toBeInstanceOf(CandidatePlanningScopeError); + expect(scoped.error.code).toBe("GRAPH_MERGE_CANDIDATE_PLANNING_SCOPE"); + expect(scoped.error.details).toEqual({ + planningScope: testCase.expected, + }); + expect(makeBackend).not.toHaveBeenCalled(); + }); + }); + + it("forwards an explicit candidate planning scope through both review entry points", async () => { + const target = await revisionTrackedTarget(acyclicGraph, sqliteBackend()); + const writeSet: CandidateWriteSet = { + formatVersion: 1, + sourceId: "scope-source", + target: await captureCandidateWriteSetTarget(target), + ...PERSON_CANDIDATE, + }; + const policy = { id: "scope-review", context: {} }; + const expected = { + planningScope: { scope: "whole-target", reason: "acyclic-relation" }, + }; + + const reviewBackend = stagingBackend(); + const refusedReview = await planCandidateWriteSetReview({ + target, + writeSet, + makeBackend: reviewBackend, + policy, + planningScope: "candidate", + }); + if (!isErr(refusedReview)) throw new Error("expected a review refusal"); + expect(refusedReview.error).toBeInstanceOf(CandidatePlanningScopeError); + expect(refusedReview.error.details).toEqual(expected); + expect(reviewBackend).not.toHaveBeenCalled(); + + const review = await planCandidateWriteSetReview({ + target, + writeSet, + makeBackend: stagingBackend(), + policy, + }); + if (isErr(review)) throw review.error; + const revalidateBackend = stagingBackend(); + const refusedRevalidation = await revalidateCandidateWriteSetReview({ + target, + review: review.data, + makeBackend: revalidateBackend, + policy, + planningScope: "candidate", + }); + if (!isErr(refusedRevalidation)) + throw new Error("expected a revalidation refusal"); + expect(refusedRevalidation.error).toBeInstanceOf( + CandidatePlanningScopeError, + ); + expect(refusedRevalidation.error.details).toEqual(expected); + expect(revalidateBackend).not.toHaveBeenCalled(); + }); + + it("refuses a target without revision tracking as a plan capability gap before staging", async () => { + const [target] = await createStoreWithSchema(plainGraph, sqliteBackend(), { + revisionTracking: false, + }); + const subject = await arranged(target, PERSON_CANDIDATE); + for (const planningScope of [undefined, "candidate"] as const) { + const makeBackend = stagingBackend(); + const refused = await subject.plan({ + makeBackend, + ...(planningScope === undefined ? {} : { planningScope }), + }); + if (!isErr(refused)) + throw new Error(`expected a capability refusal (${planningScope})`); + expect(refused.error).toBeInstanceOf(MergePlanCapabilityError); + expect(refused.error.details).toEqual({ capability: "revisionTracking" }); + expect(makeBackend).not.toHaveBeenCalled(); + } + }); + + describe.each(MALFORMED_CASES)("$name", (testCase) => { + it("reports the candidate's own validation error with or without the candidate scope", async () => { + const subject = await testCase.arrange(sqliteBackend()); + expect(subject.staticScope()).toEqual({ scope: "candidate" }); + + const unscoped = await subject.plan({ makeBackend: stagingBackend() }); + const scoped = await subject.plan({ + makeBackend: stagingBackend(), + planningScope: "candidate", + }); + + if (!isErr(unscoped) || !isErr(scoped)) + throw new Error("expected both calls to refuse the candidate"); + expect(scoped.error).toBeInstanceOf(CandidateWriteSetError); + expect(unscoped.error).toBeInstanceOf(CandidateWriteSetError); + expect(describeRefusal(scoped.error)).toBe( + describeRefusal(unscoped.error), + ); + expect(describeRefusal(scoped.error)).toContain(testCase.expectedError); + }); + }); + + it("hands each caller a planning scope that cannot change later decisions", async () => { + const target = await revisionTrackedTarget(plainGraph, sqliteBackend()); + const subject = await arranged(target, PERSON_CANDIDATE); + const planned = await subject.plan({ makeBackend: stagingBackend() }); + if (isErr(planned)) throw planned.error; + + const reported = planned.data.planningScope as { scope: string }; + expect(() => { + reported.scope = "whole-target"; + }).toThrow(TypeError); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); + }); + + it("refuses an unsupported planning scope value", async () => { + const subject = await requireDefined(SCOPE_CASES[0]).arrange( + sqliteBackend(), + ); + const makeBackend = stagingBackend(); + const refused = await subject.plan({ + makeBackend, + planningScope: "whole-target" as "candidate", + }); + if (!isErr(refused)) throw new Error("expected an options refusal"); + expect(refused.error).toBeInstanceOf(InvalidMergeOptionsError); + expect(refused.error.details).toEqual({ planningScope: "whole-target" }); + expect(makeBackend).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/candidate-review-v2.test.ts b/packages/typegraph/tests/graph-merge/candidate-review-v2.test.ts index 596c2c875..9102ce1bf 100644 --- a/packages/typegraph/tests/graph-merge/candidate-review-v2.test.ts +++ b/packages/typegraph/tests/graph-merge/candidate-review-v2.test.ts @@ -8,7 +8,10 @@ import { defineNode, } from "../../src"; import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; -import { MERGE_REVIEW_FORMAT_VERSION } from "../../src/graph-merge"; +import { + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, +} from "../../src/graph-merge"; import { planCandidateWriteSetReview, revalidateCandidateWriteSetReview, @@ -55,7 +58,7 @@ async function setup() { return store; } -describe("candidate-scoped V2 review baseline", () => { +describe("candidate-scoped review baseline", () => { it("includes future-start open assertions with the interchange state predicate", async () => { const { backend } = createLocalSqliteBackend(); cleanups.push(() => backend.close()); @@ -98,7 +101,7 @@ describe("candidate-scoped V2 review baseline", () => { expect(after.identityDigest).not.toBe(before.identityDigest); }); - it("captures format V2 and requires renewed review after connected identity changes", async () => { + it("captures the candidate-scoped format and requires renewed review after connected identity changes", async () => { const store = await setup(); const seed = await store.nodes.Person.create( { name: "seed" }, @@ -144,7 +147,9 @@ describe("candidate-scoped V2 review baseline", () => { reviewScope: "candidate" as const, }; const review = unwrap(await planCandidateWriteSetReview(args)); - expect(review.formatVersion).toBe(2); + expect(review.formatVersion).toBe( + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ); const defaultReview = unwrap( await planCandidateWriteSetReview({ target: store, @@ -176,7 +181,7 @@ describe("candidate-scoped V2 review baseline", () => { }); }); - it("keeps candidate-scoped V2 available for identity-disabled graphs", async () => { + it("keeps candidate-scoped review available for identity-disabled graphs", async () => { const { backend } = createLocalSqliteBackend(); cleanups.push(() => backend.close()); const plainGraph = defineGraph({ @@ -215,7 +220,9 @@ describe("candidate-scoped V2 review baseline", () => { reviewScope: "candidate", }), ); - expect(review.formatVersion).toBe(2); + expect(review.formatVersion).toBe( + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ); expect(review.baseline.scope).toBe("referenced"); const { digest: _digest, ...content } = review; const legacyContent = { @@ -244,7 +251,7 @@ describe("candidate-scoped V2 review baseline", () => { expect(revalidated.status).toBe("compatible"); }); - it("refuses a V2 review when identity changes after its starting fence", async () => { + it("refuses a candidate-scoped review when identity changes after its starting fence", async () => { const store = await setup(); const seed = await store.nodes.Person.create( { name: "seed" }, diff --git a/packages/typegraph/tests/graph-merge/candidate-review.test.ts b/packages/typegraph/tests/graph-merge/candidate-review.test.ts index 614839dcb..638d4f81d 100644 --- a/packages/typegraph/tests/graph-merge/candidate-review.test.ts +++ b/packages/typegraph/tests/graph-merge/candidate-review.test.ts @@ -7,6 +7,8 @@ import { asBranchId, captureCandidateWriteSetTarget, isErr, + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, MergePlanningStaleError, MergeReviewError, planCandidateWriteSetReview, @@ -18,7 +20,11 @@ import { reviewDigest, reviewOptionEvidence, } from "../../src/graph-merge/review-evidence"; -import type { MergeReviewArtifact } from "../../src/graph-merge/review-schema"; +import { + isSupportedMergeReviewFormatVersion, + type MergeReviewArtifact, + SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS, +} from "../../src/graph-merge/review-schema"; import { storeBackend } from "../../src/store/runtime-port"; const Item = defineNode("Item", { schema: z.object({ name: z.string() }) }); @@ -137,6 +143,46 @@ describe("candidate review wire and coherent capture", () => { }, ); + it("supports exactly the whole-target and candidate-scoped formats", () => { + expect(MERGE_REVIEW_FORMAT_VERSION).toBe(3); + expect(MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED).toBe(4); + expect(SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS).toEqual([3, 4]); + for (const earlier of [1, 2]) + expect(isSupportedMergeReviewFormatVersion(earlier)).toBe(false); + for (const supported of SUPPORTED_MERGE_REVIEW_FORMAT_VERSIONS) + expect(isSupportedMergeReviewFormatVersion(supported)).toBe(true); + }); + + it.each([1, 2])( + "refuses a review stored under earlier format %d as an unsupported version, not as malformed", + async (earlierFormat) => { + const { args } = await setup(); + const review = unwrap(await planCandidateWriteSetReview(args)); + expect(review.formatVersion).toBe(MERGE_REVIEW_FORMAT_VERSION); + const stored: unknown = { ...review, formatVersion: earlierFormat }; + const makeBackend = vi.fn(args.makeBackend); + + const result = await revalidateCandidateWriteSetReview({ + ...args, + makeBackend, + review: stored, + }); + + if (!isErr(result)) + throw new Error("Expected an unsupported-version refusal."); + expect(result.error).toBeInstanceOf(MergeReviewError); + expect(result.error.details).toEqual({ + reason: "unsupported-version", + received: earlierFormat, + supported: [ + MERGE_REVIEW_FORMAT_VERSION, + MERGE_REVIEW_FORMAT_VERSION_CANDIDATE_SCOPED, + ], + }); + expect(makeBackend).not.toHaveBeenCalled(); + }, + ); + it("refuses a mixed-revision baseline even when the inner planner sees a stable newer target", async () => { const { args, backend } = await setup(); const findNodes = backend.findNodesAcrossKinds; diff --git a/packages/typegraph/tests/graph-merge/candidate-write-set.test.ts b/packages/typegraph/tests/graph-merge/candidate-write-set.test.ts index 1563477a4..9d1211268 100644 --- a/packages/typegraph/tests/graph-merge/candidate-write-set.test.ts +++ b/packages/typegraph/tests/graph-merge/candidate-write-set.test.ts @@ -33,7 +33,7 @@ import { captureMergePlanTargetFence } from "../../src/graph-merge/merge"; import { canonicalMergePlanJson } from "../../src/graph-merge/plan-canonical"; import { isErr, unwrap } from "../../src/graph-merge/result"; import { - canUseSparseCandidatePlanning, + candidatePlanningScope, matchIdentityOwnersAreCloneVisible, readCandidateMatchIdentityOwners, } from "../../src/graph-merge/sparse-candidate-branch"; @@ -385,7 +385,7 @@ describe("candidate write-set planning", () => { const [target] = await createStoreWithSchema(oneActiveGraph, baseBackend, { revisionTracking: true, }); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); const writeSet: CandidateWriteSet = { formatVersion: 1, sourceId: "one-active-capability", @@ -416,7 +416,10 @@ describe("candidate write-set planning", () => { expect(descriptor).toBeDefined(); Reflect.deleteProperty(baseBackend, "findActiveEdgesBySourceV1"); try { - expect(canUseSparseCandidatePlanning(target)).toBe(false); + expect(candidatePlanningScope(target)).toEqual({ + scope: "whole-target", + reason: "missing-active-source-read", + }); unwrap( await planCandidateWriteSet({ target, @@ -624,7 +627,7 @@ describe("candidate write-set planning", () => { makeBackend: candidateBackend().makeBackend, writeSet, }), - ); + ).plan; expect(canonicalMergePlanJson(actual)).toBe( canonicalMergePlanJson(expected), ); @@ -736,7 +739,7 @@ describe("candidate write-set planning", () => { makeBackend: candidateBackend().makeBackend, writeSet, }), - ); + ).plan; expect(canonicalMergePlanJson(actual)).toBe( canonicalMergePlanJson(expected), ); @@ -828,7 +831,7 @@ describe("candidate write-set planning", () => { makeBackend: candidateBackend().makeBackend, writeSet, }), - ); + ).plan; expect(canonicalMergePlanJson(actual)).toBe( canonicalMergePlanJson(expected), ); @@ -1163,7 +1166,7 @@ describe("candidate write-set planning", () => { makeBackend: candidateBackend().makeBackend, writeSet, }); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); expect(isErr(bounded)).toBe(true); }); @@ -1231,7 +1234,7 @@ describe("candidate write-set planning", () => { { id: `other-${index}` }, ); const after = await measure(); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); expect(after.statements).toBeLessThanOrEqual(before.statements); expect(after.returnedRows).toBe(before.returnedRows); expect(after.targetWrites).toBe(0); @@ -1379,7 +1382,10 @@ describe("candidate write-set planning", () => { }, ], }; - expect(canUseSparseCandidatePlanning(target)).toBe(false); + expect(candidatePlanningScope(target)).toEqual({ + scope: "whole-target", + reason: "missing-match-identity-read", + }); expect( await fullCloneImportSucceeded( target, @@ -1484,7 +1490,7 @@ describe("candidate write-set planning", () => { ); } const after = await measure(); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); expect(after.statements).toBeLessThanOrEqual(before.statements); expect(after.returnedRows).toBe(before.returnedRows); expect(after.revisionUnchanged).toBe(true); @@ -1534,21 +1540,25 @@ describe("candidate write-set planning", () => { "full-match-identity-tombstone", ), ).toBe(true); - const owners = await readCandidateMatchIdentityOwners(target, writeSet); - expect(owners).toHaveLength(1); - expect(owners?.[0]?.deleted_at).toBeDefined(); - if (owners === undefined) throw new Error("Expected the exact owner read"); - expect(matchIdentityOwnersAreCloneVisible(owners)).toBe(false); + const ownerRead = await readCandidateMatchIdentityOwners(target, writeSet); + expect(ownerRead.status).toBe("complete"); + expect(ownerRead.owners).toHaveLength(1); + expect(ownerRead.owners[0]?.deleted_at).toBeDefined(); + expect(matchIdentityOwnersAreCloneVisible(ownerRead.owners)).toBe(false); const bounded = await planCandidateWriteSet({ target, makeBackend: candidateBackend().makeBackend, writeSet, }); - expect(canUseSparseCandidatePlanning(target)).toBe(true); + expect(candidatePlanningScope(target)).toEqual({ scope: "candidate" }); // The capability sees the durable tombstone, so planning must use the // same state projection as the full clone rather than seed the tombstone. - expect(isErr(bounded)).toBe(false); + if (isErr(bounded)) throw bounded.error; + expect(bounded.data.planningScope).toEqual({ + scope: "whole-target", + reason: "match-identity-owner-not-clone-visible", + }); }); it("returns a deterministic serialized property-conflict plan with source attribution", async () => { @@ -1563,7 +1573,7 @@ describe("candidate write-set planning", () => { writeSet: JSON.parse(JSON.stringify(writeSet)) as unknown, options, }), - ); + ).plan; const second = unwrap( await planCandidateWriteSet({ target, @@ -1571,7 +1581,7 @@ describe("candidate write-set planning", () => { writeSet: structuredClone(writeSet), options, }), - ); + ).plan; expect(canonicalMergePlanJson(first)).toBe(canonicalMergePlanJson(second)); expect(first.review.conflicts).toEqual([ @@ -1642,7 +1652,7 @@ describe("candidate write-set planning", () => { writeSet: updated, options, }), - ); + ).plan; const fullBranch = unwrap( await ingestionBranch(target, candidateBackend().makeBackend, { id: asBranchId(updated.sourceId), @@ -1958,7 +1968,7 @@ describe("candidate write-set planning", () => { writeSet, options, }), - ); + ).plan; expect( planned.writes.nodeDeletes.some((entry) => entry.id === "legacy-extra"), ).toBe(false); diff --git a/packages/typegraph/tests/graph-merge/cascade-retraction.test.ts b/packages/typegraph/tests/graph-merge/cascade-retraction.test.ts index 08eefb686..622b85a55 100644 --- a/packages/typegraph/tests/graph-merge/cascade-retraction.test.ts +++ b/packages/typegraph/tests/graph-merge/cascade-retraction.test.ts @@ -74,6 +74,20 @@ async function atOneInstant(body: () => Promise): Promise { } } +/** Runs `body` with the app clock pinned to `instant`. Only `Date` is faked. */ +async function atInstant( + instant: string, + body: () => Promise, +): Promise { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(instant)); + try { + await body(); + } finally { + vi.useRealTimers(); + } +} + const BRANCH_A = asBranchId("branch-a"); const BRANCH_B = asBranchId("branch-b"); const BRANCH_C = asBranchId("branch-c"); @@ -375,6 +389,75 @@ describe.each(backendMatrix())("cascade retraction [$name]", (entry) => { ); }); + it("ends a surviving retraction at the explicit instant, not an overruled cascade's", async () => { + // Branch A deletes b early, cascading an ending onto x-id. Branch B + // retracts x-id explicitly, later. Branch C modifies b, so A's deletion is + // overruled and b stays live. The retraction survives on B's intent + // alone, so the instant it commits must be B's: ending the assertion at + // A's instant would let a rejected deletion decide when the pair stopped + // being asserted. + const cascadeInstant = "2021-01-01T00:00:00.000Z"; + const explicitInstant = "2022-06-01T00:00:00.000Z"; + const [forkPoint] = await createStoreWithSchema( + anchoredGraph, + await makeBackend(), + ); + for (const id of ["a", "b"]) { + await forkPoint.nodes.Anchor.create( + { name: id }, + { id, validFrom: "2019-01-01T00:00:00.000Z" }, + ); + } + const imported = await importGraph( + forkPoint, + identityAssertionDocument( + "Anchor", + "x-id", + "a", + "b", + "2020-01-01T00:00:00.000Z", + ), + { onConflict: "skip" }, + ); + expect(imported.success).toBe(true); + const deleteBranch = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await atInstant(cascadeInstant, async () => { + await deleteBranch.store.nodes.Anchor.delete("b" as never); + }); + const retractBranch = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_B }), + ); + await atInstant(explicitInstant, async () => { + await retractBranch.store.identity.retractAssertion( + asIdentityAssertionId("x-id"), + ); + }); + const modifyBranch = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_C }), + ); + await modifyBranch.store.nodes.Anchor.update("b" as never, { + name: "b-modified", + }); + + const result = await merge( + forkPoint, + [deleteBranch, retractBranch, modifyBranch], + { branchOrder: [BRANCH_A, BRANCH_B, BRANCH_C] }, + ); + if (isErr(result)) throw result.error; + const live = await forkPoint.nodes.Anchor.getById("b" as never); + expect(live?.name).toBe("b-modified"); + const rows = await storeRuntime(forkPoint).identityAssertionRowsByIds([ + "x-id", + ]); + const stored = requireDefined(rows.get("x-id")); + console.info("committed retraction", stored); + expect(stored.validTo).toBe(explicitInstant); + expect(stored.endedBy).toBeUndefined(); + }); + it("drops a hard-delete cascade when the deletion is overruled", async () => { // The one undecidable case left: a hard delete leaves nothing to // separate cascade from intent, so the retraction is classified as the diff --git a/packages/typegraph/tests/graph-merge/closures.test.ts b/packages/typegraph/tests/graph-merge/closures.test.ts deleted file mode 100644 index 7a4906d6e..000000000 --- a/packages/typegraph/tests/graph-merge/closures.test.ts +++ /dev/null @@ -1,309 +0,0 @@ -import type { OntologyIntrospection } from "@nicia-ai/typegraph"; -import { - createStoreWithSchema, - defineGraph, - defineNode, - equivalentTo, - sameAs, - subClassOf, -} from "@nicia-ai/typegraph"; -import { describe, expect, it } from "vitest"; -import { z } from "zod"; - -import { - buildSubClassClosure, - isReachable, - SUB_CLASS_OF_META_EDGE, -} from "../../src/graph-merge/closures"; -import { requireDefined } from "../../src/utils/presence"; -import { createSqliteMergeBackend } from "./test-utils"; - -const Person = defineNode("Person", { - schema: z.object({ name: z.string() }), -}); -const Doctor = defineNode("Doctor", { - schema: z.object({ name: z.string() }), -}); -const SpecialistDoctor = defineNode("SpecialistDoctor", { - schema: z.object({ name: z.string() }), -}); -const Physician = defineNode("Physician", { - schema: z.object({ name: z.string() }), -}); -const Animal = defineNode("Animal", { - schema: z.object({ name: z.string() }), -}); - -/** - * Builds a real store from `graph`, then projects its public - * `store.introspect().ontology` into a closure — exercising the genuine public - * surface (introspection + `computeTransitiveClosure`) end to end rather than a - * hand-rolled ontology array. - */ -async function closureFromGraph( - graph: Parameters[0], -): Promise> { - const fixture = createSqliteMergeBackend(); - try { - const [store] = await createStoreWithSchema(graph, fixture.backend); - return buildSubClassClosure(store.introspect().ontology); - } finally { - await fixture.cleanup(); - } -} - -const taxonomyGraph = defineGraph({ - id: "closures-taxonomy", - nodes: { - Person: { type: Person }, - Doctor: { type: Doctor }, - SpecialistDoctor: { type: SpecialistDoctor }, - Animal: { type: Animal }, - }, - edges: {}, - ontology: [subClassOf(SpecialistDoctor, Doctor), subClassOf(Doctor, Person)], -}); - -describe("buildSubClassClosure over public introspection", () => { - it("exposes the introspected subClassOf relations", async () => { - const fixture = createSqliteMergeBackend(); - try { - const [store] = await createStoreWithSchema( - taxonomyGraph, - fixture.backend, - ); - const ontology = store.introspect().ontology; - const subClassPairs = ontology - .filter((entry) => entry.metaEdge === SUB_CLASS_OF_META_EDGE) - .map((entry) => [entry.from, entry.to] as const); - expect(subClassPairs).toContainEqual(["SpecialistDoctor", "Doctor"]); - expect(subClassPairs).toContainEqual(["Doctor", "Person"]); - } finally { - await fixture.cleanup(); - } - }); - - it("is reachable transitively (SpecialistDoctor -> Person)", async () => { - const closure = await closureFromGraph(taxonomyGraph); - expect(isReachable(closure, "SpecialistDoctor", "Doctor")).toBe(true); - expect(isReachable(closure, "Doctor", "Person")).toBe(true); - expect(isReachable(closure, "SpecialistDoctor", "Person")).toBe(true); - }); - - it("is not reachable in the reverse (superclass -> subclass) direction", async () => { - const closure = await closureFromGraph(taxonomyGraph); - expect(isReachable(closure, "Person", "SpecialistDoctor")).toBe(false); - expect(isReachable(closure, "Person", "Doctor")).toBe(false); - expect(isReachable(closure, "Doctor", "SpecialistDoctor")).toBe(false); - }); - - it("treats the subclass relation as strict (irreflexive)", async () => { - const closure = await closureFromGraph(taxonomyGraph); - expect(isReachable(closure, "Doctor", "Doctor")).toBe(false); - expect(isReachable(closure, "SpecialistDoctor", "SpecialistDoctor")).toBe( - false, - ); - }); - - it("reports unrelated types as mutually unreachable", async () => { - const graph = defineGraph({ - id: "closures-disjoint", - nodes: { - Person: { type: Person }, - Doctor: { type: Doctor }, - Animal: { type: Animal }, - }, - edges: {}, - ontology: [subClassOf(Doctor, Person)], - }); - const closure = await closureFromGraph(graph); - expect(isReachable(closure, "Animal", "Person")).toBe(false); - expect(isReachable(closure, "Person", "Animal")).toBe(false); - expect(isReachable(closure, "Doctor", "Animal")).toBe(false); - expect(isReachable(closure, "Animal", "Doctor")).toBe(false); - }); - - it("returns false for types absent from the ontology", async () => { - const closure = await closureFromGraph(taxonomyGraph); - expect(isReachable(closure, "Nurse", "Person")).toBe(false); - expect(isReachable(closure, "Person", "Nurse")).toBe(false); - }); -}); - -describe("closure is independent of input pair ordering", () => { - const forward: readonly OntologyIntrospection[] = [ - { - metaEdge: SUB_CLASS_OF_META_EDGE, - from: "SpecialistDoctor", - to: "Doctor", - origin: "compile-time", - }, - { - metaEdge: SUB_CLASS_OF_META_EDGE, - from: "Doctor", - to: "Person", - origin: "compile-time", - }, - ]; - const reversed: readonly OntologyIntrospection[] = [...forward].reverse(); - - it("yields identical reachability regardless of relation order", () => { - const a = buildSubClassClosure(forward); - const b = buildSubClassClosure(reversed); - expect(isReachable(a, "SpecialistDoctor", "Person")).toBe(true); - expect(isReachable(b, "SpecialistDoctor", "Person")).toBe(true); - expect(isReachable(a, "Person", "SpecialistDoctor")).toBe( - isReachable(b, "Person", "SpecialistDoctor"), - ); - }); - - it("produces byte-identical closures after normalization", () => { - const normalize = ( - closure: ReturnType, - ): Record => { - const out: Record = {}; - for (const [child, ancestors] of closure.closure) { - out[child] = [...ancestors].sort(); - } - return out; - }; - expect(normalize(buildSubClassClosure(forward))).toEqual( - normalize(buildSubClassClosure(reversed)), - ); - }); -}); - -describe("equivalentTo folding", () => { - const equivalenceGraph = defineGraph({ - id: "closures-equivalence", - nodes: { - Person: { type: Person }, - Doctor: { type: Doctor }, - Physician: { type: Physician }, - SpecialistDoctor: { type: SpecialistDoctor }, - }, - edges: {}, - ontology: [ - subClassOf(SpecialistDoctor, Doctor), - subClassOf(Doctor, Person), - equivalentTo(Physician, Doctor), - ], - }); - - it("makes equivalent types mutually reachable", async () => { - const closure = await closureFromGraph(equivalenceGraph); - expect(isReachable(closure, "Physician", "Doctor")).toBe(true); - expect(isReachable(closure, "Doctor", "Physician")).toBe(true); - }); - - it("shares ancestors across an equivalence class", async () => { - const closure = await closureFromGraph(equivalenceGraph); - // Physician ≡ Doctor, and Doctor ⊑ Person, so Physician ⊑ Person too. - expect(isReachable(closure, "Physician", "Person")).toBe(true); - // SpecialistDoctor ⊑ Doctor ≡ Physician, so SpecialistDoctor ⊑ Physician. - expect(isReachable(closure, "SpecialistDoctor", "Physician")).toBe(true); - }); - - it("is order-independent for equivalentTo and subClassOf interleaving", () => { - const base: readonly OntologyIntrospection[] = [ - { - metaEdge: SUB_CLASS_OF_META_EDGE, - from: "SpecialistDoctor", - to: "Doctor", - origin: "compile-time", - }, - { - metaEdge: SUB_CLASS_OF_META_EDGE, - from: "Doctor", - to: "Person", - origin: "compile-time", - }, - { - metaEdge: "equivalentTo", - from: "Physician", - to: "Doctor", - origin: "compile-time", - }, - ]; - const shuffled: readonly OntologyIntrospection[] = [ - requireDefined(base[2]), - requireDefined(base[0]), - requireDefined(base[1]), - ]; - const a = buildSubClassClosure(base); - const b = buildSubClassClosure(shuffled); - expect(isReachable(a, "Physician", "Person")).toBe( - isReachable(b, "Physician", "Person"), - ); - expect(isReachable(a, "SpecialistDoctor", "Physician")).toBe( - isReachable(b, "SpecialistDoctor", "Physician"), - ); - expect(isReachable(a, "Physician", "Person")).toBe(true); - }); -}); - -describe("sameAs folding", () => { - /** - * `sameAs` is the deprecated alias of `equivalentTo`. `store.introspect()` - * reports the declared meta-edge name verbatim, so a closure that recognized - * only `equivalentTo` would silently give these two graphs — identical up to - * that one keyword — different type-reconciliation outcomes. - */ - function equivalenceGraphUsing( - relation: typeof equivalentTo, - id: string, - ): ReturnType { - return defineGraph({ - id, - nodes: { - Person: { type: Person }, - Doctor: { type: Doctor }, - Physician: { type: Physician }, - SpecialistDoctor: { type: SpecialistDoctor }, - }, - edges: {}, - ontology: [ - subClassOf(SpecialistDoctor, Doctor), - subClassOf(Doctor, Person), - relation(Physician, Doctor), - ], - }); - } - - it("makes sameAs types mutually reachable", async () => { - const closure = await closureFromGraph( - // eslint-disable-next-line @typescript-eslint/no-deprecated -- pins the migration-period alias behavior - equivalenceGraphUsing(sameAs, "closures-same-as"), - ); - expect(isReachable(closure, "Physician", "Doctor")).toBe(true); - expect(isReachable(closure, "Doctor", "Physician")).toBe(true); - expect(isReachable(closure, "Physician", "Person")).toBe(true); - expect(isReachable(closure, "SpecialistDoctor", "Physician")).toBe(true); - }); - - it("reconciles a sameAs ontology exactly like an equivalentTo one", async () => { - const normalize = ( - closure: ReturnType, - ): Readonly<{ - closure: Record; - canonicalOf: Record; - }> => ({ - closure: Object.fromEntries( - [...closure.closure].map(([child, ancestors]) => [ - child, - [...ancestors].sort(), - ]), - ), - canonicalOf: Object.fromEntries(closure.canonicalOf), - }); - - const viaSameAs = await closureFromGraph( - // eslint-disable-next-line @typescript-eslint/no-deprecated -- pins the migration-period alias behavior - equivalenceGraphUsing(sameAs, "closures-same-as-parity"), - ); - const viaEquivalentTo = await closureFromGraph( - equivalenceGraphUsing(equivalentTo, "closures-equivalent-to-parity"), - ); - expect(normalize(viaSameAs)).toEqual(normalize(viaEquivalentTo)); - }); -}); diff --git a/packages/typegraph/tests/graph-merge/commit.test.ts b/packages/typegraph/tests/graph-merge/commit.test.ts index 7a9ffc5da..282c832e5 100644 --- a/packages/typegraph/tests/graph-merge/commit.test.ts +++ b/packages/typegraph/tests/graph-merge/commit.test.ts @@ -86,6 +86,7 @@ function emptyPlan(): MergePlan { warnings: [], identityAssertions: [], identityRetractions: [], + identityConflicts: [], canonicalOf: new Map(), }; } diff --git a/packages/typegraph/tests/graph-merge/composition-orphan-cross-branch.test.ts b/packages/typegraph/tests/graph-merge/composition-orphan-cross-branch.test.ts new file mode 100644 index 000000000..0b762afb1 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/composition-orphan-cross-branch.test.ts @@ -0,0 +1,695 @@ +/** + * Cross-branch composition conflicts: one branch attaches a part to a whole + * while another branch's deletion removes one end of that attachment. Merging + * both would silently lose the attachment — the attaching branch's composition + * edge would only appear as an `edge:endpoint-deleted` drop — so every + * planning entry point refuses with `MergeCompositionOrphanError`, naming the + * attaching branch in `details.branchId`: + * + * - `cause: "moved"`: the part is deleted (directly, or by its old whole's + * cascade) while the attaching branch moved it under a whole that survives. + * - `cause: "deleted"`: the whole is deleted while the attaching branch moved + * a surviving part under it. + * + * Moving a part back under a whole by reopening an ENDED `oneActive` row + * (`clearValidTo`) is an attachment too. That row existed at the fork point, + * so another branch's deletion absorbs it without even a drop; it gets the + * same refusal. + * + * When the plan deletes BOTH ends, or the edge does not realize a composition + * pair, the edge is an ordinary `edge:endpoint-deleted` drop and the merge + * proceeds. + * + * Fixture: `Whole`, `Part` and a composition edge `attach` realizing either + * `partOf(Part, Whole)` (edge `Part -> Whole`) or `hasPart(Whole, Part)` + * (edge `Whole -> Part`), under `population: "one"` or `"oneActive"`, plus a + * non-composition edge `mentions` (`Part -> Whole`). Fork point: `w1`, `w2`, + * and `p1` attached under `w1`. + */ +import type { GraphBackend, NodeId, Store } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + hasPart, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { + MergeCompositionOrphanError, + type MergeError, +} from "../../src/graph-merge/errors"; +import { + merge, + mergeIncremental, + planMerge, + planMergeIncremental, +} from "../../src/graph-merge/merge"; +import type { MergePlanCompositionOrphan } from "../../src/graph-merge/plan-schema"; +import { isErr, type Result, unwrap } from "../../src/graph-merge/result"; +import { asBranchId, type GraphBranch } from "../../src/graph-merge/types"; +import { backendMatrix } from "./test-utils"; + +const Whole = defineNode("Whole", { schema: z.object({}) }); +const Part = defineNode("Part", { schema: z.object({}) }); +const attach = defineEdge("attach", { + schema: z.object({ note: z.string().optional() }), +}); +const mentions = defineEdge("mentions", { schema: z.object({}) }); + +type Orientation = "partOf" | "hasPart"; +type Population = "one" | "oneActive"; +type Existence = "optional" | "required"; + +function buildGraph( + orientation: Orientation, + population: Population, + existence: Existence = "optional", +) { + return defineGraph({ + id: "composition-orphan-cross-branch-test", + nodes: { + Whole: { type: Whole }, + Part: { type: Part }, + }, + edges: { + attach: { + type: attach, + from: orientation === "partOf" ? [Part] : [Whole], + to: orientation === "partOf" ? [Whole] : [Part], + ...(orientation === "partOf" ? + { cardinality: population } + : { targetCardinality: population }), + }, + mentions: { type: mentions, from: [Part], to: [Whole] }, + }, + ontology: [ + orientation === "partOf" ? + partOf(Part, Whole, { via: attach, existence }) + : hasPart(Whole, Part, { via: attach, existence }), + ], + }); +} +type G = ReturnType; +type AttachEdgeId = Awaited< + ReturnType["edges"]["attach"]["find"]> +>[number]["id"]; + +const BRANCH_A = asBranchId("branch-a"); +const BRANCH_B = asBranchId("branch-b"); + +const W1 = "w1" as NodeId; +const W2 = "w2" as NodeId; +const P1 = "p1" as NodeId; + +const FIXTURES = [ + { orientation: "partOf", population: "one" }, + { orientation: "partOf", population: "oneActive" }, + { orientation: "hasPart", population: "one" }, + { orientation: "hasPart", population: "oneActive" }, +] as const satisfies readonly Readonly<{ + orientation: Orientation; + population: Population; +}>[]; + +const BRANCH_ORDERS = [ + { order: "deleting branch first", deleterFirst: true }, + { order: "attaching branch first", deleterFirst: false }, +] as const; + +/** A part and the whole a composition edge row of `attach` binds it to. */ +function attachmentOf( + orientation: Orientation, + edge: Readonly<{ fromId: string; toId: string }>, +): Readonly<{ partId: string; wholeId: string }> { + return orientation === "partOf" ? + { partId: edge.fromId, wholeId: edge.toId } + : { partId: edge.toId, wholeId: edge.fromId }; +} + +function expectCompositionRefusal( + result: Result, + expected: MergePlanCompositionOrphan, + label: string, +): void { + if (!isErr(result)) { + throw new Error(`${label}: expected a MergeCompositionOrphanError refusal`); + } + console.info(`${label} refusal:`, result.error.message); + expect(result.error).toBeInstanceOf(MergeCompositionOrphanError); + expect({ + label, + code: result.error.code, + details: result.error.details, + }).toEqual({ + label, + code: "GRAPH_MERGE_COMPOSITION_ORPHAN", + details: expected, + }); +} + +describe.each(backendMatrix())( + "cross-branch composition conflict [$name]", + (entry) => { + let cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeForkPoint(graph: G): Promise> { + const [store] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + await store.nodes.Whole.create({}, { id: W1 }); + await store.nodes.Whole.create({}, { id: W2 }); + await store.nodes.Part.create( + {}, + { id: P1, partOf: { whole: { kind: "Whole", id: W1 } } }, + ); + return store; + } + + async function makeBranchOf( + forkPoint: Store, + id: ReturnType, + ): Promise> { + const result = await branch(forkPoint, () => makeBackend(), { id }); + if (isErr(result)) throw result.error; + return result.data; + } + + async function moveUnderW2(attacher: GraphBranch): Promise { + await attacher.store.nodes.Part.reparent(P1, { + whole: { kind: "Whole", id: W2 }, + }); + } + + function ordered( + deleter: GraphBranch, + attacher: GraphBranch, + deleterFirst: boolean, + ): readonly GraphBranch[] { + return deleterFirst ? [deleter, attacher] : [attacher, deleter]; + } + + /** The fork-point state, untouched: both wholes, the part, and its open `w1` attachment. */ + async function expectForkPointState( + store: Store, + orientation: Orientation, + ): Promise { + await expect(store.nodes.Whole.getById(W1)).resolves.toBeDefined(); + await expect(store.nodes.Whole.getById(W2)).resolves.toBeDefined(); + await expect(store.nodes.Part.getById(P1)).resolves.toBeDefined(); + const edges = await store.edges.attach.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect( + edges.map((edge) => ({ + ...attachmentOf(orientation, edge), + validTo: edge.meta.validTo, + })), + ).toEqual([{ partId: P1, wholeId: W1, validTo: undefined }]); + await expect(store.edges.mentions.find({})).resolves.toEqual([]); + } + + describe.each(FIXTURES)( + "$orientation, population $population", + ({ orientation, population }) => { + const graph = buildGraph(orientation, population); + + it.each(BRANCH_ORDERS)( + "refuses a part moved by one branch whose old whole another branch deletes ($order)", + async ({ deleterFirst }) => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W1); + await expect( + deleter.store.nodes.Part.getById(P1), + ).resolves.toBeUndefined(); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + + expectCompositionRefusal( + await merge(forkPoint, ordered(deleter, attacher, deleterFirst)), + { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "moved", + branchId: BRANCH_B, + }, + "merge", + ); + await expectForkPointState(forkPoint, orientation); + }, + ); + + it.each(BRANCH_ORDERS)( + "refuses a part moved by one branch that another branch deletes directly ($order)", + async ({ deleterFirst }) => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Part.delete(P1); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + + expectCompositionRefusal( + await merge(forkPoint, ordered(deleter, attacher, deleterFirst)), + { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "moved", + branchId: BRANCH_B, + }, + "merge", + ); + await expectForkPointState(forkPoint, orientation); + }, + ); + + it.each(BRANCH_ORDERS)( + "refuses a part moved by one branch under a whole another branch deletes ($order)", + async ({ deleterFirst }) => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W2); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + + expectCompositionRefusal( + await merge(forkPoint, ordered(deleter, attacher, deleterFirst)), + { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "deleted", + branchId: BRANCH_B, + }, + "merge", + ); + await expectForkPointState(forkPoint, orientation); + }, + ); + + it("merges a move whose part and destination whole are both deleted, dropping the edge", async () => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W1); + await deleter.store.nodes.Whole.delete(W2); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + const [movedEdge] = await attacher.store.edges.attach.find({}); + if (movedEdge === undefined) throw new Error("expected the move"); + + const report = unwrap(await merge(forkPoint, [deleter, attacher])); + console.info("both-deleted drops", report.dropped); + expect(report.dropped).toContainEqual({ + kind: "edge", + id: movedEdge.id, + reason: "edge:endpoint-deleted", + }); + await expect( + forkPoint.nodes.Whole.getById(W1), + ).resolves.toBeUndefined(); + await expect( + forkPoint.nodes.Whole.getById(W2), + ).resolves.toBeUndefined(); + await expect( + forkPoint.nodes.Part.getById(P1), + ).resolves.toBeUndefined(); + }); + + it("leaves an edited inherited attachment whose part another branch deletes to the target-side check", async () => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Part.delete(P1); + const editor = await makeBranchOf(forkPoint, BRANCH_B); + const [inherited] = await editor.store.edges.attach.find({}); + if (inherited === undefined) throw new Error("expected the edge"); + await editor.store.edges.attach.update(inherited.id, { + note: "edited", + }); + + // The edit survives the delete/modify resolution, so the plan keeps + // the edge the target already holds while deleting its part: the + // target-side `moved` arm reports it and apply refuses it. No + // branch created the attachment, so no branch is named. + const targetSideFinding = { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W1 }, + viaEdgeKind: "attach", + cause: "moved", + } as const; + const artifact = unwrap( + await planMerge(forkPoint, [deleter, editor]), + ); + expect(artifact.review.compositionOrphans).toEqual([ + targetSideFinding, + ]); + expectCompositionRefusal( + await merge(forkPoint, [deleter, editor]), + targetSideFinding, + "merge", + ); + await expect(forkPoint.nodes.Part.getById(P1)).resolves.toBeDefined(); + }); + + it("merges a non-composition edge to a whole another branch deletes, dropping the edge", async () => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W2); + const mentioner = await makeBranchOf(forkPoint, BRANCH_B); + const mention = await mentioner.store.edges.mentions.create( + { kind: "Part", id: P1 }, + { kind: "Whole", id: W2 }, + {}, + ); + + const report = unwrap(await merge(forkPoint, [deleter, mentioner])); + expect(report.dropped).toEqual([ + { kind: "edge", id: mention.id, reason: "edge:endpoint-deleted" }, + ]); + await expect( + forkPoint.nodes.Whole.getById(W2), + ).resolves.toBeUndefined(); + await expect(forkPoint.nodes.Part.getById(P1)).resolves.toBeDefined(); + await expect(forkPoint.edges.mentions.find({})).resolves.toEqual([]); + }); + }, + ); + + describe("an ended attachment", () => { + const graph = buildGraph("partOf", "oneActive"); + + it("merges a branch's ended attachment to a whole another branch deletes, keeping the part's current whole", async () => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W2); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + await attacher.store.nodes.Part.reparent(P1, { + whole: { kind: "Whole", id: W1 }, + }); + const endedUnderW2 = ( + await attacher.store.edges.attach.find( + {}, + { temporalMode: "includeEnded" }, + ) + ).find((edge) => edge.toId === W2); + if (endedUnderW2 === undefined) throw new Error("expected the edge"); + expect(endedUnderW2.meta.validTo).toBeDefined(); + + const report = unwrap(await merge(forkPoint, [deleter, attacher])); + console.info("ended-attachment drops", report.dropped); + expect(report.dropped).toContainEqual({ + kind: "edge", + id: endedUnderW2.id, + reason: "edge:endpoint-deleted", + }); + await expect( + forkPoint.nodes.Whole.getById(W2), + ).resolves.toBeUndefined(); + const open = await forkPoint.edges.attach.find({}); + expect( + open.map((edge) => ({ partId: edge.fromId, wholeId: edge.toId })), + ).toEqual([{ partId: P1, wholeId: W1 }]); + }); + }); + + describe.each( + FIXTURES.filter((fixture) => fixture.population === "oneActive"), + )("re-attachment through an ended row, $orientation", ({ orientation }) => { + const graph = buildGraph(orientation, "oneActive"); + + /** + * The fork point holds `p1` under `w1` through an open row, and an ENDED + * row from an earlier attachment to `w2`. The attaching branch moves `p1` + * back to `w2` by ending the `w1` row and reopening the `w2` row. + */ + async function makeReopenForkPoint(): Promise< + Readonly<{ + forkPoint: Store; + toW1: AttachEdgeId; + toW2: AttachEdgeId; + }> + > { + const [forkPoint] = await createStoreWithSchema( + graph, + await makeBackend(), + { revisionTracking: true }, + ); + await forkPoint.nodes.Whole.create({}, { id: W1 }); + await forkPoint.nodes.Whole.create({}, { id: W2 }); + await forkPoint.nodes.Part.create( + {}, + { id: P1, partOf: { whole: { kind: "Whole", id: W2 } } }, + ); + await forkPoint.nodes.Part.reparent(P1, { + whole: { kind: "Whole", id: W1 }, + }); + const rows = await forkPoint.edges.attach.find( + {}, + { temporalMode: "includeEnded" }, + ); + const rowTo = (wholeId: string): AttachEdgeId => { + const row = rows.find( + (edge) => attachmentOf(orientation, edge).wholeId === wholeId, + ); + if (row === undefined) + throw new Error(`expected a row to ${wholeId}`); + return row.id; + }; + return { forkPoint, toW1: rowTo(W1), toW2: rowTo(W2) }; + } + + async function reopenUnderW2( + attacher: GraphBranch, + rows: Readonly<{ toW1: AttachEdgeId; toW2: AttachEdgeId }>, + ): Promise { + await attacher.store.edges.attach.update( + rows.toW1, + {}, + { validTo: new Date().toISOString() }, + ); + await attacher.store.edges.attach.update( + rows.toW2, + {}, + { clearValidTo: true }, + ); + } + + async function expectReopenForkPointState( + store: Store, + ): Promise { + await expect(store.nodes.Whole.getById(W2)).resolves.toBeDefined(); + await expect(store.nodes.Part.getById(P1)).resolves.toBeDefined(); + const edges = await store.edges.attach.find( + {}, + { temporalMode: "includeEnded" }, + ); + expect( + edges + .map((edge) => ({ + wholeId: attachmentOf(orientation, edge).wholeId, + open: edge.meta.validTo === undefined, + })) + .toSorted((left, right) => + left.wholeId.localeCompare(right.wholeId), + ), + ).toEqual([ + { wholeId: W1, open: true }, + { wholeId: W2, open: false }, + ]); + } + + const SHAPES = [ + { + shape: "the whole", + cause: "deleted", + deleteEnd: (deleter: GraphBranch) => + deleter.store.nodes.Whole.delete(W2), + }, + { + shape: "the part", + cause: "moved", + deleteEnd: (deleter: GraphBranch) => + deleter.store.nodes.Part.delete(P1), + }, + ] as const; + + it.each( + SHAPES.flatMap((shape) => + BRANCH_ORDERS.map((order) => ({ ...shape, ...order })), + ), + )( + "refuses a reopened attachment when another branch deletes $shape ($order)", + async ({ cause, deleteEnd, deleterFirst }) => { + const { forkPoint, ...rows } = await makeReopenForkPoint(); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleteEnd(deleter); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await reopenUnderW2(attacher, rows); + + const expected = { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause, + branchId: BRANCH_B, + } as const; + const branches = ordered(deleter, attacher, deleterFirst); + expectCompositionRefusal( + await planMergeIncremental({ + forkPoint, + target: forkPoint, + branches, + }), + expected, + "planMergeIncremental", + ); + expectCompositionRefusal( + await merge(forkPoint, branches), + expected, + "merge", + ); + await expectReopenForkPointState(forkPoint); + }, + ); + + it.each(SHAPES)( + "refuses a reopened attachment the branch also edits when another branch deletes $shape", + async ({ cause, deleteEnd }) => { + const { forkPoint, ...rows } = await makeReopenForkPoint(); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleteEnd(deleter); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await reopenUnderW2(attacher, rows); + await attacher.store.edges.attach.update(rows.toW2, { + note: "reopened", + }); + + expectCompositionRefusal( + await merge(forkPoint, [deleter, attacher]), + { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause, + branchId: BRANCH_B, + }, + "merge", + ); + await expectReopenForkPointState(forkPoint); + }, + ); + }); + + describe("every planning entry point", () => { + const graph = buildGraph("partOf", "oneActive"); + + it("refuses both shapes from planMerge, planMergeIncremental and mergeIncremental", async () => { + const forkPoint = await makeForkPoint(graph); + const deletesOldWhole = await makeBranchOf(forkPoint, BRANCH_A); + await deletesOldWhole.store.nodes.Whole.delete(W1); + const deletesNewWhole = await makeBranchOf( + forkPoint, + asBranchId("branch-c"), + ); + await deletesNewWhole.store.nodes.Whole.delete(W2); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + + const cases = [ + { + deleter: deletesOldWhole, + expected: { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "moved", + branchId: BRANCH_B, + }, + }, + { + deleter: deletesNewWhole, + expected: { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "deleted", + branchId: BRANCH_B, + }, + }, + ] as const; + + for (const { deleter, expected } of cases) { + const branches = [deleter, attacher]; + expectCompositionRefusal( + await planMerge(forkPoint, branches), + expected, + `planMerge (${expected.cause})`, + ); + const incremental = { forkPoint, target: forkPoint, branches }; + expectCompositionRefusal( + await planMergeIncremental(incremental), + expected, + `planMergeIncremental (${expected.cause})`, + ); + expectCompositionRefusal( + await mergeIncremental(incremental), + expected, + `mergeIncremental (${expected.cause})`, + ); + } + await expectForkPointState(forkPoint, "partOf"); + }); + }); + + describe('required existence (`existence: "required"`)', () => { + const graph = buildGraph("partOf", "one", "required"); + + it("refuses a move under a deleted whole as a cross-branch deletion, not as an unattached part", async () => { + const forkPoint = await makeForkPoint(graph); + const deleter = await makeBranchOf(forkPoint, BRANCH_A); + await deleter.store.nodes.Whole.delete(W2); + const attacher = await makeBranchOf(forkPoint, BRANCH_B); + await moveUnderW2(attacher); + + const expected = { + part: { kind: "Part", id: P1 }, + whole: { kind: "Whole", id: W2 }, + viaEdgeKind: "attach", + cause: "deleted", + branchId: BRANCH_B, + } as const; + expectCompositionRefusal( + await planMerge(forkPoint, [deleter, attacher]), + expected, + "planMerge", + ); + expectCompositionRefusal( + await merge(forkPoint, [deleter, attacher]), + expected, + "merge", + ); + await expectForkPointState(forkPoint, "partOf"); + }); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/composition-orphan-docs.test.ts b/packages/typegraph/tests/graph-merge/composition-orphan-docs.test.ts new file mode 100644 index 000000000..fb5fb8758 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/composition-orphan-docs.test.ts @@ -0,0 +1,58 @@ +/** + * The published description of `MergePlanReview.compositionOrphans` must agree + * with the planner: a part the plan deletes is not a `deleted` orphan but IS + * reported as `moved` when the target holds it under a live whole the plan + * keeps (see composition-orphan.test.ts). The changeset and the merge guide + * therefore may not say that a part the plan deletes is never reported, and + * must speak of all three causes. + */ +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const CHANGESET_PATH = fileURLToPath( + new URL("../../../../.changeset/composition.md", import.meta.url), +); +const MERGE_GUIDE_PATH = fileURLToPath( + new URL( + "../../../../apps/docs/src/content/docs/graph-merge.md", + import.meta.url, + ), +); +const NEVER_REPORTED_CLAIMS = [ + "a part the plan deletes, moves or creates with its composition edge is not reported", + "are not orphans and are not reported", +] as const; + +function flattenWhitespace(text: string): string { + return text.replaceAll(/\s+/g, " "); +} + +function compositionOrphanGuideSection(): string { + const guide = readFileSync(MERGE_GUIDE_PATH, "utf8"); + const start = guide.indexOf("### Composition orphans"); + const end = guide.indexOf("\n### ", start + 1); + return flattenWhitespace(guide.slice(start, end === -1 ? undefined : end)); +} + +describe("composition orphan documentation", () => { + it("does not claim a part the plan deletes is never reported", () => { + const documents = { + changeset: flattenWhitespace(readFileSync(CHANGESET_PATH, "utf8")), + guide: compositionOrphanGuideSection(), + }; + for (const [name, text] of Object.entries(documents)) { + for (const claim of NEVER_REPORTED_CLAIMS) { + expect(text, `${name} claims: ${claim}`).not.toContain(claim); + } + } + }); + + it("scopes the guide's judged-against-the-plan statement to all three causes", () => { + const section = compositionOrphanGuideSection(); + expect(section).toContain("judge all three against the state"); + expect(section).not.toContain("judge both"); + expect(section).toContain('still reported as `"moved"`'); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/composition-orphan-endpoint-deleted.test.ts b/packages/typegraph/tests/graph-merge/composition-orphan-endpoint-deleted.test.ts new file mode 100644 index 000000000..2eaab1f0f --- /dev/null +++ b/packages/typegraph/tests/graph-merge/composition-orphan-endpoint-deleted.test.ts @@ -0,0 +1,245 @@ +/** + * A required part whose inherited composition edge canonicalization drops + * because the whole is deleted. + * + * Fixture: `partOf(EPart, EWhole, { via: eHolds, existence: "required" })`. + * The fork point holds `w1`, `p1` and the composition edge between them. + * Branch A edits `p1` and the edge; branch B deletes `w1`, which cascades its + * own copies of `p1` and the edge. The merge then resolves the + * delete/modify conflict on `p1` and the edge under each + * `onDeleteModifyConflict` policy. + * + * Under `modifyWins` and `flag`, `p1` survives, while the edge is dropped + * with `edge:endpoint-deleted`: its whole is gone. The merge issues no write + * for that dropped edge, so no per-write composition guard ever sees it. The + * plan-time report and the in-transaction apply refusal still catch the + * orphan, because the "deleted" arm reads the target's live parts closure of + * every whole the plan deletes. Under `deleteWins` the part is deleted with + * its whole and the merge commits cleanly. + * + * The surviving part is also a node upsert, so the final-state "unattached" + * check would refuse it as well; asserting `cause: "deleted"` pins the + * "deleted" arm, which runs first. Disabling that arm turns the refusal into + * `cause: "unattached"`; disabling both lets apply commit the orphan. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { ENDPOINT_DELETED_DROP_REASON } from "../../src/graph-merge/edge-repoint"; +import { MergeCompositionOrphanError } from "../../src/graph-merge/errors"; +import { + applyMergePlan, + planMergeIncremental, +} from "../../src/graph-merge/merge"; +import { isErr, isOk } from "../../src/graph-merge/result"; +import { + asBranchId, + type DeleteModifyPolicy, + type GraphBranch, +} from "../../src/graph-merge/types"; +import { backendMatrix } from "./test-utils"; + +const EWhole = defineNode("EWhole", { + schema: z.object({ name: z.string() }), +}); +const EPart = defineNode("EPart", { schema: z.object({ name: z.string() }) }); +const eHolds = defineEdge("eHolds", { + schema: z.object({ note: z.string() }), +}); + +const graph = defineGraph({ + id: "composition-orphan-endpoint-deleted-test", + nodes: { + EWhole: { type: EWhole }, + EPart: { type: EPart }, + }, + edges: { + eHolds: { + type: eHolds, + from: [EPart], + to: [EWhole], + cardinality: "one", + }, + }, + ontology: [partOf(EPart, EWhole, { via: eHolds, existence: "required" })], +}); +type G = typeof graph; + +const BRANCH_EDITS_PART = asBranchId("branch-edits-part"); +const BRANCH_DELETES_WHOLE = asBranchId("branch-deletes-whole"); + +const ORPHANING_POLICIES: readonly DeleteModifyPolicy[] = [ + "modifyWins", + "flag", +]; + +describe.each(backendMatrix())( + "composition orphan — required part whose whole another branch deletes [$name]", + (entry) => { + let cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeBranchOf( + forkPoint: Store, + id: ReturnType, + ): Promise> { + const result = await branch(forkPoint, () => makeBackend(), { id }); + if (isErr(result)) throw result.error; + return result.data; + } + + async function divergentBranches(): Promise< + Readonly<{ + target: Store; + edgeId: string; + branches: readonly GraphBranch[]; + }> + > { + const [target] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + const whole = await target.nodes.EWhole.create( + { name: "whole" }, + { id: "w1" }, + ); + const part = await target.nodes.EPart.create( + { name: "part" }, + { + id: "p1", + partOf: { + whole: { kind: "EWhole", id: whole.id }, + props: { note: "original" }, + }, + }, + ); + const [edge] = await target.edges.eHolds.find({}); + if (edge === undefined) throw new Error("expected a composition edge"); + + const editsPart = await makeBranchOf(target, BRANCH_EDITS_PART); + await editsPart.store.nodes.EPart.update(part.id, { + name: "part (edited)", + }); + await editsPart.store.edges.eHolds.update(edge.id, { + note: "edited", + }); + + const deletesWhole = await makeBranchOf(target, BRANCH_DELETES_WHOLE); + await deletesWhole.store.nodes.EWhole.delete(whole.id); + + return { + target, + edgeId: edge.id, + branches: [editsPart, deletesWhole], + }; + } + + it.each(ORPHANING_POLICIES)( + "reports at plan time and refuses at apply when the part survives under %s", + async (policy) => { + const { target, edgeId, branches } = await divergentBranches(); + + const planResult = await planMergeIncremental({ + forkPoint: target, + target, + branches, + options: { onDeleteModifyConflict: policy }, + }); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + expect( + artifact.writes.nodeDeletes.map((deletion) => deletion.id), + ).toEqual(["w1"]); + expect(artifact.review.dropped).toContainEqual({ + kind: "edge", + id: edgeId, + reason: ENDPOINT_DELETED_DROP_REASON, + }); + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "EPart", id: "p1" }, + whole: { kind: "EWhole", id: "w1" }, + viaEdgeKind: "eHolds", + cause: "deleted", + }, + ]); + + const applyResult = await applyMergePlan(target, artifact); + if (!isErr(applyResult)) { + throw new Error( + `expected apply to be refused; committed revision ${JSON.stringify(applyResult.data)}`, + ); + } + expect(applyResult.error).toBeInstanceOf(MergeCompositionOrphanError); + expect(applyResult.error.details).toEqual({ + part: { kind: "EPart", id: "p1" }, + whole: { kind: "EWhole", id: "w1" }, + viaEdgeKind: "eHolds", + cause: "deleted", + }); + + await expect( + target.nodes.EWhole.getById("w1" as never), + ).resolves.toMatchObject({ name: "whole" }); + await expect( + target.nodes.EPart.getById("p1" as never), + ).resolves.toMatchObject({ name: "part" }); + await expect(target.edges.eHolds.find({})).resolves.toEqual([ + expect.objectContaining({ id: edgeId, note: "original" }), + ]); + await expect(target.verifyConstraintFences()).resolves.toEqual([]); + }, + ); + + it("deletes the part with its whole under deleteWins", async () => { + const { target, branches } = await divergentBranches(); + + const planResult = await planMergeIncremental({ + forkPoint: target, + target, + branches, + options: { onDeleteModifyConflict: "deleteWins" }, + }); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + expect( + artifact.writes.nodeDeletes.map((deletion) => deletion.id).toSorted(), + ).toEqual(["p1", "w1"]); + expect(artifact.review.compositionOrphans).toEqual([]); + + const applyResult = await applyMergePlan(target, artifact); + if (!isOk(applyResult)) throw applyResult.error; + + await expect( + target.nodes.EWhole.getById("w1" as never), + ).resolves.toBeUndefined(); + await expect( + target.nodes.EPart.getById("p1" as never), + ).resolves.toBeUndefined(); + await expect(target.edges.eHolds.find({})).resolves.toEqual([]); + await expect(target.verifyConstraintFences()).resolves.toEqual([]); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/composition-orphan-unattached.test.ts b/packages/typegraph/tests/graph-merge/composition-orphan-unattached.test.ts new file mode 100644 index 000000000..252d2b4b2 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/composition-orphan-unattached.test.ts @@ -0,0 +1,451 @@ +/** + * The "unattached" composition-orphan arm — + * `compositionOrphansAmong`'s sibling in `src/graph-merge/merge.ts` + * (`unattachedRequiredPartOrphansAmong` / `assertNoUnattachedRequiredParts`). + * + * `composition-orphan.test.ts` covers `cause: "deleted"`: a whole the plan + * deletes leaves behind a live part the plan does not also delete. That + * arm's walk starts FROM a planned node deletion and can never catch the + * DIFFERENT shape this file is about: a required-existence part whose + * composition edge canonicalization drops or collapses while BOTH + * endpoints (part and whole) survive — no node deletion anywhere for the + * "deleted" arm to walk from. + * + * Fixture: `UWhole` --uHolds(cardinality "one")-- `UPart`, + * `partOf(UPart, UWhole, { via: uHolds, existence: "required" })`. The + * ordinary store API can never produce a live required part with no whole + * (create refuses a bare `partOf`-less create, and every detach site + * refuses ending the part's only edge while it is live) — so the state this + * arm defends against can only arise from a MERGE combining two + * independently-valid histories, or (as constructed here, mirroring + * `composition-orphan.test.ts`'s own "already-dead part" fixture) a branch + * edit that bypasses the store's own write path entirely to model + * canonicalization dropping an edge no single store write ever could. + * + * The branch is HAND-BUILT (`GraphBranch`'s own docblock: "the merge + * primitive's own committed-target stand-in, `tests/`-only fixtures") rather + * than produced by `branch()`, because its edit — dropping the part's only + * composition edge while both endpoints stay live — is one the branch store's + * own write path refuses. The branch's rows are instead planted with + * `backend.insertNode`/`insertEdge` directly, copying the fork point's OWN ids + * verbatim, so the merge recognizes the edit as touching the SAME inherited + * edge. `branch()` itself clones a store holding a live required part through + * a single-transaction `importGraph` (a streamed import commits per chunk and + * refuses such graphs upfront); the first case below pins that clone. + * + * The target is `forkPoint` itself: the "target evolved no differently from + * the fork point" case `composition-orphan.test.ts` already establishes as + * legitimate, and the simplest way to guarantee the target's copy shares + * the fork point's exact ids too. + */ +import type { + EdgeId, + GraphBackend, + NodeId, + NodeType, + Store, +} from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { rowPropsToObject } from "../../src/backend/types"; +import { computeBaseVersion } from "../../src/graph-merge/base-version"; +import { branch } from "../../src/graph-merge/branch"; +import type { CanonicalEntity } from "../../src/graph-merge/canonicalize"; +import type { MergedEdge } from "../../src/graph-merge/edge-repoint"; +import { + applyMergePlan, + type MergePlan, + planMergeIncremental, + requiredExistenceOrphanCandidates, + resolvedMergeWrites, +} from "../../src/graph-merge/merge"; +import { mergeKey } from "../../src/graph-merge/node-key"; +import { mergePlanArtifactV2Schema } from "../../src/graph-merge/plan-schema"; +import { isErr, isOk } from "../../src/graph-merge/result"; +import { asBranchId, type GraphBranch } from "../../src/graph-merge/types"; +import { storeBackend } from "../../src/store/runtime-port"; +import { requireDefined } from "../../src/utils/presence"; +import { backendMatrix } from "./test-utils"; + +const UWhole = defineNode("UWhole", { schema: z.object({}) }); +const UPart = defineNode("UPart", { schema: z.object({}) }); +const uHolds = defineEdge("uHolds", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-orphan-unattached-test", + nodes: { + UWhole: { type: UWhole }, + UPart: { type: UPart }, + }, + edges: { + uHolds: { + type: uHolds, + from: [UPart], + to: [UWhole], + cardinality: "one", + }, + }, + ontology: [partOf(UPart, UWhole, { via: uHolds, existence: "required" })], +}); +type G = typeof graph; + +const BRANCH_A = asBranchId("branch-a"); + +function nodeId(value: string): NodeId { + return value as NodeId; +} + +/** An empty plan; the candidate-walk test overrides only the slices it exercises. */ +function emptyPlan(): MergePlan { + return { + canonicalEntities: [], + survivingModifications: [], + nodeDeletions: new Map(), + edgeDeletions: new Map(), + mergedEdges: [], + inheritedEdgeBaseProps: new Map(), + retypeMap: new Map(), + nodeValidityEnds: new Map(), + edgeValidityEnds: new Map(), + validityEnds: [], + resolutions: [], + propertyConflicts: [], + deleteModifyConflicts: [], + typeReconciliations: [], + dropped: [], + baseAmbiguities: [], + provenanceRecords: [], + warnings: [], + identityAssertions: [], + identityRetractions: [], + identityConflicts: [], + canonicalOf: new Map(), + }; +} + +describe.each(backendMatrix())( + "composition orphan conflict — unattached required part [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + afterEach(async () => { + for (const cleanup of cleanups ?? []) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeStore(): Promise> { + const [store] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + return store; + } + + /** + * Plants a byte-identical copy of `whole`/`part`/`edge` (read off + * `source`) directly onto `target`'s backend via raw insert — never + * through the ordinary create API, which would both refuse a bare + * required-existence create and mint a fresh (non-matching) edge id. + */ + async function plantIdenticalFixture( + source: GraphBackend, + target: GraphBackend, + ): Promise { + const whole = requireDefined( + await source.getNode(graph.id, "UWhole", "w1"), + "fork point UWhole/w1", + ); + const part = requireDefined( + await source.getNode(graph.id, "UPart", "p1"), + "fork point UPart/p1", + ); + const [edge] = await source.findEdgesConnectedTo({ + graphId: graph.id, + nodeKind: "UPart", + nodeId: "p1", + }); + const edgeRow = requireDefined(edge, "fork point uHolds edge"); + + await target.insertNode({ + graphId: graph.id, + kind: whole.kind, + id: whole.id, + props: rowPropsToObject(whole.props), + }); + await target.insertNode({ + graphId: graph.id, + kind: part.kind, + id: part.id, + props: rowPropsToObject(part.props), + }); + await target.insertEdge({ + graphId: graph.id, + id: edgeRow.id, + kind: edgeRow.kind, + fromKind: edgeRow.from_kind, + fromId: edgeRow.from_id, + toKind: edgeRow.to_kind, + toId: edgeRow.to_id, + props: rowPropsToObject(edgeRow.props), + }); + } + + it("branch() clones a store holding a live required part exactly", async () => { + cleanups = []; + const base = await makeStore(); + await base.nodes.UWhole.create({}, { id: "w1" }); + await base.nodes.UPart.create( + {}, + { id: "p1", partOf: { whole: { kind: "UWhole", id: "w1" } } }, + ); + + const cloned = await branch(base, () => makeBackend(), { id: BRANCH_A }); + + if (isErr(cloned)) throw cloned.error; + const clone = cloned.data.store; + const nodeIds = async (store: Store) => ({ + wholes: (await store.nodes.UWhole.find()).map((node) => node.id), + parts: (await store.nodes.UPart.find()).map((node) => node.id), + }); + const edges = async (store: Store) => + (await store.edges.uHolds.find()).map((edge) => ({ + id: edge.id, + from: edge.fromId, + to: edge.toId, + })); + expect(await nodeIds(clone)).toEqual(await nodeIds(base)); + expect(await edges(clone)).toEqual(await edges(base)); + expect(await edges(clone)).toHaveLength(1); + await cloned.data.close(); + }); + // MUTATION CHECK: dropping `declaresRequiredCompositionParts` from the + // clone's materialize condition in `working-copy.ts` sends the clone + // through `importGraphStream`, which refuses the graph, and `branch()` + // fails. + + it("reports at plan time and refuses at apply time when a branch drops the part's only composition edge while both endpoints survive", async () => { + cleanups = []; + const forkPoint = await makeStore(); + await forkPoint.nodes.UWhole.create({}, { id: "w1" }); + await forkPoint.nodes.UPart.create( + {}, + { id: "p1", partOf: { whole: { kind: "UWhole", id: "w1" } } }, + ); + const [forkEdge] = await forkPoint.edges.uHolds.find({}); + const forkEdgeId = requireDefined(forkEdge).id; + + // The hand-built branch: a fresh, independent backend planted with + // the fork point's EXACT rows (same ids), so the merge recognizes its + // edit as touching the SAME inherited edge. + const branchStore = await makeStore(); + await plantIdenticalFixture( + storeBackend(forkPoint), + storeBackend(branchStore), + ); + const branchA: GraphBranch = { + id: BRANCH_A, + base: await computeBaseVersion(forkPoint), + store: branchStore, + close: () => Promise.resolve(), + }; + // Bypasses the branch's OWN store refusal — an ordinary + // `branchStore.edges.uHolds.delete(...)` would throw + // `CompositionExistenceError` here, since `UPart/p1` is still live. + // Written directly through the raw backend instead, exactly as + // `composition-orphan.test.ts`'s "already-dead part" fixture bypasses + // `store.nodes.Part.delete` to reach a state the ordinary write path + // refuses by construction. The branch's diff against the fork point + // (computed by re-reading its live rows, not a cached record) then + // legitimately records this edge as deleted, without recording + // `UPart/p1`'s own deletion. + await storeBackend(branchStore).deleteEdge({ + graphId: graph.id, + id: forkEdgeId, + }); + + // The target IS the fork point: it evolved no differently, and + // sharing the object guarantees the same ids by construction. + const target = forkPoint; + + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(isOk(planResult)).toBe(true); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + // The plan-time PREVIEW: `unattachedRequiredPartOrphansAmong`'s own + // contribution. Nothing has been written yet, so nothing else could + // report this — proving the arm fires BEFORE any attempt to apply. + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "UPart", id: "p1" }, + viaEdgeKind: "uHolds", + cause: "unattached", + }, + ]); + + // Apply is refused. For THIS construction — an explicit + // `plan.edgeDeletes` entry naming a live required-existence + // composition edge — `applyEdgeRows`'s ordinary + // `edgeCollection(...).delete(...)` call reaches the composition + // edge's PRE-EXISTING per-write detach refusal + // (`assertCompositionExistencePreserved`) BEFORE this fix's own + // `assertNoUnattachedRequiredParts` gets a chance to run — so the + // thrown error is `CompositionExistenceError`, translated to + // `MergeConstraintConflictError` at the merge boundary, not + // `MergeCompositionOrphanError`. Either way nothing commits, which + // is the guarantee the merge-existence audit asks for. + // A composition edge canonicalization DROPS as + // `ENDPOINT_DELETED_DROP_REASON` issues no delete/update call for the + // per-write refusal to intercept; the plan deletes that edge's whole, + // so the "deleted" arm reports and refuses it instead + // (`composition-orphan-endpoint-deleted.test.ts`). + const applyResult = await applyMergePlan(target, artifact); + expect(isErr(applyResult)).toBe(true); + if (!isErr(applyResult)) throw new Error("expected apply to be refused"); + + // Nothing changed on the target: the refusal fires from inside the + // SAME apply transaction the edge deletion would have committed + // through, so it rolls back with it. + await expect( + target.nodes.UWhole.getById("w1" as never), + ).resolves.toBeDefined(); + await expect( + target.nodes.UPart.getById("p1" as never), + ).resolves.toBeDefined(); + await expect(target.edges.uHolds.find({})).resolves.toEqual([ + expect.objectContaining({ id: forkEdgeId }), + ]); + }); + // MUTATION CHECK: comment out the `unattached` computation inside + // `planTimeCompositionOrphans` (src/graph-merge/merge.ts) — drop its + // call to `unattachedRequiredPartOrphansAmong` and return only + // `deleted`. The plan-time `compositionOrphans` assertion above then + // reports `[]` instead of the finding. (Apply still refuses either way, + // via the pre-existing per-write detach refusal described above — that + // refusal is what the final `find({})` assertion actually pins, not + // this fix's own code; the `compositionOrphans` assertion is this + // test's load-bearing check on the NEW code.) + + it("finds a candidate reachable through each of the three write sources from the live plan's view and from the wire artifact's view alike", async () => { + cleanups = []; + const target = await makeStore(); + await target.nodes.UWhole.create({}, { id: "w1" }); + for (const id of ["p-written", "p-detached", "p-ended"]) { + await target.nodes.UPart.create( + {}, + { id, partOf: { whole: { kind: "UWhole", id: "w1" } } }, + ); + } + const edgeOf = async (partId: string): Promise => { + const [edge] = await storeBackend(target).findEdgesConnectedTo({ + graphId: graph.id, + nodeKind: "UPart", + nodeId: partId, + }); + return requireDefined(edge, `uHolds edge of ${partId}`).id as EdgeId; + }; + const detachedEdgeId = await edgeOf("p-detached"); + const endedEdgeId = await edgeOf("p-ended"); + + // Source 1: the plan WRITES the part's own row (a canonical upsert). + const written: CanonicalEntity = { + canonicalId: nodeId("p-written"), + kind: "UPart", + props: {}, + resolution: { + canonicalId: nodeId("p-written"), + memberIds: [nodeId("p-written")], + kind: "UPart", + branchOrigins: [], + decisiveEdges: [], + }, + conflicts: [], + }; + // Source 3: the plan CLOSES the validity window of the part's edge. + const ended: MergedEdge = { + id: endedEdgeId, + kind: "uHolds", + fromId: nodeId("p-ended"), + toId: nodeId("w1"), + fromKind: "UPart", + toKind: "UWhole", + props: {}, + mergedIds: [endedEdgeId], + validTo: "2026-01-01T00:00:00.000Z", + }; + const plan: MergePlan = { + ...emptyPlan(), + canonicalEntities: [written], + // Source 2: the plan DELETES the part's edge outright. + edgeDeletions: new Map([ + [mergeKey("uHolds", detachedEdgeId), "uHolds"], + ]), + mergedEdges: [ended], + }; + const ctx = { graphId: target.graphId, registry: target.registry }; + const backend = storeBackend(target); + + // The live-plan entry point (`applyInternalMergePlan` and the plan-time + // preview): the plan's own normalized write view. + const liveWrites = resolvedMergeWrites(plan); + const fromLivePlan = await requiredExistenceOrphanCandidates( + ctx, + backend, + liveWrites, + ); + + // The replayed-artifact entry point (`applyWireMergeWrites`): the same + // writes after the wire round trip the artifact's `writes` field takes. + const wireWrites = mergePlanArtifactV2Schema.shape.writes.parse( + JSON.parse(JSON.stringify(liveWrites)), + ); + const fromWireArtifact = await requiredExistenceOrphanCandidates( + ctx, + backend, + wireWrites, + ); + + const sortedIds = ( + candidates: readonly Readonly<{ kind: string; id: string }>[], + ): readonly string[] => + candidates + .map((candidate) => `${candidate.kind}/${candidate.id}`) + .sort(); + console.info( + `[${entry.name}] candidates — live plan:`, + sortedIds(fromLivePlan), + "wire artifact:", + sortedIds(fromWireArtifact), + ); + expect(sortedIds(fromLivePlan)).toEqual([ + "UPart/p-detached", + "UPart/p-ended", + "UPart/p-written", + ]); + expect(sortedIds(fromWireArtifact)).toEqual(sortedIds(fromLivePlan)); + }); + // MUTATION CHECK: delete any one of the three source loops in + // `requiredExistenceOrphanCandidates` (src/graph-merge/merge.ts) — the + // matching `UPart/p-*` entry disappears from BOTH views above, since + // both entry points now share that one walk. + }, +); diff --git a/packages/typegraph/tests/graph-merge/composition-orphan.test.ts b/packages/typegraph/tests/graph-merge/composition-orphan.test.ts new file mode 100644 index 000000000..351dba737 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/composition-orphan.test.ts @@ -0,0 +1,606 @@ +/** + * The composition orphan conflict: a part attached on the target + * AFTER the branch point, whose whole the branch deletes, is a typed + * conflict — reported on the plan's review payload at plan time, and refused + * at apply time under the write lock. + * + * Modeled through `planMergeIncremental`/`applyMergePlan`, whose whole + * purpose is a target that moved on independently of the fork point — the + * EXACT shape this conflict needs and the snapshot `base@V` contract + * (`planMerge`) refuses outright (a target write after the fork point is + * precisely what it exists to reject). + * + * Fixture: `Whole` --holds(cardinality "one")-- `Part`, + * `partOf(Part, Whole, { via: holds })`. Two scenarios: + * + * - The target gains a NEW part after the fork point. The branch's own diff + * (against the fork point) knows nothing of it, so it is absent from the + * plan's `nodeDeletions` even though the branch deletes the whole. + * `planMergeIncremental` reports it as a `compositionOrphans` entry; + * `applyMergePlan` refuses with `MergeCompositionOrphanError` (code + * `GRAPH_MERGE_COMPOSITION_ORPHAN`), and NOTHING changes on the target. + * - The mirror: the target MOVES a part to another whole after the fork + * point, and the branch deletes the part's old whole, cascading its own + * copy of the part. The plan deletes the part, which the target now holds + * under a whole the plan leaves standing; it is reported with + * `cause: "moved"` and refused the same way, through the live merge too. + * - The branch deletes the whole AND its part (the ordinary case: the + * branch's own runtime cascade already recorded the part's deletion in + * its local diff against the fork point, so the plan's `nodeDeletions` + * already carries both, and the target evolved no differently). Apply + * succeeds with `cascadeComposition: false` — trusting the plan rather + * than re-cascading — and a delete-count hook proves EXACTLY the planned + * two nodes were deleted, not more. + * + * MUTATION CHECK, with a caveat: flipping merge apply's policy to + * `cascadeComposition: true` does NOT change either scenario's outcome in + * THIS minimal two-node fixture — `executeNodeDelete`'s own "already + * tombstoned" gate absorbs the resulting redundant delete attempt as a + * silent no-op regardless of which of the two co-planned rows the deletion + * loop reaches first. The runtime-cascade DISABLE mutation is instead + * verified directly in `composition-cascade.test.ts` (disabling + * `applyCompositionCascade` itself fails six other tests there), and this + * file's own first scenario proves the orphan refusal fires with zero rows + * changed regardless of `cascadeComposition`. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + asNodeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { MergeCompositionOrphanError } from "../../src/graph-merge/errors"; +import { + applyMergePlan, + mergeIncremental, + planMergeIncremental, +} from "../../src/graph-merge/merge"; +import { isErr, isOk, unwrap } from "../../src/graph-merge/result"; +import { asBranchId, type GraphBranch } from "../../src/graph-merge/types"; +import { backendMatrix } from "./test-utils"; + +const Whole = defineNode("Whole", { schema: z.object({}) }); +const Part = defineNode("Part", { schema: z.object({}) }); +const holds = defineEdge("holds", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition-orphan-test", + nodes: { + Whole: { type: Whole }, + Part: { type: Part }, + }, + edges: { + holds: { + type: holds, + from: [Part], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [partOf(Part, Whole, { via: holds })], +}); +type G = typeof graph; + +const BRANCH_A = asBranchId("branch-a"); +const TARGET_CLONE = asBranchId("target-clone"); + +describe.each(backendMatrix())( + "composition orphan conflict [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + afterEach(async () => { + for (const cleanup of cleanups ?? []) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeStore( + onOperationEnd?: ( + ctx: Readonly<{ operation: string; id: string }>, + ) => void, + ): Promise> { + const [store] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + ...(onOperationEnd === undefined ? {} : { hooks: { onOperationEnd } }), + }); + return store; + } + + async function makeBranchOf( + forkPoint: Store, + id: ReturnType, + ): Promise> { + const result = await branch(forkPoint, () => makeBackend(), { id }); + if (isErr(result)) throw result.error; + return result.data; + } + + it("reports and refuses a part attached to the target after the branch point", async () => { + cleanups = []; + // The fork point: the state the branch saw. + const forkPoint = await makeStore(); + const whole = await forkPoint.nodes.Whole.create({}, { id: "w1" }); + const originalPart = await forkPoint.nodes.Part.create({}, { id: "p1" }); + await forkPoint.edges.holds.create(originalPart, whole, {}); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + // The branch deletes the whole, cascading its OWN copy of the + // original part (recorded in the branch's diff against the fork + // point). + await branchA.store.nodes.Whole.delete(whole.id); + + // The TARGET: independently at the same fork-point state, but with a + // SECOND part attached after the branch was forked — something the + // branch's diff has no way to know about. + const target = await makeStore(); + const targetWhole = await target.nodes.Whole.create({}, { id: "w1" }); + const targetOriginalPart = await target.nodes.Part.create( + {}, + { id: "p1" }, + ); + await target.edges.holds.create(targetOriginalPart, targetWhole, {}); + const lateAttachedPart = await target.nodes.Part.create({}, { id: "p2" }); + await target.edges.holds.create(lateAttachedPart, targetWhole, {}); + + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(isOk(planResult)).toBe(true); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "Part", id: "p2" }, + whole: { kind: "Whole", id: "w1" }, + viaEdgeKind: "holds", + cause: "deleted", + }, + ]); + + const applyResult = await applyMergePlan(target, artifact); + expect(isErr(applyResult)).toBe(true); + if (!isErr(applyResult)) throw new Error("expected apply to be refused"); + expect(applyResult.error).toBeInstanceOf(MergeCompositionOrphanError); + expect(applyResult.error.code).toBe("GRAPH_MERGE_COMPOSITION_ORPHAN"); + + // Nothing changed: the refusal fires before any row work in this apply. + await expect( + target.nodes.Whole.getById(targetWhole.id), + ).resolves.toBeDefined(); + await expect( + target.nodes.Part.getById(targetOriginalPart.id), + ).resolves.toBeDefined(); + await expect( + target.nodes.Part.getById(lateAttachedPart.id), + ).resolves.toBeDefined(); + }); + + it("reports and refuses deleting a part the target moved to a whole the plan keeps", async () => { + cleanups = []; + const forkPoint = await makeStore(); + await forkPoint.nodes.Whole.create({}, { id: "w1" }); + await forkPoint.nodes.Whole.create({}, { id: "w2" }); + await forkPoint.nodes.Part.create( + {}, + { id: "p1", partOf: { whole: { kind: "Whole", id: "w1" } } }, + ); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + // The branch deletes the whole; its cascade deletes the branch's own + // copy of the part. + await branchA.store.nodes.Whole.delete(asNodeId("w1")); + await expect( + branchA.store.nodes.Part.getById(asNodeId("p1")), + ).resolves.toBe(undefined); + + // The target is a clone of the fork point that moves the part to the + // second whole — an edge the branch never saw. + const target = (await makeBranchOf(forkPoint, TARGET_CLONE)).store; + await target.nodes.Part.reparent(asNodeId("p1"), { + whole: { kind: "Whole", id: "w2" }, + }); + + const artifact = unwrap( + await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }), + ); + console.info("composition orphans", artifact.review.compositionOrphans); + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "Part", id: "p1" }, + whole: { kind: "Whole", id: "w2" }, + viaEdgeKind: "holds", + cause: "moved", + }, + ]); + + const applyResult = await applyMergePlan(target, artifact); + if (!isErr(applyResult)) throw new Error("expected apply to be refused"); + console.info("apply refusal", applyResult.error.message); + expect(applyResult.error).toBeInstanceOf(MergeCompositionOrphanError); + expect(applyResult.error.code).toBe("GRAPH_MERGE_COMPOSITION_ORPHAN"); + expect(applyResult.error.details).toEqual( + artifact.review.compositionOrphans[0], + ); + + const merged = await mergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + if (!isErr(merged)) throw new Error("expected the merge to be refused"); + expect(merged.error).toBeInstanceOf(MergeCompositionOrphanError); + + // Nothing changed: both wholes, the part and its new edge survive. + await expect( + target.nodes.Whole.getById(asNodeId("w1")), + ).resolves.toBeDefined(); + await expect( + target.nodes.Part.getById(asNodeId("p1")), + ).resolves.toBeDefined(); + const edges = await target.edges.holds.find(); + expect(edges.map((edge) => edge.toId)).toEqual(["w2"]); + }); + + it("applies a branch that deletes a part the target left under its whole", async () => { + cleanups = []; + const forkPoint = await makeStore(); + await forkPoint.nodes.Whole.create({}, { id: "w1" }); + await forkPoint.nodes.Part.create( + {}, + { id: "p1", partOf: { whole: { kind: "Whole", id: "w1" } } }, + ); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + await branchA.store.nodes.Part.delete(asNodeId("p1")); + + const target = (await makeBranchOf(forkPoint, TARGET_CLONE)).store; + + const artifact = unwrap( + await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }), + ); + expect(artifact.review.compositionOrphans).toEqual([]); + unwrap(await applyMergePlan(target, artifact)); + await expect(target.nodes.Part.getById(asNodeId("p1"))).resolves.toBe( + undefined, + ); + await expect( + target.nodes.Whole.getById(asNodeId("w1")), + ).resolves.toBeDefined(); + expect(await target.edges.holds.find()).toEqual([]); + }); + + it("does not report an already-dead part (its composition edge survives its endpoint) as an orphan", async () => { + cleanups = []; + const forkPoint = await makeStore(); + const whole = await forkPoint.nodes.Whole.create({}, { id: "w1" }); + const originalPart = await forkPoint.nodes.Part.create({}, { id: "p1" }); + await forkPoint.edges.holds.create(originalPart, whole, {}); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + await branchA.store.nodes.Whole.delete(whole.id); + + // The target: independently at the same fork-point state, but with a + // SECOND part whose node row is already dead while its `holds` edge + // is still live — the state a direct `restrict` delete can no longer + // leave behind, reachable today only from a legacy row or + // a write that bypasses the ordinary delete pipeline. Written directly + // through the backend seam (never through `store.nodes.Part.delete`, + // which cleans up its own composition edges) to construct that state + // deliberately. + const targetBackend = await makeBackend(); + const [target] = await createStoreWithSchema(graph, targetBackend, { + revisionTracking: true, + }); + const targetWhole = await target.nodes.Whole.create({}, { id: "w1" }); + const targetOriginalPart = await target.nodes.Part.create( + {}, + { id: "p1" }, + ); + await target.edges.holds.create(targetOriginalPart, targetWhole, {}); + const deadPart = await target.nodes.Part.create({}, { id: "p2" }); + await target.edges.holds.create(deadPart, targetWhole, {}); + await targetBackend.deleteNode({ + graphId: graph.id, + kind: "Part", + id: deadPart.id, + }); + + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(isOk(planResult)).toBe(true); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + // MUTATION: drop `liveDiscoveredMembers`'s filter from + // `planCompositionCascade` (keep every discovered member, live or not) and + // this reports `p2` as an orphan instead of an empty array — a dead + // node is not a live composition part for a caller to act on. + expect(artifact.review.compositionOrphans).toEqual([]); + + const applyResult = await applyMergePlan(target, artifact); + expect(isOk(applyResult)).toBe(true); + + await expect( + target.nodes.Whole.getById(targetWhole.id), + ).resolves.toBeUndefined(); + await expect( + target.nodes.Part.getById(targetOriginalPart.id), + ).resolves.toBeUndefined(); + await expect( + target.nodes.Part.getById(deadPart.id), + ).resolves.toBeUndefined(); + }); + + it("applies a plan that already carries the whole AND its part, deleting exactly those two — no extra cascade", async () => { + cleanups = []; + const forkPoint = await makeStore(); + const whole = await forkPoint.nodes.Whole.create({}, { id: "w1" }); + const part = await forkPoint.nodes.Part.create({}, { id: "p1" }); + await forkPoint.edges.holds.create(part, whole, {}); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + // The branch's own cascade deletes both the whole and its part; both + // land in the branch's diff against the fork point. + await branchA.store.nodes.Whole.delete(whole.id); + + // The target evolved no differently from the fork point. + const deletedIds: string[] = []; + const target = await makeStore((ctx) => { + if (ctx.operation === "delete") deletedIds.push(ctx.id); + }); + const targetWhole = await target.nodes.Whole.create({}, { id: "w1" }); + const targetPart = await target.nodes.Part.create({}, { id: "p1" }); + await target.edges.holds.create(targetPart, targetWhole, {}); + + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(isOk(planResult)).toBe(true); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + expect(artifact.review.compositionOrphans).toEqual([]); + expect( + [...artifact.writes.nodeDeletes].map((entity) => entity.id).toSorted(), + ).toEqual(["p1", "w1"]); + + deletedIds.length = 0; + const applyResult = await applyMergePlan(target, artifact); + expect(isOk(applyResult)).toBe(true); + + await expect( + target.nodes.Whole.getById(targetWhole.id), + ).resolves.toBeUndefined(); + await expect( + target.nodes.Part.getById(targetPart.id), + ).resolves.toBeUndefined(); + + // Exactly the planned two deletes — `cascadeComposition: false` means + // apply trusted the plan rather than re-walking the closure and + // attempting a redundant delete of the already-deleted part. + expect(deletedIds.toSorted()).toEqual(["p1", "w1"]); + }); + }, +); + +// ============================================================ +// Depth-2 fixture: Whole -[holds]- Part -[holdsLeaf]- Leaf +// +// `compositionOrphansAmong` walks the closure from the ROOT whole +// (`Whole`), so a depth-2 member (`Leaf`) never has a directly declared +// composition pair against the root. Its own immediate whole is `Part`, +// realized by a DIFFERENT edge kind (`holdsLeaf`) than the one that binds +// `Part` to `Whole` (`holds`). This is what regresses a fix that re-derives +// "which pair realizes this membership" via +// `registry.compositionPairsBetween(member.kind, root.whole.kind)` instead of +// reading the pair `planCompositionCascade` already resolved per member. +// ============================================================ + +const Leaf = defineNode("Leaf", { schema: z.object({}) }); +const holdsLeaf = defineEdge("holdsLeaf", { schema: z.object({}) }); + +const nestedGraph = defineGraph({ + id: "composition-orphan-nested-test", + nodes: { + Whole: { type: Whole }, + Part: { type: Part, onDelete: "disconnect" }, + Leaf: { type: Leaf, onDelete: "disconnect" }, + }, + edges: { + holds: { type: holds, from: [Part], to: [Whole], cardinality: "one" }, + holdsLeaf: { + type: holdsLeaf, + from: [Leaf], + to: [Part], + cardinality: "one", + }, + }, + ontology: [ + partOf(Part, Whole, { via: holds }), + partOf(Leaf, Part, { via: holdsLeaf }), + ], +}); +type NestedG = typeof nestedGraph; + +describe.each(backendMatrix())( + "composition orphan conflict — depth 2 [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + afterEach(async () => { + for (const cleanup of cleanups ?? []) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeStore(): Promise> { + const [store] = await createStoreWithSchema( + nestedGraph, + await makeBackend(), + { revisionTracking: true }, + ); + return store; + } + + async function makeBranchOf( + forkPoint: Store, + id: ReturnType, + ): Promise> { + const result = await branch(forkPoint, () => makeBackend(), { + id, + }); + if (isErr(result)) throw result.error; + return result.data; + } + + it("carries a deeper part away with the intermediate part a branch moves off the whole it deletes", async () => { + cleanups = []; + // Fork point: w1 <- p1 <- l1, and a second whole w2. + const forkPoint = await makeStore(); + const oldWhole = await forkPoint.nodes.Whole.create({}, { id: "w1" }); + const newWhole = await forkPoint.nodes.Whole.create({}, { id: "w2" }); + const part = await forkPoint.nodes.Part.create({}, { id: "p1" }); + const leaf = await forkPoint.nodes.Leaf.create({}, { id: "l1" }); + const binding = await forkPoint.edges.holds.create(part, oldWhole, {}); + await forkPoint.edges.holdsLeaf.create(leaf, part, {}); + const target = (await makeBranchOf(forkPoint, TARGET_CLONE)).store; + + // The branch moves p1 (and so l1 beneath it) to w2, then deletes w1. + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + await branchA.store.edges.holds.delete(binding.id); + await branchA.store.edges.holds.create(part, newWhole, {}); + await branchA.store.nodes.Whole.delete(oldWhole.id); + + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + if (!isOk(planResult)) throw planResult.error; + expect(planResult.data.review.compositionOrphans).toEqual([]); + + const applyResult = await applyMergePlan(target, planResult.data); + if (isErr(applyResult)) throw applyResult.error; + await expect(target.nodes.Whole.getById(oldWhole.id)).resolves.toBe( + undefined, + ); + await expect(target.nodes.Leaf.getById(leaf.id)).resolves.toBeDefined(); + const heldBy = await target.edges.holds.findFrom(part); + expect(heldBy.map((edge) => edge.toId)).toEqual([newWhole.id]); + }); + + it("reports the orphan against its OWN immediate whole (Part), not the cascade root (Whole), for a part attached two levels deep", async () => { + cleanups = []; + // Fork point: Whole w1 with Part p1, no Leaf yet. + const forkPoint = await makeStore(); + const whole = await forkPoint.nodes.Whole.create({}, { id: "w1" }); + const part = await forkPoint.nodes.Part.create({}, { id: "p1" }); + await forkPoint.edges.holds.create(part, whole, {}); + + const branchA = await makeBranchOf(forkPoint, BRANCH_A); + // The branch deletes the whole, cascading its own copy of Part. + await branchA.store.nodes.Whole.delete(whole.id); + + // The TARGET: same fork-point state, but a Leaf was attached under + // Part AFTER the fork — two levels below the whole the branch deletes. + const target = await makeStore(); + const targetWhole = await target.nodes.Whole.create({}, { id: "w1" }); + const targetPart = await target.nodes.Part.create({}, { id: "p1" }); + await target.edges.holds.create(targetPart, targetWhole, {}); + const lateLeaf = await target.nodes.Leaf.create({}, { id: "l1" }); + await target.edges.holdsLeaf.create(lateLeaf, targetPart, {}); + + // Before the fix: this throws a raw `TypeError` out of + // `requireDefined` (wrapped as `MergeError`/`GRAPH_MERGE_ERROR`) + // instead of returning a plan, because no pair declares "Leaf" + // directly under "Whole". + const planResult = await planMergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(isOk(planResult)).toBe(true); + if (!isOk(planResult)) throw planResult.error; + const artifact = planResult.data; + + // MUTATION (verified): reverting `compositionOrphansAmong` to its + // pre-fix shape — a plain `orphans.push` (no dedupe) that re-derives + // the pair via a (part, whole)-only pair lookup + // (the ROOT `whole` of the OUTER loop, not `member.whole`) — makes + // `isOk(planResult)` false above (the TypeError, wrapped as + // `GRAPH_MERGE_ERROR`). Reverting ONLY the pair-derivation while + // LEAVING the dedupe-by-part-key in place does not reliably reproduce + // the crash: `Part` "p1" is itself in `wholes` (it declares its own + // composition parts, via `holdsLeaf`), and `Part`'s OWN walk resolves + // Leaf's pair correctly (`whole.kind` IS `member`'s immediate whole at + // that call site) — if `wholes` happens to order `p1` before `w1`, the + // dedupe skips `w1`'s walk over the same member before it reaches the + // buggy line. The revert/restore check above is against the FULL + // pre-fix function, which is the reliable, order-independent + // reproduction. + expect(artifact.review.compositionOrphans).toEqual([ + { + part: { kind: "Leaf", id: "l1" }, + whole: { kind: "Part", id: "p1" }, + viaEdgeKind: "holdsLeaf", + cause: "deleted", + }, + ]); + + const applyResult = await applyMergePlan(target, artifact); + expect(isErr(applyResult)).toBe(true); + if (!isErr(applyResult)) throw new Error("expected apply to be refused"); + expect(applyResult.error).toBeInstanceOf(MergeCompositionOrphanError); + expect(applyResult.error.code).toBe("GRAPH_MERGE_COMPOSITION_ORPHAN"); + + // Nothing changed. + await expect( + target.nodes.Whole.getById(targetWhole.id), + ).resolves.toBeDefined(); + await expect( + target.nodes.Part.getById(targetPart.id), + ).resolves.toBeDefined(); + await expect( + target.nodes.Leaf.getById(lateLeaf.id), + ).resolves.toBeDefined(); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/durable-branch.test.ts b/packages/typegraph/tests/graph-merge/durable-branch.test.ts index a1386e25e..a998fdbf2 100644 --- a/packages/typegraph/tests/graph-merge/durable-branch.test.ts +++ b/packages/typegraph/tests/graph-merge/durable-branch.test.ts @@ -40,11 +40,13 @@ import { defineGraph, defineGraphExtension, defineNode, + partOf, } from "../../src"; import { deriveBackend } from "../../src/backend/derive-backend"; import { createSqliteBackend } from "../../src/backend/drizzle/sqlite"; import type { GraphBackend } from "../../src/backend/types"; import type { EngineRevision, LineageMembers } from "../../src/backend/types"; +import type { GraphDef } from "../../src/core/define-graph"; import { applyDurableMergePlan, applyMergePlan, @@ -64,6 +66,7 @@ import { } from "../../src/graph-merge"; import { cloneWorkingCopyStrategy } from "../../src/graph-merge/working-copy"; import { storeBackend } from "../../src/store/runtime-port"; +import type { Store } from "../../src/store/store"; import { createSqliteMergeBackend } from "./test-utils"; const Person = defineNode("Person", { @@ -100,6 +103,27 @@ const divergentGraph = defineGraph({ edges: {}, }); +/** Operational identity: a merged `same` assertion is identity-ledger work. */ +const identityGraph = defineGraph({ + id: "durable-branch-identity", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const Album = defineNode("Album", { schema: z.object({}) }); +const Track = defineNode("Track", { schema: z.object({}) }); +const trackOf = defineEdge("trackOf", { schema: z.object({}) }); +/** Composition: a merged part owes cascade and single-whole enforcement. */ +const compositionGraph = defineGraph({ + id: "durable-branch-composition", + nodes: { Album: { type: Album }, Track: { type: Track } }, + edges: { + trackOf: { type: trackOf, from: [Track], to: [Album], cardinality: "one" }, + }, + ontology: [partOf(Track, Album, { via: trackOf })], +}); + /** The opaque, JSON-serializable locator the fake in-memory strategy stores. */ type LocatorDescriptor = Readonly<{ locator: string }>; @@ -138,8 +162,8 @@ function sameOrigin(a: DurableBranchOrigin, b: DurableBranchOrigin): boolean { ); } -type FakeDurableHost = Readonly<{ - strategy: DurableWorkingCopyStrategy; +type FakeDurableHost = Readonly<{ + strategy: DurableWorkingCopyStrategy; /** Overrides the complete origin the host attests for a locator. */ attest: (locator: string, origin: DurableBranchOrigin) => void; /** How many working-copy connections have been closed. */ @@ -174,9 +198,9 @@ type FakeDurableHostOptions = Readonly<{ * from the base via the ordinary clone strategy; `seal` records the attested * origin; `reopen` reconnects to that database; `destroy` verifies then deletes. */ -function createFakeDurableHost( +function createFakeDurableHost( options: FakeDurableHostOptions = {}, -): FakeDurableHost { +): FakeDurableHost { const databases = new Map(); const allocations = new Map< string, @@ -211,7 +235,7 @@ function createFakeDurableHost( return database; }; - const strategy: DurableWorkingCopyStrategy = { + const strategy: DurableWorkingCopyStrategy = { type: "fake-in-memory-durable-host", version: 1, create: async (baseStore, base, branchId, allocationId) => { @@ -244,7 +268,7 @@ function createFakeDurableHost( forkRevision: FIXED_REVISION, }; } - const seed = cloneWorkingCopyStrategy(() => + const seed = cloneWorkingCopyStrategy(() => Promise.resolve(openDurableBackend(database)), ); const store = await seed.create(baseStore, base); @@ -693,6 +717,84 @@ describe("durable branch", () => { await created.branch.close(); }); + /** + * A plan owing identity or composition work is applied by the portable + * applier inside the target transaction, so the ledger and the composition + * edge land with the node rows. + */ + async function assertDurableApplyForSemanticPlan( + semanticGraph: THostGraph, + mutate: (branchStore: Store) => Promise, + verify: (baseStore: Store) => Promise, + ): Promise { + const semanticHost = createFakeDurableHost(); + cleanups.push(async () => { + semanticHost.closeAll(); + }); + const fixture = createSqliteMergeBackend(); + cleanups.push(fixture.cleanup); + const [baseStore] = await createStoreWithSchema( + semanticGraph, + fixture.backend, + { revisionTracking: true }, + ); + const created = unwrap( + await branchDurable(baseStore, semanticHost.strategy), + ); + await mutate(created.branch.store); + const plan = unwrap(await planMerge(baseStore, [created.branch])); + + const applied = await applyDurableMergePlan({ + target: baseStore, + branch: created.branch, + descriptor: created.descriptor, + strategy: semanticHost.strategy, + plan, + }); + + if (isErr(applied)) throw applied.error; + await verify(baseStore); + await created.branch.close(); + } + + it("applies a durable plan carrying identity assertions", async () => { + await assertDurableApplyForSemanticPlan( + identityGraph, + async (branchStore) => { + await branchStore.nodes.Person.create({ name: "Ada" }, { id: "a" }); + await branchStore.nodes.Person.create({ name: "Ada L." }, { id: "b" }); + await branchStore.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + }, + async (baseStore) => { + await expect( + baseStore.identity.areSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ), + ).resolves.toBe(true); + }, + ); + }); + + it("applies a durable plan writing composition parts and edges", async () => { + await assertDurableApplyForSemanticPlan( + compositionGraph, + async (branchStore) => { + const album = await branchStore.nodes.Album.create({}, { id: "album" }); + await branchStore.nodes.Track.create( + {}, + { id: "track", partOf: { whole: { kind: "Album", id: album.id } } }, + ); + }, + async (baseStore) => { + await expect(baseStore.edges.trackOf.find()).resolves.toHaveLength(1); + }, + ); + }); + it("refuses a wrong strategy, wrong descriptor version, malformed envelope, and wrong graph", async () => { const { baseStore } = await seedBase(); const created = unwrap( diff --git a/packages/typegraph/tests/graph-merge/edge-acyclicity-merge.test.ts b/packages/typegraph/tests/graph-merge/edge-acyclicity-merge.test.ts new file mode 100644 index 000000000..187ef17f7 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/edge-acyclicity-merge.test.ts @@ -0,0 +1,538 @@ +/** + * Edge acyclicity in the merge planner: a cycle that would only exist once a merge combines edges + * from more than one branch — or once canonicalization/repointing collapses + * two staged nodes onto one survivor — is caught at PLAN TIME, before any + * write, and surfaced as the typed `AcyclicityMergeConflictError` rather + * than discovered only when the write is attempted. + * + * `resolveMerge` (src/graph-merge/merge.ts) runs + * `assertResolvedPlanEdgesAcyclic` immediately after the internal plan is + * built — the same point the one-id-one-truth check already runs its own + * plan-time validation — over the plan's `mergedEdges` (already POST + * repoint + dedupe, so a repointed id is already the survivor by the time + * this reads it), resolved to their FINAL `(kind, id)` via + * `finalEdgeEndpoint`. It calls `readProposedEdgeAcyclicityViolations`, the + * lock-free preview built on the SAME `buildEdgeAcyclicityProbe` SQL and the + * same in-memory cycle search a batch write's own pre-write check uses, so a + * plan-time verdict and an eventual write verdict can never disagree. The + * edges the plan itself deletes are left out of the walk. + * + * `resolveMerge` is shared by every entry point (`merge`, `mergeAgainstBase`, + * `planMerge`, `planMergeIncremental`, `mergeIncremental`), so the check + * runs identically whether a caller reviews a `MergePlanArtifact` before + * deciding to apply it, or calls the one-shot `merge()`. + * + * Apply-time re-verification is UNCHANGED (a plan-time + * conflict, an apply-time drift is still a refusal). The plan-time preview + * takes no per-graph write lock (see `assertResolvedPlanEdgesAcyclic`'s + * docblock) and is inherently racy against a concurrent writer, so a cycle + * that only appears from a write landing between planning and commit is + * still caught by the ordinary write-path fence and + * surfaces as `MergeConstraintConflictError` wrapping `EdgeAcyclicityError`, + * exactly as before this change. The write path's own in-batch-cycle + * refusal (`bulkCreate`) is pinned unchanged in + * `tests/backends/integration/edge-acyclicity.ts` ("refuses an in-batch + * cycle in bulkCreate with zero rows committed"). + * + * Mutation check: making `readProposedEdgeAcyclicityViolations` + * (src/store/acyclicity.ts) report only rows whose own `to` already reaches + * their own `from` over stored edges makes `planMerge()` resolve `ok` for the + * purely-proposed cycle in case (a) below instead of refusing — the plan + * "passes" — and a subsequent `applyMergePlan()`-equivalent write then + * refuses via the unchanged apply-time path (`MergeConstraintConflictError` + * / `EdgeAcyclicityError`), never committing the cycle. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + asNodeId, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { AcyclicityMergeConflictError } from "../../src/graph-merge/errors"; +import { merge, planMerge } from "../../src/graph-merge/merge"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import type { GraphBranch } from "../../src/graph-merge/types"; +import { asBranchId } from "../../src/graph-merge/types"; +import { requireDefined } from "../../src/utils/presence"; +import { withBindBudget } from "../backends/integration/bind-budget"; +import { backendMatrix } from "./test-utils"; + +const Task = defineNode("Task", { schema: z.object({ key: z.string() }) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "merge_acyclicity_test", + nodes: { + Task: { + type: Task, + unique: [ + { + name: "key_unique", + fields: ["key"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + acyclic: true, + }, + }, +}); + +describe.each(backendMatrix())( + "merge planner: edge acyclicity plan-time conflict [$name]", + (entry) => { + let cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups.reverse()) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeTarget(): Promise> { + const [target] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + return target; + } + + async function makeBranch( + target: Store, + id: string, + ): Promise> { + return unwrap( + await branch(target, () => makeBackend(), { id: asBranchId(id) }), + ); + } + + it("(a) refuses, at PLAN time, a cycle formed entirely from two branches' proposed edges — planMerge()", async () => { + // Nodes exist at the fork point, before either branch adds edges, so + // both branches resolve "a" / "b" / "c" to the SAME node by shared id + // — no candidate resolution is needed to see the cycle. Neither + // branch alone has one: branch A's chain a -> b -> c never returns to + // "a", and branch B's lone c -> a never reaches "b". Nothing is live + // on the target either, so this is exactly the case the SQL seed-hop + // exists for: the probe has no live edge to walk at all. + const target = await makeTarget(); + await target.nodes.Task.create({ key: "a" }, { id: "a" }); + await target.nodes.Task.create({ key: "b" }, { id: "b" }); + await target.nodes.Task.create({ key: "c" }, { id: "c" }); + + const branchA = await makeBranch(target, "branch-a"); + const branchB = await makeBranch(target, "branch-b"); + await branchA.store.edges.dependsOn.create( + { kind: "Task", id: "a" }, + { kind: "Task", id: "b" }, + {}, + { id: "ab" }, + ); + await branchA.store.edges.dependsOn.create( + { kind: "Task", id: "b" }, + { kind: "Task", id: "c" }, + {}, + { id: "bc" }, + ); + await branchB.store.edges.dependsOn.create( + { kind: "Task", id: "c" }, + { kind: "Task", id: "a" }, + {}, + { id: "ca" }, + ); + + const planResult = await planMerge(target, [branchA, branchB]); + expect(isErr(planResult)).toBe(true); + if (!isErr(planResult)) throw new Error("expected planMerge to refuse"); + const { error } = planResult; + if (!(error instanceof AcyclicityMergeConflictError)) { + throw new Error( + `expected AcyclicityMergeConflictError, got ${error.name}`, + ); + } + expect(error.details.relation).toBe("dependsOn"); + expect(error.details.edges.map((edge) => edge.edgeId).toSorted()).toEqual( + ["ab", "bc", "ca"], + ); + + // Byte-unchanged: planning alone never wrote anything. + const remaining = await target.edges.dependsOn.find({}); + expect(remaining).toEqual([]); + }); + + it("(a) refuses the same cycle for the one-shot merge() surface too", async () => { + const target = await makeTarget(); + await target.nodes.Task.create({ key: "a" }, { id: "a" }); + await target.nodes.Task.create({ key: "b" }, { id: "b" }); + await target.nodes.Task.create({ key: "c" }, { id: "c" }); + + const branchA = await makeBranch(target, "branch-a"); + const branchB = await makeBranch(target, "branch-b"); + await branchA.store.edges.dependsOn.create( + { kind: "Task", id: "a" }, + { kind: "Task", id: "b" }, + {}, + { id: "ab" }, + ); + await branchA.store.edges.dependsOn.create( + { kind: "Task", id: "b" }, + { kind: "Task", id: "c" }, + {}, + { id: "bc" }, + ); + await branchB.store.edges.dependsOn.create( + { kind: "Task", id: "c" }, + { kind: "Task", id: "a" }, + {}, + { id: "ca" }, + ); + + const mergeResult = await merge(target, [branchA, branchB]); + expect(isErr(mergeResult)).toBe(true); + if (!isErr(mergeResult)) throw new Error("expected merge to refuse"); + expect(mergeResult.error).toBeInstanceOf(AcyclicityMergeConflictError); + + const remaining = await target.edges.dependsOn.find({}); + expect(remaining).toEqual([]); + }); + + it("(b) refuses, at PLAN time, a cycle that only exists after canonicalization repoints two branches' duplicate nodes onto one survivor", async () => { + // `hub` pre-exists. Neither branch creates a cycle on its own: branch + // A writes hub -> newA (a brand-new node), branch B writes + // newB -> hub (a DIFFERENT brand-new node). `newA` and `newB` declare + // the SAME unique `key`, so the staged-vs-staged `uniqueSource` + // candidate (src/graph-merge/sources.ts, part of the public merge()'s + // CANDIDATE_SOURCES) folds them into ONE canonical survivor during + // clustering — only THEN do the two edges become hub -> survivor and + // survivor -> hub, a 2-cycle neither branch's own edge set contained + // before repointing. + const target = await makeTarget(); + await target.nodes.Task.create({ key: "hub" }, { id: "hub" }); + + const branchA = await makeBranch(target, "branch-a"); + const branchB = await makeBranch(target, "branch-b"); + await branchA.store.nodes.Task.create( + { key: "dup-key" }, + { id: "new-a" }, + ); + await branchA.store.edges.dependsOn.create( + { kind: "Task", id: "hub" }, + { kind: "Task", id: "new-a" }, + {}, + { id: "hub-to-new" }, + ); + await branchB.store.nodes.Task.create( + { key: "dup-key" }, + { id: "new-b" }, + ); + await branchB.store.edges.dependsOn.create( + { kind: "Task", id: "new-b" }, + { kind: "Task", id: "hub" }, + {}, + { id: "new-to-hub" }, + ); + + // `resolve.Task` must be present (with a `similarity` strategy — the + // options validator requires one even though `uniqueSource`'s forced + // edge does not consult it) for candidate generation to run at all for + // this kind; with no resolve config, a kind merges "by id only" and + // every new node stays its own singleton cluster (no repointing). + const planResult = await planMerge(target, [branchA, branchB], { + resolve: { + Task: { + threshold: 1, + similarity: { kind: "fulltext", fields: ["key"] }, + }, + }, + }); + expect(isErr(planResult)).toBe(true); + if (!isErr(planResult)) throw new Error("expected planMerge to refuse"); + const { error } = planResult; + if (!(error instanceof AcyclicityMergeConflictError)) { + throw new Error( + `expected AcyclicityMergeConflictError, got ${error.name}`, + ); + } + expect(error.details.relation).toBe("dependsOn"); + + // Byte-unchanged: the target still holds only "hub". + const remaining = await target.edges.dependsOn.find({}); + expect(remaining).toEqual([]); + const remainingNodes = await target.nodes.Task.find({}); + expect(remainingNodes.map((node) => node.id)).toEqual(["hub"]); + }); + + // The plan removes an edge and adds its reverse. Apply deletes before it + // writes, so the result is a DAG; the preview must judge that result, not + // the new edge layered onto the row the same plan removes. + // + // MUTATION CHECK (both cases): not passing the plan's edge deletions to + // `readProposedEdgeAcyclicityViolations` in + // `assertResolvedPlanEdgesAcyclic` refuses each with + // AcyclicityMergeConflictError — verified and reverted. + it("(d) previews more proposed edges than one acyclicity statement can carry", async () => { + const bindBudget = 100; + const edgeCount = 30; + const [target] = await createStoreWithSchema( + graph, + withBindBudget(await makeBackend(), bindBudget), + { revisionTracking: true }, + ); + const ids = Array.from( + { length: edgeCount + 1 }, + (_unused, index) => `t${String(index)}`, + ); + for (const id of ids) { + await target.nodes.Task.create({ key: id }, { id }); + } + + const chained = await makeBranch(target, "chained"); + for (const [index, id] of ids.slice(0, -1).entries()) { + await chained.store.edges.dependsOn.create( + { kind: "Task", id }, + { kind: "Task", id: requireDefined(ids[index + 1]) }, + {}, + ); + } + + // MUTATION CHECK: sending every proposed edge in one statement + // (`readUnwrittenEdgeReaches` never slicing) binds more than the budget + // for these 30 edges, and the budgeted backend refuses the plan's + // preview — verified and reverted. + expect(isErr(await planMerge(target, [chained]))).toBe(false); + }); + + it("(c) merges a branch that reverses an edge of an acyclic relation", async () => { + const target = await makeTarget(); + await target.nodes.Task.create({ key: "a" }, { id: "a" }); + await target.nodes.Task.create({ key: "b" }, { id: "b" }); + const forward = await target.edges.dependsOn.create( + { kind: "Task", id: "a" }, + { kind: "Task", id: "b" }, + {}, + { id: "ab" }, + ); + + const reversal = await makeBranch(target, "reversal"); + await reversal.store.edges.dependsOn.delete(forward.id); + await reversal.store.edges.dependsOn.create( + { kind: "Task", id: "b" }, + { kind: "Task", id: "a" }, + {}, + { id: "ba" }, + ); + + expect(isErr(await planMerge(target, [reversal]))).toBe(false); + expect(isErr(await merge(target, [reversal]))).toBe(false); + + const stored = await target.edges.dependsOn.find({}); + expect(stored.map((edge) => edge.id)).toEqual(["ba"]); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("(c) merges a branch that re-roots a chain of an acyclic relation", async () => { + const target = await makeTarget(); + for (const id of ["a", "b", "c"]) { + await target.nodes.Task.create({ key: id }, { id }); + } + await target.edges.dependsOn.create( + { kind: "Task", id: "a" }, + { kind: "Task", id: "b" }, + {}, + { id: "ab" }, + ); + const tail = await target.edges.dependsOn.create( + { kind: "Task", id: "b" }, + { kind: "Task", id: "c" }, + {}, + { id: "bc" }, + ); + + const reroot = await makeBranch(target, "reroot"); + await reroot.store.edges.dependsOn.delete(tail.id); + await reroot.store.edges.dependsOn.create( + { kind: "Task", id: "c" }, + { kind: "Task", id: "a" }, + {}, + { id: "ca" }, + ); + + expect(isErr(await merge(target, [reroot]))).toBe(false); + + const stored = await target.edges.dependsOn.find({}); + expect(stored.map((edge) => edge.id).toSorted()).toEqual(["ab", "ca"]); + }); + }, +); + +/** + * A reflexive `oneActive` composition: a move ENDS the incumbent's window, + * and the ended row is history, outside the composition relation. A branch, + * a clone and a merge must all judge it that way, or the documented "former + * ancestor under former descendant" move becomes unmergeable. + */ +const Folder = defineNode("Folder", { schema: z.object({}) }); +const inFolder = defineEdge("inFolder", { + schema: z.object({ note: z.string().optional() }), +}); +const folderGraph = defineGraph({ + id: "merge_composition_history_test", + nodes: { Folder: { type: Folder } }, + edges: { + inFolder: { + type: inFolder, + from: [Folder], + to: [Folder], + cardinality: "oneActive", + }, + }, + ontology: [partOf(Folder, Folder, { via: inFolder, partSide: "from" })], +}); + +const folder = (id: string) => ({ kind: "Folder" as const, id }); + +describe.each(backendMatrix())( + "merge planner: composition history is outside the composition relation [$name]", + (entry) => { + let cleanups: (() => Promise)[] = []; + + afterEach(async () => { + for (const cleanup of cleanups.reverse()) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + /** `B` moved out of `A` (that row ended) and `A` then placed under `B`. */ + async function storeWithHistory(): Promise> { + const [store] = await createStoreWithSchema( + folderGraph, + await makeBackend(), + { revisionTracking: true }, + ); + await store.nodes.Folder.create({}, { id: "A" }); + await store.nodes.Folder.create({}, { id: "C" }); + await store.nodes.Folder.create( + {}, + { id: "B", partOf: { whole: folder("A") } }, + ); + await store.nodes.Folder.reparent(asNodeId("B"), { + whole: folder("C"), + }); + await store.edges.inFolder.create(folder("A"), folder("B"), {}); + expect(await store.verifyConstraintFences()).toEqual([]); + return store; + } + + async function liveLinks( + store: Store, + ): Promise { + return (await store.edges.inFolder.find({})) + .map((edge) => `${edge.fromId}->${edge.toId}`) + .toSorted(); + } + + it("(e) branches a store whose history placed a former whole under its part", async () => { + const source = await storeWithHistory(); + + // MUTATION CHECK: drop the `acyclicRelationCountsRow` filter from + // `groupProposedByAcyclicRelation` (src/store/acyclicity.ts) — the + // clone's import then refuses the ended `B -> A` row as closing a + // cycle, and `branch()` fails with a `BranchError`. + const cloned = unwrap( + await branch(source, () => makeBackend(), { id: asBranchId("copy") }), + ); + + expect( + await cloned.store.edges.inFolder.find( + {}, + { temporalMode: "includeEnded" }, + ), + ).toHaveLength(3); + expect(await liveLinks(cloned.store)).toEqual(await liveLinks(source)); + }); + + it("(f) merges a branch that moves a part out and then places its former whole under it", async () => { + const [target] = await createStoreWithSchema( + folderGraph, + await makeBackend(), + { revisionTracking: true }, + ); + await target.nodes.Folder.create({}, { id: "A" }); + await target.nodes.Folder.create({}, { id: "C" }); + await target.nodes.Folder.create( + {}, + { id: "B", partOf: { whole: folder("A") } }, + ); + const moves = unwrap( + await branch(target, () => makeBackend(), { id: asBranchId("moves") }), + ); + await moves.store.nodes.Folder.reparent(asNodeId("B"), { + whole: folder("C"), + }); + await moves.store.edges.inFolder.create(folder("A"), folder("B"), {}); + + // MUTATION CHECK: project `validTo: undefined` for every merged edge in + // `resolvedPlanAcyclicityCandidates` (src/graph-merge/merge.ts) — the + // preview then counts the ended `B -> A` as a member and refuses the + // plan with `AcyclicityMergeConflictError`. + const result = await merge(target, [moves]); + expect(isErr(result)).toBe(false); + expect(await liveLinks(target)).toEqual(["A->B", "B->C"]); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("(g) merges a branch that edits an ended history row without restating its window", async () => { + const target = await storeWithHistory(); + const ended = requireDefined( + ( + await target.edges.inFolder.find({}, { temporalMode: "includeEnded" }) + ).find((edge) => edge.meta.validTo !== undefined), + ); + const edits = unwrap( + await branch(target, () => makeBackend(), { id: asBranchId("edits") }), + ); + await edits.store.edges.inFolder.update(ended.id, { note: "moved" }); + + // MUTATION CHECK: read no stored row in + // `resolvedPlanAcyclicityCandidates` (src/graph-merge/merge.ts), + // treating a write that states no window end as open — the preview then + // proposes the ended row as a live member and refuses the plan. + const result = await merge(target, [edits]); + expect(isErr(result)).toBe(false); + const edited = requireDefined( + await target.edges.inFolder.getById(ended.id, { + temporalMode: "includeEnded", + }), + ); + expect(edited.note).toBe("moved"); + expect(edited.meta.validTo).toBe(ended.meta.validTo); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/fresh-snapshot-fence.test.ts b/packages/typegraph/tests/graph-merge/fresh-snapshot-fence.test.ts index a4b897c38..ec2ace789 100644 --- a/packages/typegraph/tests/graph-merge/fresh-snapshot-fence.test.ts +++ b/packages/typegraph/tests/graph-merge/fresh-snapshot-fence.test.ts @@ -14,9 +14,14 @@ const EXECUTION_CAPABILITIES = { unitOfWork: "interactive", } as const; +/** + * A transaction whose fence statement reports `isolation` and, when + * `generation` is given, an advanced fence row. + */ function transactionBackend( writeFence: BackendCapabilities["writeFence"], isolation: unknown, + generation?: number, ): TransactionBackend { return { dialect: "postgres", @@ -26,7 +31,13 @@ function transactionBackend( }, fenceSql: postgresFenceSql, lockSchemaVersionForWrite: () => Promise.resolve(), - execute: () => Promise.resolve([{ transaction_isolation: isolation }]), + execute: () => + Promise.resolve([ + { + transaction_isolation: isolation, + ...(generation === undefined ? {} : { generation }), + }, + ]), } as unknown as TransactionBackend; } @@ -82,4 +93,48 @@ describe("graph-merge fresh-snapshot fences", () => { ); expect(witness.mutated).toBe(false); }); + const ADVISORY_FENCE = { + mechanism: "advisory", + drain: "table-lock", + } as const; + + // The fence row is advanced by the holders that make a lock-only decision, + // not by every holder, so advancing it shows nothing about a holder that + // only captured history or a revision. The applier reads the whole target: + // only a per-statement snapshot observes every earlier holder. + it.each([ + ["repeatable read", "repeatable_read"], + ["serializable", "serializable"], + ])( + "refuses a %s session even when its acquisition advanced the fence row", + async (isolation, reported) => { + const backend = transactionBackend(ADVISORY_FENCE, isolation, 7); + const witness = { mutated: false }; + + await expect(mutateAfterFreshFence(backend, witness)).rejects.toEqual( + expect.objectContaining({ + name: "MergePlanCapabilityError", + details: expect.objectContaining({ + capability: "mergeCallbackIsolation", + isolation: reported, + }) as unknown, + }), + ); + expect(witness.mutated).toBe(false); + }, + ); + + it.each([undefined, 7])( + "accepts a read-committed session (fence-row generation: %s)", + async (generation) => { + const witness = { mutated: false }; + + await mutateAfterFreshFence( + transactionBackend(ADVISORY_FENCE, "read committed", generation), + witness, + ); + + expect(witness.mutated).toBe(true); + }, + ); }); diff --git a/packages/typegraph/tests/graph-merge/identity-commit-closure.test.ts b/packages/typegraph/tests/graph-merge/identity-commit-closure.test.ts index ecf7647d2..c2a40b751 100644 --- a/packages/typegraph/tests/graph-merge/identity-commit-closure.test.ts +++ b/packages/typegraph/tests/graph-merge/identity-commit-closure.test.ts @@ -103,6 +103,7 @@ function emptyFoldPlan(): MergePlan { warnings: [], identityAssertions: [], identityRetractions: [], + identityConflicts: [], canonicalOf: new Map(), }; } @@ -327,6 +328,7 @@ describe("affectedIdentityClassSeeds", () => { canonicalEntities: [], identityAssertions: [], identityRetractions: [], + identityConflicts: [], nodeDeletions: new Map(), retypeMap: new Map(), canonicalOf: new Map(), diff --git a/packages/typegraph/tests/graph-merge/identity-decision-provenance.test.ts b/packages/typegraph/tests/graph-merge/identity-decision-provenance.test.ts new file mode 100644 index 000000000..094675e74 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-decision-provenance.test.ts @@ -0,0 +1,471 @@ +/** + * An ordinary merge that writes identity assertions explains itself in the + * transition log: each transition it causes carries cause `reconcile` and a + * decision naming the branch, the branch ancestry and — when a plan artifact + * was applied — the plan digest. The decision carries exactly that evidence + * and nothing else. + * + * Every entry point records the same decision for one plan: `merge()`, + * `applyMergePlan` (TypeGraph owns the transaction) and + * `applyMergePlanInTransaction` (the caller does). + * + * Fixture: the base holds nothing; the branch creates people `a1` and `b1` and + * asserts they are one person. The merge lands both rows and the assertion. + * + * An apply under a review records that review's digest only when the review + * revalidated the very plan being applied; a review naming another plan, a + * review that is not compatible, and an undeclared apply option are refused + * before anything is written. + * + * A transition the merge causes through a NODE write rather than an identity + * row keeps the cause that names its mechanism — `detach` for a deleted + * member, `fold` for a created same-ID peer — and carries the same decision. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + createStoreWithSchema, + defineGraph, + defineNode, + type Store, +} from "../../src"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import type { + CompatibleMergeReview, + MergePlanApplyOptions, + MergePlanDecisionOptions, +} from "../../src/graph-merge/apply-callbacks"; +import { branch } from "../../src/graph-merge/branch"; +import { InvalidMergeOptionsError } from "../../src/graph-merge/errors"; +import { + applyMergePlan, + applyMergePlanInTransaction, + merge, + mergeIncremental, + planMerge, + planMergeIncremental, +} from "../../src/graph-merge/merge"; +import type { MergePlanArtifact } from "../../src/graph-merge/plan-schema"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); + +const graph = defineGraph({ + id: "identity_decision_provenance", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const BRANCH = asBranchId("branch-a"); +const BRANCH_ANCESTRY = [graph.id, BRANCH]; + +const cleanups: (() => Promise)[] = []; + +afterEach(async () => { + for (const cleanup of cleanups.splice(0).toReversed()) await cleanup(); +}); + +function openBackend() { + const { backend } = createLocalSqliteBackend(); + cleanups.push(() => backend.close()); + return backend; +} + +async function branchAssertingSame() { + const [base] = await createStoreWithSchema(graph, openBackend(), { + history: true, + }); + const source = unwrap( + await branch(base, () => Promise.resolve(openBackend()), { id: BRANCH }), + ); + for (const id of ["a1", "b1"]) { + await source.store.nodes.Person.create({ name: id }, { id }); + } + await source.store.identity.assertSame( + { kind: "Person", id: "a1" }, + { kind: "Person", id: "b1" }, + ); + return { base, source }; +} + +/** A live target holding what the (empty) fork point holds, with a transition log. */ +async function emptyHistoryTarget() { + const [target] = await createStoreWithSchema(graph, openBackend(), { + history: true, + }); + return target; +} + +async function mergeTransitions(store: Store) { + const history = await store.identity.transitionsOf({ + kind: "Person", + id: "a1", + }); + const transitions = history.transitions.map((transition) => ({ + cause: transition.cause, + decision: transition.decision, + })); + console.info("merge transitions", JSON.stringify(transitions)); + return transitions; +} + +describe("an ordinary merge records decision provenance on its transitions", () => { + it("merge() records cause `reconcile` with the branch and its ancestry, and nothing else", async () => { + const { base, source } = await branchAssertingSame(); + const report = unwrap(await merge(base, [source])); + expect(report.merged.identity.asserted).toBe(1); + + expect(await mergeTransitions(base)).toEqual([ + { + cause: "reconcile", + decision: { branchId: BRANCH, branchAncestry: BRANCH_ANCESTRY }, + }, + ]); + }); + + it("applyMergePlan adds the plan digest, and applyMergePlanInTransaction records the same decision", async () => { + const managed = await branchAssertingSame(); + const managedPlan = unwrap(await planMerge(managed.base, [managed.source])); + unwrap(await applyMergePlan(managed.base, managedPlan)); + + const callerOwned = await branchAssertingSame(); + const callerOwnedPlan = unwrap( + await planMerge(callerOwned.base, [callerOwned.source]), + ); + await callerOwned.base.transaction((tx) => + applyMergePlanInTransaction(callerOwned.base, tx, callerOwnedPlan), + ); + + expect(await mergeTransitions(managed.base)).toEqual([ + { + cause: "reconcile", + decision: { + branchId: BRANCH, + branchAncestry: BRANCH_ANCESTRY, + mergePlanDigest: managedPlan.digest.value, + }, + }, + ]); + expect(await mergeTransitions(callerOwned.base)).toEqual([ + { + cause: "reconcile", + decision: { + branchId: BRANCH, + branchAncestry: BRANCH_ANCESTRY, + mergePlanDigest: callerOwnedPlan.digest.value, + }, + }, + ]); + }); + + it("mergeIncremental() records the caller's branch alone, exactly as the planned incremental merge does", async () => { + const direct = await branchAssertingSame(); + const directTarget = await emptyHistoryTarget(); + unwrap( + await mergeIncremental({ + forkPoint: direct.base, + target: directTarget, + branches: [direct.source], + }), + ); + + const planned = await branchAssertingSame(); + const plannedTarget = await emptyHistoryTarget(); + const plan = unwrap( + await planMergeIncremental({ + forkPoint: planned.base, + target: plannedTarget, + branches: [planned.source], + }), + ); + unwrap(await applyMergePlan(plannedTarget, plan)); + + // The reserved stand-in for the committed target is not a merged branch: + // recording it would also hide the sole branch's name. + expect(await mergeTransitions(directTarget)).toEqual([ + { + cause: "reconcile", + decision: { branchId: BRANCH, branchAncestry: BRANCH_ANCESTRY }, + }, + ]); + expect(await mergeTransitions(plannedTarget)).toEqual([ + { + cause: "reconcile", + decision: { + branchId: BRANCH, + branchAncestry: BRANCH_ANCESTRY, + mergePlanDigest: plan.digest.value, + }, + }, + ]); + }); +}); + +const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + +const foldGraph = defineGraph({ + id: "identity_decision_provenance_fold", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +const FOLD_BRANCH_ANCESTRY = [foldGraph.id, BRANCH]; + +type FoldStore = Store; + +async function forkedFoldBase(seed: (base: FoldStore) => Promise) { + const [base] = await createStoreWithSchema(foldGraph, openBackend(), { + history: true, + }); + await seed(base); + const source = unwrap( + await branch(base, () => Promise.resolve(openBackend()), { id: BRANCH }), + ); + return { base, source }; +} + +/** The base holds `a1` and `b1` as one person; the branch deletes `a1`. */ +async function branchDeletingAClassMember() { + const forked = await forkedFoldBase(async (base) => { + for (const id of ["a1", "b1"]) { + await base.nodes.Person.create({ name: id }, { id }); + } + await base.identity.assertSame( + { kind: "Person", id: "a1" }, + { kind: "Person", id: "b1" }, + ); + }); + await forked.source.store.nodes.Person.delete(asNodeId("a1")); + return forked; +} + +/** The base holds the org `a1`; the branch creates the person `a1`. */ +async function branchCreatingASameIdPeer() { + const forked = await forkedFoldBase(async (base) => { + await base.nodes.Org.create({ name: "a1" }, { id: "a1" }); + }); + await forked.source.store.nodes.Person.create({ name: "a1" }, { id: "a1" }); + return forked; +} + +async function decisionOfCause(store: FoldStore, cause: "detach" | "fold") { + const history = await store.identity.transitionsOf({ + kind: "Person", + id: "a1", + }); + console.info( + `${cause} transitions`, + JSON.stringify( + history.transitions.map((transition) => ({ + cause: transition.cause, + decision: transition.decision, + })), + ), + ); + const caused = history.transitions.filter( + (transition) => transition.cause === cause, + ); + expect(caused).toHaveLength(1); + return caused[0]?.decision; +} + +describe("a transition a merge-applied node write causes carries the merge's decision", () => { + it("merge() records the branch on the detach a merged delete causes", async () => { + const { base, source } = await branchDeletingAClassMember(); + unwrap(await merge(base, [source])); + + expect(await decisionOfCause(base, "detach")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + }); + }); + + it("merge() records the branch on the fold a merged create causes", async () => { + const { base, source } = await branchCreatingASameIdPeer(); + unwrap(await merge(base, [source])); + + expect(await decisionOfCause(base, "fold")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + }); + }); + + it("applyMergePlan and applyMergePlanInTransaction record the plan digest on the detach", async () => { + const managed = await branchDeletingAClassMember(); + const managedPlan = unwrap(await planMerge(managed.base, [managed.source])); + unwrap(await applyMergePlan(managed.base, managedPlan)); + + const callerOwned = await branchDeletingAClassMember(); + const callerOwnedPlan = unwrap( + await planMerge(callerOwned.base, [callerOwned.source]), + ); + await callerOwned.base.transaction((tx) => + applyMergePlanInTransaction(callerOwned.base, tx, callerOwnedPlan), + ); + + expect(await decisionOfCause(managed.base, "detach")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + mergePlanDigest: managedPlan.digest.value, + }); + expect(await decisionOfCause(callerOwned.base, "detach")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + mergePlanDigest: callerOwnedPlan.digest.value, + }); + }); +}); + +/** What `work` rejected with, or `undefined` when it resolved. */ +async function rejectionOf(work: Promise): Promise { + try { + await work; + } catch (error) { + return error; + } + return undefined; +} + +/** A compatible revalidation of `plan`, as `revalidateCandidateWriteSetReview` returns one. */ +function compatibleReviewOf(plan: MergePlanArtifact): CompatibleMergeReview { + return { + status: "compatible", + reviewDigest: { algorithm: "sha256", value: REVIEW_DIGEST }, + plan, + }; +} + +const REVIEW_DIGEST = "a".repeat(64); + +describe("an apply under a review records the review digest bound to its plan", () => { + it("applyMergePlan and applyMergePlanInTransaction record the digest of a review naming the applied plan", async () => { + const managed = await branchDeletingAClassMember(); + const managedPlan = unwrap(await planMerge(managed.base, [managed.source])); + unwrap( + await applyMergePlan(managed.base, managedPlan, { + review: compatibleReviewOf(managedPlan), + }), + ); + + const callerOwned = await branchDeletingAClassMember(); + const callerOwnedPlan = unwrap( + await planMerge(callerOwned.base, [callerOwned.source]), + ); + await callerOwned.base.transaction((tx) => + applyMergePlanInTransaction(callerOwned.base, tx, callerOwnedPlan, { + review: compatibleReviewOf(callerOwnedPlan), + }), + ); + + expect(await decisionOfCause(managed.base, "detach")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + mergePlanDigest: managedPlan.digest.value, + reviewDigest: REVIEW_DIGEST, + }); + expect(await decisionOfCause(callerOwned.base, "detach")).toEqual({ + branchId: BRANCH, + branchAncestry: FOLD_BRANCH_ANCESTRY, + mergePlanDigest: callerOwnedPlan.digest.value, + reviewDigest: REVIEW_DIGEST, + }); + }); + + it("refuses a review that revalidated a different plan, on both apply paths, writing nothing", async () => { + const { base, source } = await branchDeletingAClassMember(); + const plan = unwrap(await planMerge(base, [source])); + const other = await branchCreatingASameIdPeer(); + const otherPlan = unwrap(await planMerge(other.base, [other.source])); + + const managed = await applyMergePlan(base, plan, { + review: compatibleReviewOf(otherPlan), + }); + if (!isErr(managed)) throw new Error("expected the apply to be refused"); + console.info("managed refusal", managed.error.message); + expect(managed.error).toBeInstanceOf(InvalidMergeOptionsError); + expect(managed.error.details).toMatchObject({ + option: "review", + reason: "plan-mismatch", + }); + + const callerOwned = await rejectionOf( + base.transaction((tx) => + applyMergePlanInTransaction(base, tx, plan, { + review: compatibleReviewOf(otherPlan), + }), + ), + ); + expect(callerOwned).toBeInstanceOf(InvalidMergeOptionsError); + expect((callerOwned as InvalidMergeOptionsError).details).toMatchObject({ + option: "review", + reason: "plan-mismatch", + }); + + await expect( + base.nodes.Person.getById(asNodeId("a1")), + ).resolves.toBeDefined(); + }); + + it("refuses a review revalidation that is not compatible", async () => { + const { base, source } = await branchDeletingAClassMember(); + const plan = unwrap(await planMerge(base, [source])); + const changed = { + status: "changed", + reviewDigest: { algorithm: "sha256", value: REVIEW_DIGEST }, + differences: [], + plan, + } as unknown as CompatibleMergeReview; + + const result = await applyMergePlan(base, plan, { review: changed }); + if (!isErr(result)) throw new Error("expected the apply to be refused"); + expect(result.error).toBeInstanceOf(InvalidMergeOptionsError); + expect(result.error.details).toMatchObject({ + option: "review", + reason: "not-compatible", + }); + await expect( + base.nodes.Person.getById(asNodeId("a1")), + ).resolves.toBeDefined(); + }); + + it("refuses an apply option neither apply path declares", async () => { + const { base, source } = await branchDeletingAClassMember(); + const plan = unwrap(await planMerge(base, [source])); + const undeclared = { + reviewDigest: REVIEW_DIGEST, + } as unknown as MergePlanApplyOptions; + + const managed = await applyMergePlan(base, plan, undeclared); + if (!isErr(managed)) throw new Error("expected the apply to be refused"); + expect(managed.error).toBeInstanceOf(InvalidMergeOptionsError); + expect(managed.error.details).toMatchObject({ option: "reviewDigest" }); + + const callerOwned = await rejectionOf( + base.transaction((tx) => + applyMergePlanInTransaction(base, tx, plan, undeclared), + ), + ); + expect(callerOwned).toBeInstanceOf(InvalidMergeOptionsError); + expect((callerOwned as InvalidMergeOptionsError).details).toMatchObject({ + option: "reviewDigest", + }); + // The caller-owned path accepts only the decision provenance, not the + // managed path's callbacks. + const withCallback = await rejectionOf( + base.transaction((tx) => + applyMergePlanInTransaction(base, tx, plan, { + afterApply: () => Promise.resolve(), + } as unknown as MergePlanDecisionOptions), + ), + ); + expect(withCallback).toBeInstanceOf(InvalidMergeOptionsError); + await expect( + base.nodes.Person.getById(asNodeId("a1")), + ).resolves.toBeDefined(); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/identity-merge.test.ts b/packages/typegraph/tests/graph-merge/identity-merge.test.ts index cac94468a..a749fcbc4 100644 --- a/packages/typegraph/tests/graph-merge/identity-merge.test.ts +++ b/packages/typegraph/tests/graph-merge/identity-merge.test.ts @@ -26,6 +26,7 @@ import { merge, mergeIncremental, planMerge, + planMergeIncremental, } from "../../src/graph-merge/merge"; import { assertNoContradictoryIdentityClosure, @@ -308,14 +309,14 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { expect(result.error).toBeInstanceOf(IdentityMergeConflictError); }); - it("merges convergent retract + reassert of one pair", async () => { + it("refuses a retraction another branch overrules by retracting and reasserting", async () => { const { store, first, second, assertion } = await createBase(true); if (assertion === undefined) throw new Error("Missing base assertion"); const retractBranch = unwrap(await branch(store, () => makeBackend())); const reassertBranch = unwrap(await branch(store, () => makeBackend())); - // Both branches AGREE the inherited assertion dies; one went further and - // re-asserted the pair under a new id. That is convergence, not a race, so - // both effects apply: the old id is retracted and the new one is asserted. + // One branch ends the pair; the other ends it and asserts it again under a + // new id. The branches disagree about whether the pair is the same at all, + // so keeping the reassertion would silently overrule the first branch. await retractBranch.store.identity.retractAssertion(assertion.id); await reassertBranch.store.identity.retractAssertion(assertion.id); const reasserted = await reassertBranch.store.identity.assertSame( @@ -324,11 +325,78 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { ); const result = await merge(store, [retractBranch, reassertBranch], {}); - if (isErr(result)) throw result.error; + if (isOk(result)) { + console.info("merged", result.data.merged.identity); + throw new Error("Expected a retract/reassert race refusal"); + } + console.info("refusal", result.error.message, result.error.details); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + expect(result.error.message).toBe( + "Branches contain a retract/reassert race for one identity pair.", + ); + expect(result.error.details).toMatchObject({ + retractedAssertion: { id: assertion.id }, + retractedBy: retractBranch.id, + reassertedAssertion: { id: reasserted.assertion.id }, + reassertedBy: reassertBranch.id, + }); + // Nothing applied: the base assertion is still the pair's only truth. + const current = await store.identity.assertionsOf(first); + expect(current.map((entry) => entry.id)).toEqual([assertion.id]); + }); + + it("merges a retraction the other branch reasserted and then ended itself", async () => { + const { store, first, second, assertion } = await createBase(true); + if (assertion === undefined) throw new Error("Missing base assertion"); + const retractBranch = unwrap(await branch(store, () => makeBackend())); + const reassertBranch = unwrap(await branch(store, () => makeBackend())); + await retractBranch.store.identity.retractAssertion(assertion.id); + await reassertBranch.store.identity.retractAssertion(assertion.id); + const reasserted = await reassertBranch.store.identity.assertSame( + first, + second, + ); + // An ending in the assertion's own millisecond leaves an empty window, + // which the merge drops rather than lands as history. + await new Promise((resolve) => setTimeout(resolve, 2)); + await reassertBranch.store.identity.retractAssertion( + reasserted.assertion.id, + ); + + const result = await merge(store, [retractBranch, reassertBranch], {}); + if (isErr(result)) { + console.info("refusal", result.error.message, result.error.details); + throw result.error; + } + console.info("merged", result.data.merged.identity, result.data.dropped); expect(result.data.merged.identity).toEqual({ asserted: 1, retracted: 1 }); + expect(await store.identity.assertionsOf(first)).toEqual([]); + expect(await store.identity.areSame(first, second)).toBe(false); + const archival = await storeRuntime(store).identityAssertionsAtTarget( + storeRuntime(store).backend, + "archival", + ); + expect( + archival.find((row) => row.id === reasserted.assertion.id)?.validTo, + ).toBeDefined(); + }); + + it("merges a retract and reassert every retracting branch agrees on", async () => { + const { store, first, second, assertion } = await createBase(true); + if (assertion === undefined) throw new Error("Missing base assertion"); + const firstBranch = unwrap(await branch(store, () => makeBackend())); + const secondBranch = unwrap(await branch(store, () => makeBackend())); + await firstBranch.store.identity.retractAssertion(assertion.id); + const kept = await firstBranch.store.identity.assertSame(first, second); + await new Promise((resolve) => setTimeout(resolve, 2)); + await secondBranch.store.identity.retractAssertion(assertion.id); + await secondBranch.store.identity.assertSame(first, second); + const result = await merge(store, [firstBranch, secondBranch], {}); + if (isErr(result)) throw result.error; + expect(result.data.merged.identity).toEqual({ asserted: 1, retracted: 1 }); const current = await store.identity.assertionsOf(first); - expect(current.map((entry) => entry.id)).toEqual([reasserted.assertion.id]); + expect(current.map((entry) => entry.id)).toEqual([kept.assertion.id]); expect(await store.identity.areSame(first, second)).toBe(true); }); @@ -549,6 +617,246 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { expect(await store.identity.assertionsOf(first)).toEqual([]); }); + /** + * An assertion that ENDED claims nothing about its endpoint's present, so a + * deletion of that endpoint does not contradict it: the merge lands it as + * history. Covers the branch that ended it by deleting the endpoint itself, + * and a branch that retracted it while another branch deleted the endpoint. + */ + it.each([ + { relation: "same", ending: "own-delete" }, + { relation: "different", ending: "own-delete" }, + { relation: "same", ending: "retract" }, + { relation: "different", ending: "retract" }, + ] as const)( + "lands an ended $relation assertion over a deleted endpoint as history ($ending)", + async ({ relation, ending }) => { + const { store, first, second } = await createBase(); + const assertBranch = unwrap(await branch(store, () => makeBackend())); + const deleteBranch = unwrap(await branch(store, () => makeBackend())); + const { assertion } = + relation === "same" ? + await assertBranch.store.identity.assertSame(first, second) + : await assertBranch.store.identity.assertDifferent(first, second); + // An ending in the assertion's own millisecond leaves an empty window, + // which the merge drops rather than lands as history. + await new Promise((resolve) => setTimeout(resolve, 2)); + if (ending === "own-delete") { + await assertBranch.store.nodes.Person.delete(second.id); + } else { + await assertBranch.store.identity.retractAssertion(assertion.id); + await deleteBranch.store.nodes.Person.delete(second.id); + } + expect(await assertBranch.store.identity.assertionsOf(first)).toEqual([]); + + const branches = + ending === "own-delete" ? [assertBranch] : [assertBranch, deleteBranch]; + const result = await merge(store, branches, {}); + if (isErr(result)) { + console.info("refusal", result.error.message, result.error.details); + throw result.error; + } + expect(result.data.merged.identity).toEqual({ + asserted: 1, + retracted: 0, + }); + expect(await store.nodes.Person.getById(second.id)).toBeUndefined(); + expect(await store.identity.assertionsOf(first)).toEqual([]); + const archival = await storeRuntime(store).identityAssertionsAtTarget( + storeRuntime(store).backend, + "archival", + ); + const landed = archival.find((row) => row.id === assertion.id); + console.info("landed history", landed); + expect(landed).toMatchObject({ relation }); + expect(landed?.validTo).toBeDefined(); + }, + ); + + /** + * A deletion the incremental TARGET made after the fork is not one this + * merge applies: it landed at the target's own, earlier instant (or removed + * the row outright), so even an ENDED branch assertion over it cannot be + * written. Planning refuses it with the typed conflict, and the merge + * refuses it the same way rather than dying inside the commit. + */ + it.each([ + { deletion: "soft-delete before the branch retracts" }, + { deletion: "hard-delete" }, + ] as const)( + "refuses an ended branch assertion over a node the target deleted ($deletion)", + async ({ deletion }) => { + // Planning an incremental merge needs durable revision tracking. + const [forkPoint] = await createStoreWithSchema( + graph, + await makeBackend(), + { history: true }, + ); + const first = await forkPoint.nodes.Person.create( + { name: "First" }, + { id: "first" }, + ); + const second = await forkPoint.nodes.Person.create( + { name: "Second" }, + { id: "second" }, + ); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_B }), + ).store; + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + const { assertion } = await source.store.identity.assertSame( + first, + second, + ); + await new Promise((resolve) => setTimeout(resolve, 2)); + if (deletion === "hard-delete") { + await source.store.identity.retractAssertion(assertion.id); + await target.nodes.Person.hardDelete(second.id); + } else { + await target.nodes.Person.delete(second.id); + await new Promise((resolve) => setTimeout(resolve, 2)); + await source.store.identity.retractAssertion(assertion.id); + } + const request = { + forkPoint, + target, + branches: [source], + options: { branchOrder: [BRANCH_A] }, + }; + + const planned = await planMergeIncremental(request); + const merged = await mergeIncremental(request); + + for (const [label, result] of [ + ["plan", planned], + ["merge", merged], + ] as const) { + if (result.success) throw new Error(`Expected a ${label} refusal`); + console.info(label, "refusal", result.error.message); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + expect(result.error.message).toBe( + "A branch asserted an identity relation over a node another branch deleted.", + ); + expect(result.error.details).toMatchObject({ + assertion: { id: assertion.id }, + deletedEndpoint: { kind: "Person", id: second.id }, + }); + } + }, + ); + + it("merges incrementally when the target ended its own assertion by deleting the endpoint", async () => { + const { store: forkPoint, first, second } = await createBase(); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_B }), + ).store; + const { assertion } = await target.identity.assertSame(first, second); + await new Promise((resolve) => setTimeout(resolve, 2)); + await target.nodes.Person.delete(second.id); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create({ name: "Third" }, { id: "third" }); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { branchOrder: [BRANCH_A] }, + }); + + if (isErr(result)) { + console.info("refusal", result.error.message, result.error.details); + throw result.error; + } + expect(result.data.dropped).not.toContainEqual( + expect.objectContaining({ id: assertion.id }), + ); + expect(await target.nodes.Person.getById("third" as never)).toBeDefined(); + }); + + /** + * Reconciliation that fuses the endpoints of a row the target already holds + * writes nothing for it: the row stays on the target as it is, so it must not + * be reported dropped. An incremental merge re-stages the target's own + * post-fork assertions, which is how such a row reaches the remap: a `same` + * the target asserted (exactly what a scorer re-matches), and a `different` + * it asserted and then retracted. + */ + it.each(["same", "different"] as const)( + "keeps, unreported, a %s assertion the target holds when resolution fuses its endpoints", + async (relation) => { + const { store: forkPoint } = await createBase(); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_B }), + ).store; + const n1 = await target.nodes.Person.create( + { name: "Ada" }, + { id: "n1" }, + ); + const n2 = await target.nodes.Person.create( + { name: "Ada" }, + { id: "n2" }, + ); + const { assertion } = + relation === "same" ? + await target.identity.assertSame(n1, n2) + : await target.identity.assertDifferent(n1, n2); + if (relation === "different") { + await new Promise((resolve) => setTimeout(resolve, 2)); + await target.identity.retractAssertion(assertion.id); + } + const targetRow = async () => + ( + await storeRuntime(target).identityAssertionsAtTarget( + storeRuntime(target).backend, + "archival", + ) + ).find((row) => row.id === assertion.id); + const before = await targetRow(); + expect(before).toBeDefined(); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create({ name: "Bob" }, { id: "bob" }); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + resolve: { + Person: { + block: () => "all", + threshold: 0.5, + similarity: { + kind: "custom", + score: (left, right) => + [left.id, right.id].toSorted().join(",") === "n1,n2" ? 1 : 0, + }, + }, + }, + }, + }); + + if (isErr(result)) { + console.info("refusal", result.error.message, result.error.details); + throw result.error; + } + console.info("dropped", result.data.dropped); + expect(result.data.resolutions).toEqual([ + expect.objectContaining({ memberIds: ["n1", "n2"] }), + ]); + expect(result.data.dropped).not.toContainEqual( + expect.objectContaining({ id: assertion.id }), + ); + expect(await targetRow()).toEqual(before); + }, + ); + it("rejects a transitive same/different contradiction (#4)", async () => { const { store, first, second, third } = await createChainBase(); // Base already holds same(second, third). One branch chains first onto @@ -769,7 +1077,14 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { expect(report.warnings).toEqual(expectedWarnings); }); - it("rejects opposing relations created by endpoint reconciliation", async () => { + /** + * Branch A asserts `p-anna` the same as `anchor`; branch B asserts `p-ana` + * different from `anchor`. Fusing the two staged patients would turn those + * into opposing relations on one endpoint pair, so the separation veto — + * which reads both branches' staged assertions — drops the scored match and + * reports it, and the merge lands both assertions with the patients apart. + */ + it("vetoes a scored match endpoint reconciliation would turn into opposing relations", async () => { const backend = await makeBackend(); const [baseStore] = await createStoreWithSchema(patientGraph, backend); const anchor = await baseStore.nodes.Patient.create( @@ -805,12 +1120,21 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { branchOrder: [BRANCH_A, BRANCH_B], }); - expect(isErr(result)).toBe(true); - if (isOk(result)) throw new Error("Expected identity merge conflict"); - expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + if (isErr(result)) throw result.error; + console.info("identity conflicts", result.data.identityConflicts); + expect(result.data.identityConflicts).toMatchObject([ + { + kind: "separation", + a: { kind: "Patient", id: "p-ana" }, + b: { kind: "Patient", id: "p-anna" }, + }, + ]); + expect(await baseStore.identity.areSame(anna, anchor)).toBe(true); + expect(await baseStore.identity.areDifferent(ana, anchor)).toBe(true); + expect(await baseStore.identity.areSame(anna, ana)).toBe(false); }); - it("rejects a different assertion collapsed to one reconciled survivor", async () => { + it("keeps apart a scored pair the merged branch itself asserts different", async () => { const backend = await makeBackend(); const [baseStore] = await createStoreWithSchema(patientGraph, backend); const branchA = unwrap( @@ -838,9 +1162,19 @@ describe.each(backendMatrix())("identity merge [$name]", (entry) => { branchOrder: [BRANCH_A], }); - expect(isErr(result)).toBe(true); - if (isOk(result)) throw new Error("Expected identity merge conflict"); - expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + // The branch's own `different` assertion vetoes the scored match: the + // pair is reported, both rows land, and the assertion lands with them. + if (isErr(result)) throw result.error; + expect(result.data.identityConflicts).toEqual([ + expect.objectContaining({ kind: "separation" }), + ]); + expect(result.data.merged.identity).toEqual({ asserted: 1, retracted: 0 }); + const patients = await baseStore.nodes.Patient.find(); + expect(patients.map((patient) => patient.id).toSorted()).toEqual([ + "p-ana", + "p-anna", + ]); + expect(await baseStore.identity.areDifferent(anna, ana)).toBe(true); }); it("drops a same assertion collapsed to one reconciled survivor (#5)", async () => { @@ -1001,12 +1335,34 @@ describe("planIdentityChanges retract/reassert rules", () => { ).toThrow(IdentityMergeConflictError); }); - it("accepts a reassert by a branch that retracted the pair itself", () => { - // Convergent: branch B retracted the inherited assertion before re-asserting, - // so both branches agree the old id dies. + it("rejects a reassert when another branch only retracted the pair", () => { + // Branch B retracted and re-asserted; branch A retracted and stopped. The + // reassertion would overrule A's retraction, so the plan is refused. + expect(() => + planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_B, assertion: reasserted }], + [ + { branchId: BRANCH_A, assertion: inherited }, + { branchId: BRANCH_B, assertion: inherited }, + ], + ), + new Map(), + ), + ).toThrow(IdentityMergeConflictError); + }); + + it("accepts a reassert when every retracting branch reasserted the pair", () => { + const reassertedByA: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "a-3", + }; const planned = planIdentityChanges( stagingWithIdentityChanges( - [{ branchId: BRANCH_B, assertion: reasserted }], + [ + { branchId: BRANCH_A, assertion: reassertedByA }, + { branchId: BRANCH_B, assertion: reasserted }, + ], [ { branchId: BRANCH_A, assertion: inherited }, { branchId: BRANCH_B, assertion: inherited }, @@ -1021,7 +1377,13 @@ describe("planIdentityChanges retract/reassert rules", () => { expect(planned.assertions.map((entry) => entry.id)).toEqual([ reasserted.id, ]); - expect(planned.dropped).toEqual([]); + expect(planned.dropped).toEqual([ + { + kind: "identity", + id: reassertedByA.id, + reason: DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, + }, + ]); }); it("accepts a single branch that retracts then re-asserts the pair", () => { @@ -1597,6 +1959,24 @@ describe("remapIdentityAssertionEndpoints validity", () => { expect(result.warnings).toEqual([]); }); + it("keeps, unreported, a committed row whose remapped window is empty", () => { + const result = remapIdentityAssertionEndpoints( + [assertion], + canonicalOf, + new Map(), + new Map([[assertion.id, assertion]]), + new Map([ + [ + mergeKey("Person", "survivor"), + { validFrom: "2025-01-01T00:00:00.000Z" }, + ], + ]), + ); + + expect(result.assertions).toEqual([]); + expect(result.dropped).toEqual([]); + }); + it("honors a survivor window with only an upper bound", () => { const result = remapIdentityAssertionEndpoints( [assertion], diff --git a/packages/typegraph/tests/graph-merge/identity-reconciliation.test.ts b/packages/typegraph/tests/graph-merge/identity-reconciliation.test.ts new file mode 100644 index 000000000..084902858 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-reconciliation.test.ts @@ -0,0 +1,259 @@ +/** + * The end-to-end half of merging identity truth: what a REAL merge writes, and + * what it records about why. + * + * - Decision provenance. A merge that changes identity classes writes + * transitions whose cause is `reconcile` and whose `decision` names the + * plan digest, the review digest it was approved under, the branch, and + * the root-first branch ancestry. Without it a fold triggered by a merged + * node create is filed as an anonymous `fold` and a reviewer replaying the + * class can see that it happened but not which plan produced it. + * - Refusal. Branches asserting opposing relations for one pair have no + * rule that settles them, so the merge fails and writes nothing. + * + * Runs on every backend in the merge matrix, because the transition log and + * the closure it annotates are storage, not shared pure code. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineGraph, + defineNode, +} from "@nicia-ai/typegraph"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { IdentityMergeConflictError } from "../../src/graph-merge/errors"; +import { applyMergePlan, merge, planMerge } from "../../src/graph-merge/merge"; +import { isErr, isOk, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; +import { identityTransitionsOf } from "../../src/identity/replay"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { backendMatrix } from "./test-utils"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); + +const reconciliationGraph = defineGraph({ + id: "identity_reconciliation", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); +type ReconciliationGraph = typeof reconciliationGraph; + +const BRANCH_A = asBranchId("branch-a"); +const BRANCH_B = asBranchId("branch-b"); +const REVIEW_DIGEST = "review-digest-under-test"; + +describe.each(backendMatrix())( + "identity reconciliation end to end [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + beforeEach(() => { + cleanups = []; + }); + + afterEach(async () => { + const outcomes = await Promise.allSettled( + cleanups.toReversed().map((cleanup) => cleanup()), + ); + const rejection = outcomes.find( + (outcome): outcome is PromiseRejectedResult => + outcome.status === "rejected", + ); + if (rejection !== undefined) { + throw rejection.reason instanceof Error ? + rejection.reason + : new Error(String(rejection.reason)); + } + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + /** A base holding two people, ready for a branch to relate them. */ + async function baseWithTwoPeople(): Promise> { + const [store] = await createStoreWithSchema( + reconciliationGraph, + await makeBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "Ada" }, { id: "ada" }); + await store.nodes.Person.create({ name: "Ada L." }, { id: "ada2" }); + return store; + } + + /** + * Decision provenance reaches the transition log. + * + * A reviewed plan whose identity slice merges two classes must leave a + * `reconcile` transition that names the governing decision, so the fold is + * attributable to THIS plan rather than to an anonymous API write. + */ + it("records the plan digest, review digest, branch and root-first ancestry on the transitions a reviewed apply causes", async () => { + const target = await baseWithTwoPeople(); + const source = unwrap( + await branch(target, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.identity.assertSame( + { kind: "Person", id: "ada" }, + { kind: "Person", id: "ada2" }, + ); + + const artifact = unwrap( + await planMerge(target, [source], { branchOrder: [BRANCH_A] }), + ); + const applied = await applyMergePlan(target, artifact, { + review: { + status: "compatible", + reviewDigest: { algorithm: "sha256", value: REVIEW_DIGEST }, + plan: artifact, + }, + }); + if (isErr(applied)) throw applied.error; + expect(applied.data.merged.identity.asserted).toBe(1); + + const ctx = storeRuntime(target).identityContext(); + const { transitions } = await identityTransitionsOf(ctx, { + kind: "Person", + id: "ada", + }); + expect(transitions.length).toBeGreaterThan(0); + const reconciled = transitions.filter( + (transition) => transition.cause === "reconcile", + ); + expect(reconciled.length).toBeGreaterThan(0); + for (const transition of reconciled) { + // Exactly this evidence and nothing else. One branch merged, so + // "which branch" has an unambiguous answer; the ancestry is + // root-first: the base graph, then the branches in anchor order. + expect(transition.decision).toEqual({ + mergePlanDigest: artifact.digest.value, + reviewDigest: REVIEW_DIGEST, + branchId: BRANCH_A, + branchAncestry: [target.graphId, BRANCH_A], + }); + } + }); + + /** + * The two apply paths must record the SAME ancestry for the same logical + * merge: `merge()` has no plan artifact to read anchors from, so it + * derives the order itself, and a second spelling of that order would make + * replay provenance incomparable across the paths whenever the caller's + * branch order is not already code-point sorted. + */ + it("records one branch ancestry whether the merge is direct or applied from a plan", async () => { + async function ancestryOf( + apply: "direct" | "plan", + ): Promise { + const target = await baseWithTwoPeople(); + // Deliberately NOT in code-point order. + const later = unwrap( + await branch(target, () => makeBackend(), { id: BRANCH_B }), + ); + await later.store.identity.assertSame( + { kind: "Person", id: "ada" }, + { kind: "Person", id: "ada2" }, + ); + const earlier = unwrap( + await branch(target, () => makeBackend(), { id: BRANCH_A }), + ); + await earlier.store.nodes.Person.create( + { name: "Grace" }, + { id: "grace" }, + ); + + if (apply === "direct") { + const merged = await merge(target, [later, earlier], { + branchOrder: [BRANCH_B, BRANCH_A], + }); + if (isErr(merged)) throw merged.error; + } else { + const artifact = unwrap( + await planMerge(target, [later, earlier], { + branchOrder: [BRANCH_B, BRANCH_A], + }), + ); + const applied = await applyMergePlan(target, artifact); + if (isErr(applied)) throw applied.error; + } + + const ctx = storeRuntime(target).identityContext(); + const { transitions } = await identityTransitionsOf(ctx, { + kind: "Person", + id: "ada", + }); + const reconciled = transitions.filter( + (transition) => transition.cause === "reconcile", + ); + expect(reconciled.length).toBeGreaterThan(0); + return reconciled[0]?.decision?.branchAncestry; + } + + const direct = await ancestryOf("direct"); + const planned = await ancestryOf("plan"); + expect(direct).toEqual(planned); + expect(direct).toEqual(["identity_reconciliation", BRANCH_A, BRANCH_B]); + }); + + /** + * Two branches assert opposing relations for the same pair. No rule can + * arbitrate that, so the merge fails and the target is left untouched — + * including the unrelated node one of the branches also staged. + */ + it("refuses branches asserting opposing relations and writes nothing", async () => { + const target = await baseWithTwoPeople(); + const sameBranch = unwrap( + await branch(target, () => makeBackend(), { id: BRANCH_A }), + ); + const { assertion: sameAssertion } = + await sameBranch.store.identity.assertSame( + { kind: "Person", id: "ada" }, + { kind: "Person", id: "ada2" }, + ); + const differentBranch = unwrap( + await branch(target, () => makeBackend(), { id: BRANCH_B }), + ); + const { assertion: differentAssertion } = + await differentBranch.store.identity.assertDifferent( + { kind: "Person", id: "ada" }, + { kind: "Person", id: "ada2" }, + ); + await differentBranch.store.nodes.Person.create( + { name: "Grace" }, + { id: "grace" }, + ); + + const result = await merge(target, [sameBranch, differentBranch], { + branchOrder: [BRANCH_A, BRANCH_B], + }); + if (isOk(result)) throw new Error("expected an identity conflict"); + console.info("refusal", result.error.code, result.error.details); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + expect(result.error.code).toBe("GRAPH_MERGE_IDENTITY_CONFLICT"); + // Both staged assertions are named: neither silently disappears. + expect( + ( + result.error.details["assertions"] as readonly Readonly<{ + id: string; + }>[] + ) + .map((assertion) => assertion.id) + .toSorted(), + ).toEqual([sameAssertion.id, differentAssertion.id].toSorted()); + expect(await target.nodes.Person.count()).toBe(2); + expect( + await target.identity.areSame( + { kind: "Person", id: "ada" }, + { kind: "Person", id: "ada2" }, + ), + ).toBe(false); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/identity-separation-storage-missing.test.ts b/packages/typegraph/tests/graph-merge/identity-separation-storage-missing.test.ts new file mode 100644 index 000000000..0edd3e73f --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-separation-storage-missing.test.ts @@ -0,0 +1,120 @@ +/** + * A merge never answers "not separated" for a target whose identity separation + * relation is unreadable. When the relation is missing (or never filled) while + * the ledger holds a live `different` assertion, `bulkIsSeparated` refuses the + * read with `IDENTITY_STORAGE_MISSING`, the merge's separation-fact capture + * surfaces that refusal, and nothing is written. + * + * SQLite-local on purpose: the fixture drops the relation out from under a + * live handle through the raw client, which the merge backend matrix has no + * portable spelling for. + */ +import type Database from "better-sqlite3"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStoreWithSchema, defineGraph, defineNode } from "../../src"; +import { + createLocalSqliteBackend, + type LocalSqliteBackendResult, +} from "../../src/backend/sqlite/local"; +import { branch } from "../../src/graph-merge/branch"; +import { merge } from "../../src/graph-merge/merge"; +import { isOk, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; + +const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), +}); +const graph = defineGraph({ + id: "identity_separation_storage_missing", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const BRANCH_A = asBranchId("branch-a"); +const SEPARATION_TABLE = "typegraph_identity_separation"; + +/** `details` of an error and of every cause beneath it. */ +function errorChain(error: unknown): readonly unknown[] { + const details: unknown[] = []; + for (let current = error; current instanceof Error; current = current.cause) { + details.push((current as Error & { details?: unknown }).details); + } + return details; +} + +function rawClient(result: LocalSqliteBackendResult): Database.Database { + return (result.db as unknown as { $client: Database.Database }).$client; +} + +describe("a merge against a target whose separation relation is unreadable", () => { + const disposers: (() => Promise)[] = []; + afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose(); + }); + + async function makeBranchBackend() { + const result = createLocalSqliteBackend(); + disposers.push(() => result.backend.close()); + return result.backend; + } + + it("refuses with the storage fault and writes nothing", async () => { + const targetResult = createLocalSqliteBackend(); + disposers.push(() => targetResult.backend.close()); + const [target] = await createStoreWithSchema(graph, targetResult.backend, { + history: true, + }); + // A live `different` in the ledger is what makes the missing relation a + // fact the veto must read — a graph separating nothing needs no relation. + const alice = await target.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await target.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await target.identity.assertDifferent(alice, bob); + + const source = unwrap( + await branch(target, () => makeBranchBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create({ name: "Ada" }, { id: "a1" }); + await source.store.nodes.Person.create({ name: "Ada L." }, { id: "b1" }); + await source.store.identity.assertSame( + { kind: "Person", id: "a1" }, + { kind: "Person", id: "b1" }, + ); + // The staged `same` joins `a1` and `b1` into one class, so `c1` is what + // leaves the veto a cross-class pair to judge. + await source.store.nodes.Person.create({ name: "Ada K." }, { id: "c1" }); + + // The relation goes away under the target's LIVE handle: every separation + // read from here on is loud, and the merge's fact capture is one of them. + rawClient(targetResult).exec(`DROP TABLE ${SEPARATION_TABLE}`); + + // A scored candidate pair gives the veto a pair to judge, so the plan's + // own fact capture is what reads the relation. + const outcome = await merge(target, [source], { + branchOrder: [BRANCH_A], + resolve: { + Person: { + block: () => "all", + threshold: 0.5, + similarity: { kind: "custom", score: () => 1 }, + }, + }, + }).then( + (result) => (isOk(result) ? undefined : result.error), + (error: unknown) => error, + ); + console.info("refusal:", outcome); + expect(JSON.stringify(errorChain(outcome))).toContain( + "IDENTITY_STORAGE_MISSING", + ); + expect(await target.nodes.Person.count()).toBe(2); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/identity-separation-veto.test.ts b/packages/typegraph/tests/graph-merge/identity-separation-veto.test.ts new file mode 100644 index 000000000..d77dd3789 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-separation-veto.test.ts @@ -0,0 +1,956 @@ +/** + * The always-on identity separation veto, and what an ordinary merge does with + * a `same` assertion. + * + * A `different` assertion is an integrity fact, so the veto runs for EVERY + * identity-enabled merge. What would otherwise abort inside the commit on the + * separation relation's ordered-pair CHECK is decided at plan time, naming + * both entities and the assertion that separated them: + * + * - a SCORED match between separated entities is dropped and reported as a + * typed `separation` conflict; the merge continues; + * - a DEFINITIONAL match that survives the base and diameter guards fails + * the plan; + * - a TRANSITIVE fusion through a third node, where no single candidate edge + * is separated, fails the plan. + * + * A `same` assertion is ledger truth the merge carries across; it never fuses + * the rows it names. + * + * Runs on every backend in the merge matrix (SQLite + in-process PGlite, plus + * server Postgres when `POSTGRES_URL` is set) — the veto decision is shared + * code, the separation probe is not, so both must agree. + */ +import type { GraphBackend, Store } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineGraph, + defineNode, + subClassOf, +} from "@nicia-ai/typegraph"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { IdentityMergeConflictError } from "../../src/graph-merge/errors"; +import { merge, mergeIncremental } from "../../src/graph-merge/merge"; +import { COLLAPSED_ENDED_DIFFERENT_DROP_REASON } from "../../src/graph-merge/merge-identity"; +import { isErr, isOk, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; +import { backendMatrix } from "./test-utils"; + +const Person = defineNode("Person", { + schema: z.object({ name: z.string(), email: z.string() }), +}); +const Robot = defineNode("Robot", { + schema: z.object({ name: z.string(), email: z.string() }), +}); + +const uniqueEmailGraph = defineGraph({ + id: "identity_separation_veto", + nodes: { + Person: { + type: Person, + unique: [ + { + name: "person_email", + fields: ["email"], + scope: "kind", + collation: "binary", + }, + ], + }, + Robot: { type: Robot }, + }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); +type UniqueEmailGraph = typeof uniqueEmailGraph; + +/** + * The same kinds with NO unique constraint, so the new-vs-base sources pull no + * base members into scope. That keeps the component-level base guard (which + * refuses to collapse two COMMITTED entities on its own) out of the way, so the + * transitive case below exercises the post-cluster separation assertion rather + * than a base-ambiguity split. + */ +const similarityGraph = defineGraph({ + id: "identity_separation_veto_similarity", + nodes: { Person: { type: Person }, Robot: { type: Robot } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); +type SimilarityGraph = typeof similarityGraph; + +const Employee = defineNode("Employee", { + schema: z.object({ name: z.string(), email: z.string() }), +}); + +/** + * `Employee` refines `Person`, so under `reconcileTypes: "ontology"` a + * `Person` and an `Employee` staged under one id are one entity at a refined + * type: a definitional match no similarity score is consulted for. + */ +const retypeGraph = defineGraph({ + id: "identity_separation_veto_retype", + nodes: { Person: { type: Person }, Employee: { type: Employee } }, + edges: {}, + ontology: [subClassOf(Employee, Person)], + identity: { sameIdAcrossKinds: "ignore" }, +}); + +/** + * A resolve config whose SCORING never accepts anything: the forced + * (definitional) edges a unique constraint produces still pass through, so a + * test can drive the forced path alone. + */ +const FORCED_ONLY_RESOLVE = { + block: () => "all", + threshold: 1, + similarity: { kind: "custom", score: () => 0 }, +} as const; + +const BRANCH_A = asBranchId("branch-a"); +const TARGET_CLONE = asBranchId("target-clone"); + +/** Every staged Person lands in one bucket, so `exactKey` proposes all pairs. */ +const ONE_BUCKET = { block: () => "all" } as const; + +describe.each(backendMatrix())("identity separation veto [$name]", (entry) => { + let cleanups: (() => Promise)[]; + + beforeEach(() => { + cleanups = []; + }); + + afterEach(async () => { + const outcomes = await Promise.allSettled( + cleanups.toReversed().map((cleanup) => cleanup()), + ); + const rejection = outcomes.find( + (outcome): outcome is PromiseRejectedResult => + outcome.status === "rejected", + ); + if (rejection !== undefined) { + throw rejection.reason instanceof Error ? + rejection.reason + : new Error(String(rejection.reason)); + } + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + async function makeStore(): Promise> { + const [store] = await createStoreWithSchema( + uniqueEmailGraph, + await makeBackend(), + { history: true }, + ); + return store; + } + + async function makeSimilarityStore(): Promise> { + const [store] = await createStoreWithSchema( + similarityGraph, + await makeBackend(), + { history: true }, + ); + return store; + } + + async function livePersonIds( + store: Store | Store, + ): Promise { + const rows = await store.nodes.Person.find(); + return rows.map((row) => row.id as string).toSorted(); + } + + /** + * A DEFINITIONAL match that SURVIVES the base and diameter guards is + * refused at PLAN time. + * + * The target already holds `Person:x` and `Employee:x` apart. The branch + * recreates both, and ontology reconciliation treats a `Person` and its + * subclass `Employee` staged under one id as a single entity — a forced + * edge between two staged nodes. No base member is involved, so no guard + * severs it and the cluster really would collapse two separated classes. + */ + it("refuses a definitional match that survives the guards", async () => { + const [forkPoint] = await createStoreWithSchema( + retypeGraph, + await makeBackend(), + { history: true }, + ); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + const person = { kind: "Person", id: "x" } as const; + const employee = { kind: "Employee", id: "x" } as const; + const props = { name: "X", email: "x@example.test" }; + await target.nodes.Person.create(props, { id: "x" }); + await target.nodes.Employee.create(props, { id: "x" }); + await target.identity.assertDifferent(person, employee); + + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create(props, { id: "x" }); + await source.store.nodes.Employee.create(props, { id: "x" }); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { branchOrder: [BRANCH_A], reconcileTypes: "ontology" }, + }); + + if (isOk(result)) throw new Error("expected a separation refusal"); + console.info("separation refusal", result.error.details); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + // The load-bearing half: the code AND the phase. Without the veto the + // merge reaches the commit and dies on the separation relation's CHECK + // under a different code. + expect(result.error.code).toBe("GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT"); + expect( + [result.error.details["a"], result.error.details["b"]].toSorted( + (left, right) => + JSON.stringify(left).localeCompare(JSON.stringify(right)), + ), + ).toEqual([employee, person]); + // The refusal names the assertion that separated them, not just that + // something did. + expect(result.error.details["assertionIds"]).toHaveLength(1); + // The EDGE-level diagnosis, not the cluster-level one: a surviving + // definitional claim is refused naming the source that proposed it. + expect(result.error.details["sources"]).toEqual([ + expect.objectContaining({ kind: "retype" }), + ]); + expect(result.error.details["cluster"]).toBeUndefined(); + // A plan-time refusal writes nothing: the separation still stands. + expect(await target.identity.areDifferent(person, employee)).toBe(true); + }); + + /** + * The counterpart, and the reason the definitional refusal runs AFTER the + * guards: a forced BASE match between two committed entities is severed + * by the component base guard, so it never fuses anything. Refusing it up + * front would fail a merge that is harmless — this fixture merges cleanly + * and leaves both entities, and their separation, intact. + */ + it("does not refuse a forced base match the base guard already severed", async () => { + const forkPoint = await makeStore(); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + await target.nodes.Person.create( + { name: "Alpha", email: "alpha@example.test" }, + { id: "alpha" }, + ); + await target.nodes.Person.create( + { name: "Beta", email: "beta@example.test" }, + { id: "beta" }, + ); + await target.identity.assertDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ); + + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + // Shares `alpha`'s unique email, so a base source forces the match. + await source.store.nodes.Person.create( + { name: "Beta", email: "alpha@example.test" }, + { id: "beta" }, + ); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + resolve: { Person: FORCED_ONLY_RESOLVE }, + }, + }); + + if (isErr(result)) throw result.error; + expect(result.data.resolutions).toEqual([]); + expect(await livePersonIds(target)).toEqual(["alpha", "beta"]); + expect( + await target.identity.areDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ), + ).toBe(true); + }); + + /** + * A TRANSITIVE fusion is refused too. + * + * `alpha`–`gamma` and `gamma`–`beta` each clear the threshold while + * `alpha`–`beta` does not, so NO candidate edge is separated and the + * edge-level veto sees nothing. The cluster still fuses all three, and the + * post-cluster assertion is what refuses it. + */ + it("refuses a cluster that fuses a separated pair through a third node", async () => { + const forkPoint = await makeSimilarityStore(); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + await target.nodes.Person.create( + { name: "alpha", email: "alpha@example.test" }, + { id: "alpha" }, + ); + await target.nodes.Person.create( + { name: "beta", email: "beta@example.test" }, + { id: "beta" }, + ); + await target.identity.assertDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ); + + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create( + { name: "gamma", email: "gamma@example.test" }, + { id: "gamma" }, + ); + + const before = await livePersonIds(target); + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { + kind: "custom", + // `gamma` matches both, `alpha` and `beta` match neither — + // a deterministic transitive bridge with no separated edge. + score: (left, right) => { + const ids = [left.id as string, right.id as string].toSorted(); + return ids.includes("gamma") ? 1 : 0; + }, + }, + }, + }, + }, + }); + + if (isOk(result)) throw new Error("expected a separation refusal"); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + expect(result.error.code).toBe("GRAPH_MERGE_IDENTITY_SEPARATION_CONFLICT"); + // The refusal names the separated PAIR, not the bridge that fused them. + expect(result.error.details["cluster"]).toHaveLength(3); + expect(await livePersonIds(target)).toEqual(before); + }); + + /** + * A `same` assertion is carried across as ledger truth and never fuses the + * rows it names — whether both endpoints are staged, one is already + * committed on the target, or the two are different kinds. + */ + it("lands a same assertion without fusing the nodes it spans", async () => { + const store = await makeStore(); + await store.nodes.Person.create( + { name: "Ada", email: "committed@example.test" }, + { id: "committed" }, + ); + const source = unwrap( + await branch(store, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create( + { name: "Ada", email: "a1@example.test" }, + { id: "a1" }, + ); + await source.store.nodes.Person.create( + { name: "Ada", email: "b1@example.test" }, + { id: "b1" }, + ); + await source.store.nodes.Robot.create( + { name: "Ada", email: "r1@example.test" }, + { id: "r1" }, + ); + await source.store.identity.assertSame( + { kind: "Person", id: "a1" }, + { kind: "Person", id: "b1" }, + ); + await source.store.identity.assertSame( + { kind: "Person", id: "committed" }, + { kind: "Person", id: "a1" }, + ); + await source.store.identity.assertSame( + { kind: "Person", id: "b1" }, + { kind: "Robot", id: "r1" }, + ); + + const result = await merge(store, [source], { branchOrder: [BRANCH_A] }); + if (isErr(result)) throw result.error; + console.info("merge report", { + merged: result.data.merged, + dropped: result.data.dropped, + identityConflicts: result.data.identityConflicts, + }); + + expect(await livePersonIds(store)).toEqual(["a1", "b1", "committed"]); + expect(result.data.resolutions).toEqual([]); + // Every staged assertion is a write: none dropped, none reported. + expect(result.data.merged.identity.asserted).toBe(3); + expect( + result.data.dropped.filter((item) => item.kind === "identity"), + ).toEqual([]); + expect(result.data.identityConflicts).toEqual([]); + expect( + await store.identity.areSame( + { kind: "Person", id: "committed" }, + { kind: "Robot", id: "r1" }, + ), + ).toBe(true); + }); + + /** + * Only a `different` that holds NOW separates. A branch that asserts and then + * retracts `different(alpha, beta)` no longer holds them apart, exactly as a + * retracted row in the target's ledger would not, so its match is neither + * dropped (scored) nor refused (definitional). The ended row's pair is one + * node after the fusion, so the row is dropped with a typed reason. + */ + it("lets a scored match through once the branch retracted the different that held it apart", async () => { + const forkPoint = await makeSimilarityStore(); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + const alpha = { kind: "Person", id: "alpha" } as const; + const beta = { kind: "Person", id: "beta" } as const; + const props = { name: "Ada", email: "ada@example.test" }; + await source.store.nodes.Person.create(props, { id: "alpha" }); + await source.store.nodes.Person.create(props, { id: "beta" }); + const { assertion: retracted } = + await source.store.identity.assertDifferent(alpha, beta); + await source.store.identity.retractAssertion(retracted.id); + expect(await source.store.identity.areDifferent(alpha, beta)).toBe(false); + + const result = await merge(forkPoint, [source], { + branchOrder: [BRANCH_A], + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { kind: "custom", score: () => 1 }, + }, + }, + }); + + if (isErr(result)) { + console.info("merge refused", result.error.code, result.error.message); + throw result.error; + } + console.info("identity conflicts", result.data.identityConflicts); + expect(result.data.identityConflicts).toEqual([]); + expect(result.data.resolutions).toEqual([ + expect.objectContaining({ memberIds: ["alpha", "beta"] }), + ]); + expect(await livePersonIds(forkPoint)).toHaveLength(1); + expect(result.data.dropped).toContainEqual({ + kind: "identity", + id: retracted.id, + reason: COLLAPSED_ENDED_DIFFERENT_DROP_REASON, + }); + }); + + it("does not refuse a definitional match whose different the branch retracted", async () => { + const [forkPoint] = await createStoreWithSchema( + retypeGraph, + await makeBackend(), + { history: true }, + ); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + const person = { kind: "Person", id: "x" } as const; + const employee = { kind: "Employee", id: "x" } as const; + const props = { name: "X", email: "x@example.test" }; + await source.store.nodes.Person.create(props, { id: "x" }); + await source.store.nodes.Employee.create(props, { id: "x" }); + const { assertion: retracted } = + await source.store.identity.assertDifferent(person, employee); + await source.store.identity.retractAssertion(retracted.id); + + const result = await merge(forkPoint, [source], { + branchOrder: [BRANCH_A], + reconcileTypes: "ontology", + }); + + if (isErr(result)) { + console.info("merge refused", result.error.code, result.error.details); + throw result.error; + } + expect(result.data.identityConflicts).toEqual([]); + expect(await forkPoint.nodes.Employee.getById("x" as never)).toBeDefined(); + expect(await forkPoint.nodes.Person.getById("x" as never)).toBeUndefined(); + expect(result.data.dropped).toContainEqual({ + kind: "identity", + id: retracted.id, + reason: COLLAPSED_ENDED_DIFFERENT_DROP_REASON, + }); + }); + + /** + * The veto judges the target's ledger as it stands BEFORE the merge. Which + * staged retractions of target-held rows the merge applies is decided inside + * plan construction, after the veto's facts are captured, so a target-held + * `different` the branch retracts still separates for the veto: the scored + * match is dropped citing it, while the retraction itself lands. + */ + it("still drops a scored match held apart by a target-held different the branch retracts", async () => { + const forkPoint = await makeSimilarityStore(); + const a = { kind: "Person", id: "a" } as const; + const c = { kind: "Person", id: "c" } as const; + const n1 = { kind: "Person", id: "n1" } as const; + const n2 = { kind: "Person", id: "n2" } as const; + await forkPoint.nodes.Person.create( + { name: "A", email: "a@example.test" }, + { id: "a" }, + ); + await forkPoint.nodes.Person.create( + { name: "C", email: "c@example.test" }, + { id: "c" }, + ); + const { assertion: separation } = await forkPoint.identity.assertDifferent( + a, + c, + ); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await new Promise((resolve) => setTimeout(resolve, 2)); + await source.store.identity.retractAssertion(separation.id); + const props = { name: "Ada", email: "ada@example.test" }; + await source.store.nodes.Person.create(props, { id: "n1" }); + await source.store.nodes.Person.create(props, { id: "n2" }); + await source.store.identity.assertSame(n1, a); + await source.store.identity.assertSame(n2, c); + + const result = await merge(forkPoint, [source], { + branchOrder: [BRANCH_A], + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { + kind: "custom", + score: (left, right) => + ( + [left.id as string, right.id as string].toSorted().join(",") === + "n1,n2" + ) ? + 1 + : 0, + }, + }, + }, + }); + + if (isErr(result)) throw result.error; + console.info("identity conflicts", result.data.identityConflicts); + expect(result.data.identityConflicts).toEqual([ + { + kind: "separation", + a: n1, + b: n2, + assertionIds: [separation.id], + source: expect.anything() as unknown, + }, + ]); + expect(result.data.resolutions).toEqual([]); + expect(await forkPoint.identity.areDifferent(a, c)).toBe(false); + }); + + /** + * A SCORED + * match is recall, so the ledger's veto drops the proposal, reports it, + * and the merge continues — the plan is still applicable. + */ + it("drops and reports a scored match the ledger holds apart", async () => { + const forkPoint = await makeSimilarityStore(); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + await target.nodes.Person.create( + { name: "alpha", email: "alpha@example.test" }, + { id: "alpha" }, + ); + await target.nodes.Person.create( + { name: "beta", email: "beta@example.test" }, + { id: "beta" }, + ); + await target.identity.assertDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create( + { name: "delta", email: "delta@example.test" }, + { id: "delta" }, + ); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + candidateDiagnostics: { limit: 50 }, + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { + kind: "custom", + // Only the separated pair scores: nothing else may fuse, so + // the drop is observable on its own. + score: (left, right) => { + const ids = [left.id as string, right.id as string].toSorted(); + return ids[0] === "alpha" && ids[1] === "beta" ? 1 : 0; + }, + }, + }, + }, + }, + }); + + if (isErr(result)) throw result.error; + const separations = result.data.identityConflicts.filter( + (conflict) => conflict.kind === "separation", + ); + expect(separations).toHaveLength(1); + expect(separations[0]).toMatchObject({ + kind: "separation", + a: { kind: "Person", id: "alpha" }, + b: { kind: "Person", id: "beta" }, + }); + expect(separations[0]?.assertionIds).toHaveLength(1); + // Reported, not fatal: the merge lands and the separation stands. + expect(await livePersonIds(target)).toEqual(["alpha", "beta", "delta"]); + expect( + await target.identity.areDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ), + ).toBe(true); + // The diagnostic surface must say what actually happened to the + // vetoed pair — "excluded" for "separation" — never "retained", which + // would flatly contradict the `identityConflicts` entry above for the + // identical pair. + const alphaBetaDiagnostic = result.data.candidateDiagnostics?.entries.find( + (diagnostic) => + diagnostic.evidence.decision === "scored" && + [diagnostic.evidence.a.id, diagnostic.evidence.b.id] + .toSorted() + .join(",") === "alpha,beta", + ); + expect(alphaBetaDiagnostic).toBeDefined(); + expect(alphaBetaDiagnostic?.scoreDecision).toBe("accepted"); + expect(alphaBetaDiagnostic?.clusterDisposition).toEqual({ + kind: "excluded", + reason: "separation", + }); + }); + + /** + * A `same` assertion the branch carries moves its endpoint into a class the + * ledger already holds apart from another. The target separates `alpha` + * from `gamma`; the branch asserts `beta` the same as `gamma`. A scored + * match between `alpha` and `beta` would therefore fuse two classes the + * merged ledger holds apart, so the veto drops it, reports it naming the + * target's assertion, and the merge lands the branch's `same` assertion. + */ + it("drops a scored match into a class a staged same assertion joins", async () => { + const forkPoint = await makeSimilarityStore(); + await forkPoint.nodes.Person.create( + { name: "gamma", email: "gamma@example.test" }, + { id: "gamma" }, + ); + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + await target.nodes.Person.create( + { name: "alpha", email: "alpha@example.test" }, + { id: "alpha" }, + ); + const { assertion: separation } = await target.identity.assertDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "gamma" }, + ); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create( + { name: "beta", email: "beta@example.test" }, + { id: "beta" }, + ); + await source.store.identity.assertSame( + { kind: "Person", id: "beta" }, + { kind: "Person", id: "gamma" }, + ); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { + kind: "custom", + score: (left, right) => { + const ids = [left.id as string, right.id as string].toSorted(); + return ids[0] === "alpha" && ids[1] === "beta" ? 1 : 0; + }, + }, + }, + }, + }, + }); + + if (isErr(result)) { + console.info("merge refused", result.error.code, result.error.message); + throw result.error; + } + console.info("identity conflicts", result.data.identityConflicts); + expect(result.data.identityConflicts).toEqual([ + { + kind: "separation", + a: { kind: "Person", id: "alpha" }, + b: { kind: "Person", id: "beta" }, + assertionIds: [separation.id], + source: expect.anything() as unknown, + }, + ]); + expect(await livePersonIds(target)).toEqual(["alpha", "beta", "gamma"]); + expect( + await target.identity.areSame( + { kind: "Person", id: "beta" }, + { kind: "Person", id: "gamma" }, + ), + ).toBe(true); + expect( + await target.identity.areDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ), + ).toBe(true); + }); + + /** + * The veto reads the ledger as it stands at plan time, not a fact an + * earlier identity call on the same Store handle settled. An `assertSame` + * or an `areDifferent` read on a graph holding no `different` assertion + * proves the separation relation's readiness for the handle; the + * `assertDifferent` that follows makes "this graph separates nothing" + * false, and a merge after it must still drop and report a scored pair + * that assertion holds apart. + * + * The assertion is INHERITED here — the fork point already holds it — so + * the merge stages no copy of it and the target's ledger is the only place + * it can be read. `alpha` and `beta` are new on the target, each asserted + * the same as one side of the separated pair, so the class-lifted + * separation is what forbids fusing them. + */ + it.each([ + { + primer: "assertSame", + prime: (target: Store) => + target.identity.assertSame( + { kind: "Person", id: "p" }, + { kind: "Person", id: "q" }, + ), + }, + { + primer: "areDifferent", + prime: (target: Store) => + target.identity.areDifferent( + { kind: "Person", id: "p" }, + { kind: "Person", id: "q" }, + ), + }, + ])( + "honors an inherited different assertion made after an earlier $primer on the same handle", + async ({ prime }) => { + const target = await makeSimilarityStore(); + const createPerson = (id: string) => + target.nodes.Person.create( + { name: id, email: `${id}@example.test` }, + { id }, + ); + for (const id of ["x", "y", "p", "q"]) await createPerson(id); + await prime(target); + await target.identity.assertDifferent( + { kind: "Person", id: "x" }, + { kind: "Person", id: "y" }, + ); + const forkPoint = unwrap( + await branch(target, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + for (const [id, peer] of [ + ["alpha", "x"], + ["beta", "y"], + ] as const) { + await createPerson(id); + await target.identity.assertSame( + { kind: "Person", id }, + { kind: "Person", id: peer }, + ); + } + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Person.create( + { name: "delta", email: "delta@example.test" }, + { id: "delta" }, + ); + + const result = await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { + branchOrder: [BRANCH_A], + resolve: { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { + kind: "custom", + score: (left, right) => { + const ids = [ + left.id as string, + right.id as string, + ].toSorted(); + return ids[0] === "alpha" && ids[1] === "beta" ? 1 : 0; + }, + }, + }, + }, + }, + }); + + if (isErr(result)) { + console.info("separation veto skipped", { + code: result.error.code, + message: result.error.message, + }); + throw result.error; + } + expect(result.data.identityConflicts).toHaveLength(1); + expect(result.data.identityConflicts[0]).toMatchObject({ + kind: "separation", + a: { kind: "Person", id: "alpha" }, + b: { kind: "Person", id: "beta" }, + }); + expect(result.data.identityConflicts[0]?.assertionIds).toHaveLength(1); + expect(await livePersonIds(target)).toEqual([ + "alpha", + "beta", + "delta", + "p", + "q", + "x", + "y", + ]); + }, + ); + + /** + * A `different` assertion a merged branch CARRIES holds its pair apart for + * the merge that lands it, exactly as one already on the target does: the + * scored match is dropped and reported, both rows land, and the assertion + * lands with them. Read from the target's ledger alone the pair fuses, and + * the plan dies collapsing the assertion onto one survivor. + */ + it.each(["merge", "mergeIncremental"] as const)( + "drops and reports a scored match a merged branch's own different assertion holds apart (%s)", + async (entryPoint) => { + const forkPoint = await makeSimilarityStore(); + const source = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + for (const id of ["alpha", "beta"]) { + await source.store.nodes.Person.create( + { name: id, email: `${id}@example.test` }, + { id }, + ); + } + await source.store.identity.assertDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ); + const [staged] = await source.store.identity.assertionsOf({ + kind: "Person", + id: "alpha", + }); + const resolve = { + Person: { + ...ONE_BUCKET, + threshold: 0.5, + similarity: { kind: "custom", score: () => 1 }, + }, + } as const; + + const target = + entryPoint === "merge" ? forkPoint : ( + unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store + ); + const result = + entryPoint === "merge" ? + await merge(forkPoint, [source], { resolve }) + : await mergeIncremental({ + forkPoint, + target, + branches: [source], + options: { branchOrder: [BRANCH_A], resolve }, + }); + + if (isErr(result)) throw result.error; + expect(staged).toBeDefined(); + expect(result.data.identityConflicts).toEqual([ + expect.objectContaining({ + kind: "separation", + a: { kind: "Person", id: "alpha" }, + b: { kind: "Person", id: "beta" }, + assertionIds: [staged?.id], + }), + ]); + expect(await livePersonIds(target)).toEqual(["alpha", "beta"]); + const landed = await target.identity.assertionsOf({ + kind: "Person", + id: "alpha", + }); + expect(landed.map((assertion) => assertion.id)).toEqual([staged?.id]); + expect( + await target.identity.areDifferent( + { kind: "Person", id: "alpha" }, + { kind: "Person", id: "beta" }, + ), + ).toBe(true); + }, + ); +}); diff --git a/packages/typegraph/tests/graph-merge/identity-target-history.test.ts b/packages/typegraph/tests/graph-merge/identity-target-history.test.ts new file mode 100644 index 000000000..5b225c84a --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-target-history.test.ts @@ -0,0 +1,717 @@ +/** + * A merge's identity claims are judged against the target's whole history, + * not only its current rows, and at plan time: + * + * - a base assertion a branch retracts becomes history holding until the + * retraction's own end, so another branch's claim over that span is + * checked against it (otherwise the merge commits a ledger that is both + * same and different there); + * - a row the target ended before the fork — which a branch does not carry, + * so the branch accepted a backdated claim over it — refuses the plan with + * the contradiction and its window, instead of passing review and failing + * inside the commit. + */ +import type { GraphBackend } from "@nicia-ai/typegraph"; +import { + asNodeId, + createStoreWithSchema, + defineGraph, + defineNode, +} from "@nicia-ai/typegraph"; +import { afterAll, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { + BaseVersionMismatchError, + IdentityMergeConflictError, +} from "../../src/graph-merge/errors"; +import { + merge, + mergeIncremental, + planMerge, + planMergeIncremental, +} from "../../src/graph-merge/merge"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; +import { importGraph } from "../../src/interchange"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { createPgliteFixturePool } from "./pglite-fixture-pool"; +import { backendMatrix } from "./test-utils"; + +const Agent = defineNode("Agent", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "identity_retracted_history", + nodes: { Agent: { type: Agent } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const BRANCH_A = asBranchId("retracted-history-a"); +const BRANCH_B = asBranchId("retracted-history-b"); +const TARGET_CLONE = asBranchId("target-history-target"); + +const OPPOSING = + "Branches asserted opposing identity relations for one endpoint pair."; +const CONTRADICTION = + "The merged identity ledger would assert one pair of nodes is both the same and different."; + +/** A state import of one `same` assertion valid since 2024 — a backdated claim. */ +function backdatedSame(id: string, left: string, right: string) { + return { + formatVersion: "2.0" as const, + exportedAt: "2026-04-01T00:00:00.000Z", + source: { type: "external" as const }, + nodes: [], + edges: [], + identity: { + profile: "typegraph-identity-v1" as const, + mode: "state" as const, + assertions: [ + { + id, + relation: "same" as const, + a: agent(left), + b: agent(right), + validFrom: "2024-01-01T00:00:00.000Z", + }, + ], + }, + }; +} + +function agent(id: string) { + return { kind: "Agent" as const, id }; +} + +const pglitePool = createPgliteFixturePool(); +afterAll(async () => { + await pglitePool.dispose(); +}); + +describe.each(backendMatrix())( + "a merge judging claims against retracted base history [$name]", + (entry) => { + it("refuses at plan time a claim that contradicts a base row over the time it still held", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [base] = await createStoreWithSchema(graph, await makeBackend()); + for (const id of ["n1", "n2", "n4"]) { + await base.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const inherited = await base.identity.assertSame( + agent("n1"), + agent("n2"), + ); + const branchA = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + const branchB = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_B }), + ); + + // Branch A: n2 and n4 differ from March — consistent, since n1 ~ n2 + // says nothing about n4 on this branch. + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + const separated = await branchA.store.identity.assertDifferent( + agent("n2"), + agent("n4"), + ); + // Branch B: n1 ~ n4 since 2024, and n1 ~ n2 ends in May — consistent, + // since nothing on this branch separates n2 from n4. + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchB.store, + { + formatVersion: "2.0", + exportedAt: "2026-04-01T00:00:00.000Z", + source: { type: "external" }, + nodes: [], + edges: [], + identity: { + profile: "typegraph-identity-v1", + mode: "state", + assertions: [ + { + id: "backdated-same", + relation: "same", + a: agent("n1"), + b: agent("n4"), + validFrom: "2024-01-01T00:00:00.000Z", + }, + ], + }, + }, + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + await branchB.store.identity.retractAssertion(inherited.assertion.id); + + // Together, n2 ~ n1 ~ n4 from March to May while n2 and n4 differ. + vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + const result = await merge(base, [branchA, branchB], { + branchOrder: [BRANCH_A, BRANCH_B], + }); + expect(isErr(result)).toBe(true); + if (!isErr(result)) return; + console.info("retracted-history refusal", result.error.details); + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + expect(result.error.message).toBe(CONTRADICTION); + const ledger = await storeRuntime(base).identityAssertionRowsByIds([ + inherited.assertion.id, + separated.assertion.id, + "backdated-same", + ]); + expect([...ledger.keys()]).toEqual([inherited.assertion.id]); + expect(ledger.get(inherited.assertion.id)?.validTo).toBeUndefined(); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("refuses at plan time a branch claim over a row the target ended before the fork", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [base] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + for (const id of ["n1", "n2"]) { + await base.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const separated = await base.identity.assertDifferent( + agent("n1"), + agent("n2"), + ); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + await base.identity.retractAssertion(separated.assertion.id); + + // The branch does not carry the ended row, so on its own ledger a + // `same` valid since 2024 contradicts nothing. + const branchA = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchA.store, + backdatedSame("backdated-same", "n1", "n2"), + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + const expectedDetails = { + assertion: { + id: separated.assertion.id, + relation: "different", + a: agent("n1"), + b: agent("n2"), + validFrom: "2026-02-01T00:00:00.000Z", + validTo: "2026-03-01T00:00:00.000Z", + }, + sameClass: [agent("n1"), agent("n2")], + window: { + validFrom: "2026-02-01T00:00:00.000Z", + validTo: "2026-03-01T00:00:00.000Z", + }, + }; + const planned = await planMerge(base, [branchA]); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + console.info("ended-history plan refusal", planned.error.details); + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.message).toBe(CONTRADICTION); + expect(planned.error.details).toEqual(expectedDetails); + + const merged = await merge(base, [branchA]); + expect(isErr(merged) && merged.error.message).toBe(CONTRADICTION); + expect( + [ + ...( + await storeRuntime(base).identityAssertionRowsByIds([ + "backdated-same", + ]) + ).keys(), + ], + "nothing was written", + ).toEqual([]); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("refuses at plan time an incremental branch claim over a row the target ended before the fork", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [target] = await createStoreWithSchema( + graph, + await makeBackend(), + { + revisionTracking: true, + }, + ); + for (const id of ["n1", "n2"]) { + await target.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const separated = await target.identity.assertDifferent( + agent("n1"), + agent("n2"), + ); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + await target.identity.retractAssertion(separated.assertion.id); + // An incremental merge into the fork point itself: the branch does not + // carry the ended row, the target holds it as its own history. + const forkPoint = target; + const branchA = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchA.store, + backdatedSame("backdated-same", "n1", "n2"), + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + // The plan check alone: a snapshot plan runs no post-plan recheck. + const snapshotPlan = await planMerge(forkPoint, [branchA]); + expect(isErr(snapshotPlan) && snapshotPlan.error.message).toBe( + CONTRADICTION, + ); + const args = { forkPoint, target, branches: [branchA] }; + const planned = await planMergeIncremental(args); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.message).toBe(CONTRADICTION); + expect(planned.error.details).toMatchObject({ + assertion: { id: separated.assertion.id }, + window: { + validFrom: "2026-02-01T00:00:00.000Z", + validTo: "2026-03-01T00:00:00.000Z", + }, + }); + const merged = await mergeIncremental(args); + expect(isErr(merged) && merged.error.message).toBe(CONTRADICTION); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("refuses at plan time an incremental branch claim over a row the advanced target ended", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [forkPoint] = await createStoreWithSchema( + graph, + await makeBackend(), + { revisionTracking: true }, + ); + for (const id of ["n1", "n2"]) { + await forkPoint.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + const branchA = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + // The target, after the fork: n1 and n2 differ from February to March. + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const separated = await target.identity.assertDifferent( + agent("n1"), + agent("n2"), + ); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + await target.identity.retractAssertion(separated.assertion.id); + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchA.store, + backdatedSame("backdated-same", "n1", "n2"), + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + // The target's own post-fork rows are staged as a side of the merge, + // so the ended `different` meets the backdated `same` as an opposing + // pair over overlapping windows. + const args = { forkPoint, target, branches: [branchA] }; + const planned = await planMergeIncremental(args); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.message).toBe(OPPOSING); + expect(planned.error.details).toMatchObject({ + assertions: [ + { id: "backdated-same" }, + { id: separated.assertion.id }, + ], + }); + const merged = await mergeIncremental(args); + expect(isErr(merged) && merged.error.message).toBe(OPPOSING); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("refuses as a re-plan a target history row committed and ended between planning and commit", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [forkPoint] = await createStoreWithSchema( + graph, + await makeBackend(), + { revisionTracking: true }, + ); + for (const id of ["n1", "n2"]) { + await forkPoint.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + const target = unwrap( + await branch(forkPoint, () => makeBackend(), { id: TARGET_CLONE }), + ).store; + const branchA = unwrap( + await branch(forkPoint, () => makeBackend(), { id: BRANCH_A }), + ); + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchA.store, + backdatedSame("backdated-same", "n1", "n2"), + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + + // After planning, before the commit transaction: the target holds n1 + // and n2 different for a day and ends it — no current row changes. + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + const original = target.transaction.bind(target); + let injected = false; + (target as { transaction: unknown }).transaction = async ( + fn: unknown, + options: unknown, + ) => { + if (!injected) { + injected = true; + const window = await target.identity.assertDifferent( + agent("n1"), + agent("n2"), + ); + vi.setSystemTime(new Date("2026-05-02T00:00:00.000Z")); + await target.identity.retractAssertion(window.assertion.id); + } + return (original as (f: unknown, o: unknown) => unknown)(fn, options); + }; + try { + const result = await mergeIncremental({ + forkPoint, + target, + branches: [branchA], + }); + expect(injected).toBe(true); + expect(isErr(result)).toBe(true); + if (!isErr(result)) return; + console.info("window history refusal", result.error.message); + expect(result.error).toBeInstanceOf(BaseVersionMismatchError); + } finally { + (target as { transaction: unknown }).transaction = original; + } + expect( + [ + ...( + await storeRuntime(target).identityAssertionRowsByIds([ + "backdated-same", + ]) + ).keys(), + ], + "nothing was written", + ).toEqual([]); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("keeps a deleted node's ended history in the plan, as the commit does", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [base] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + for (const id of ["n1", "n2", "n3"]) { + await base.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + // February to March: n1 ~ n3 while n3 and n2 differ. + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const joined = await base.identity.assertSame(agent("n1"), agent("n3")); + const separated = await base.identity.assertDifferent( + agent("n3"), + agent("n2"), + ); + vi.setSystemTime(new Date("2026-03-01T00:00:00.000Z")); + await base.identity.retractAssertion(joined.assertion.id); + await base.identity.retractAssertion(separated.assertion.id); + + // The branch carries none of that history: it claims n1 ~ n2 since + // 2024 and deletes n3, whose ended rows the deletion does not erase. + const branchA = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + const imported = await importGraph( + branchA.store, + backdatedSame("backdated-same", "n1", "n2"), + { onConflict: "skip" }, + ); + expect(imported.errors).toEqual([]); + await branchA.store.nodes.Agent.delete(asNodeId("n3")); + + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + const planned = await planMerge(base, [branchA]); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + console.info("deleted-node history refusal", planned.error.details); + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.message).toBe(CONTRADICTION); + expect(planned.error.details).toMatchObject({ + assertion: { id: separated.assertion.id }, + window: { + validFrom: "2026-02-01T00:00:00.000Z", + validTo: "2026-03-01T00:00:00.000Z", + }, + }); + const merged = await merge(base, [branchA]); + expect(isErr(merged) && merged.error.message).toBe(CONTRADICTION); + expect( + await base.nodes.Agent.getById(asNodeId("n3")), + ).toBeDefined(); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + it("refuses a branch claim valid from after the planner's clock with the typed identity conflict", async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [base] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + for (const id of ["n1", "n2"]) { + await base.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + const branchA = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + // The branch's clock runs a day ahead of the planner's. + vi.setSystemTime(new Date("2026-02-02T00:00:00.000Z")); + await branchA.store.identity.assertSame(agent("n1"), agent("n2")); + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + + const planned = await planMerge(base, [branchA]); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + console.info("future validFrom refusal", planned.error.message); + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.details).toEqual({ + identityCode: "IDENTITY_IMPORT_FUTURE_VALID_FROM", + }); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + + for (const mode of ["snapshot", "incremental"] as const) { + // The target holds a deleted node's assertions until the merge deletes + // it, however early the branch deleted it: a branch claim over that + // span contradicts them and is refused at plan time, never inside the + // commit. + it(`refuses at plan time a claim over a deleted node's assertions the target held until the merge (${mode})`, async () => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date("2026-01-01T00:00:00.000Z")); + const [base] = await createStoreWithSchema( + graph, + await makeBackend(), + { revisionTracking: true }, + ); + for (const id of ["n1", "n2", "n3"]) { + await base.nodes.Agent.create( + {}, + { id, validFrom: "2020-01-01T00:00:00.000Z" }, + ); + } + vi.setSystemTime(new Date("2026-02-01T00:00:00.000Z")); + const joined = await base.identity.assertSame( + agent("n1"), + agent("n3"), + ); + const separated = await base.identity.assertDifferent( + agent("n3"), + agent("n2"), + ); + const target = + mode === "snapshot" ? base : ( + unwrap( + await branch(base, () => makeBackend(), { id: TARGET_CLONE }), + ).store + ); + const branchA = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + // On the branch, deleting n3 ends both of its assertions on April 1, + // so n1 ~ n2 from April 2 contradicts nothing there. + vi.setSystemTime(new Date("2026-04-01T00:00:00.000Z")); + await branchA.store.nodes.Agent.delete(asNodeId("n3")); + vi.setSystemTime(new Date("2026-04-02T00:00:00.000Z")); + const asserted = await branchA.store.identity.assertSame( + agent("n1"), + agent("n2"), + ); + + vi.setSystemTime(new Date("2026-05-01T00:00:00.000Z")); + const planned = + mode === "snapshot" ? + await planMerge(base, [branchA]) + : await planMergeIncremental({ + forkPoint: base, + target, + branches: [branchA], + }); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + expect(planned.error).toBeInstanceOf(IdentityMergeConflictError); + expect(planned.error.message).toBe(CONTRADICTION); + expect(planned.error.details).toMatchObject({ + assertion: { id: separated.assertion.id }, + }); + const merged = + mode === "snapshot" ? + await merge(base, [branchA]) + : await mergeIncremental({ + forkPoint: base, + target, + branches: [branchA], + }); + expect(isErr(merged) && merged.error.message).toBe(CONTRADICTION); + const rows = await storeRuntime(target).identityAssertionRowsByIds([ + joined.assertion.id, + asserted.assertion.id, + ]); + expect(rows.get(joined.assertion.id)?.validTo).toBeUndefined(); + expect(rows.has(asserted.assertion.id)).toBe(false); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }); + } + }, +); diff --git a/packages/typegraph/tests/graph-merge/identity-three-way.test.ts b/packages/typegraph/tests/graph-merge/identity-three-way.test.ts new file mode 100644 index 000000000..f63d3a7aa --- /dev/null +++ b/packages/typegraph/tests/graph-merge/identity-three-way.test.ts @@ -0,0 +1,587 @@ +/** + * The identity three-way classifier, verified directly against + * `planIdentityChanges` / `classifyIdentityPair` without a full store or + * merge — the same unit-fixture style + * `tests/graph-merge/identity-merge.test.ts` uses for `planIdentityChanges`. + * + * The invariant under test: every staged identity assertion and retraction + * ends as a planned write, an explicit `dropped` entry, or a refusal that + * names it. None silently disappears. + */ +import { describe, expect, it } from "vitest"; + +import { IdentityMergeConflictError } from "../../src/graph-merge/errors"; +import { + classifyIdentityPair, + DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, +} from "../../src/graph-merge/identity-three-way"; +import { + planIdentityChanges, + RETRACTION_TARGET_MISMATCH_DROP_REASON, +} from "../../src/graph-merge/merge-identity"; +import type { StagingSet } from "../../src/graph-merge/staging"; +import type { IdentityTransferAssertion } from "../../src/graph-merge/typegraph-internal"; +import { asBranchId, type BranchId } from "../../src/graph-merge/types"; + +const BRANCH_A = asBranchId("branch-a"); +const BRANCH_B = asBranchId("branch-b"); + +/** A branch-tagged assertion, as `stageBranches` produces. */ +type StagedAssertion = Readonly<{ + branchId: BranchId; + assertion: IdentityTransferAssertion; +}>; + +/** + * An otherwise-empty {@link StagingSet} carrying only identity changes. + * Mirrors `identity-merge.test.ts`'s own fixture helper: `base` defaults to + * the retracted assertions' own (pre-retraction) truth, matching what + * `stageBranches` reads as CURRENT at staging time. + */ +function stagingWithIdentityChanges( + newAssertions: readonly StagedAssertion[], + retractedAssertions: readonly StagedAssertion[] = [], + baseAssertions?: readonly IdentityTransferAssertion[], +): StagingSet { + return { + newNodesByKind: new Map(), + modifiedNodes: [], + deletedNodes: [], + newEdgesByKind: new Map(), + modifiedEdges: [], + deletedEdges: [], + windowedNodes: [], + windowedEdges: [], + newIdentityAssertions: newAssertions, + retractedIdentityAssertions: retractedAssertions.map((staged) => ({ + ...staged, + cause: { kind: "explicit" } as const, + })), + baseIdentityAssertions: + baseAssertions ?? retractedAssertions.map((staged) => staged.assertion), + targetNodeVersions: new Map(), + targetEdgeSignatures: new Map(), + }; +} + +const SAME_PAIR = { + relation: "same", + a: { kind: "Person", id: "first" }, + b: { kind: "Person", id: "second" }, + validFrom: "2024-01-01T00:00:00.000Z", +} as const; + +function caughtFrom(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + return undefined; +} + +describe("a retract/reassert race", () => { + const inherited: IdentityTransferAssertion = { ...SAME_PAIR, id: "a-1" }; + const reasserted: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "a-2", + validFrom: "2024-02-01T00:00:00.000Z", + }; + + it("is refused, naming the retraction, the reassertion and both branches", () => { + const caught = caughtFrom(() => + planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_B, assertion: reasserted }], + [{ branchId: BRANCH_A, assertion: inherited }], + ), + new Map(), + ), + ); + expect(caught).toBeInstanceOf(IdentityMergeConflictError); + const error = caught as IdentityMergeConflictError; + expect(error.message).toBe( + "Branches contain a retract/reassert race for one identity pair.", + ); + expect(error.details).toEqual({ + retractedAssertion: inherited, + retractedBy: BRANCH_A, + reassertedAssertion: reasserted, + reassertedBy: BRANCH_B, + }); + }); + + it("is refused when another branch retracts the pair without reasserting it, naming that branch", () => { + const caught = caughtFrom(() => + planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_B, assertion: reasserted }], + [ + { branchId: BRANCH_A, assertion: inherited }, + { branchId: BRANCH_B, assertion: inherited }, + ], + ), + new Map(), + ), + ); + expect(caught).toBeInstanceOf(IdentityMergeConflictError); + const error = caught as IdentityMergeConflictError; + console.info("race refusal", error.details); + expect(error.details).toEqual({ + retractedAssertion: inherited, + retractedBy: BRANCH_A, + reassertedAssertion: reasserted, + reassertedBy: BRANCH_B, + }); + }); + + it("is refused when one branch retracts and reasserts the pair while another only reasserts it", () => { + const reassertedByA: IdentityTransferAssertion = { + ...reasserted, + id: "a-3", + }; + const caught = caughtFrom(() => + planIdentityChanges( + stagingWithIdentityChanges( + [ + { branchId: BRANCH_A, assertion: reassertedByA }, + { branchId: BRANCH_B, assertion: reasserted }, + ], + [{ branchId: BRANCH_A, assertion: inherited }], + ), + new Map(), + ), + ); + expect(caught).toBeInstanceOf(IdentityMergeConflictError); + const error = caught as IdentityMergeConflictError; + console.info("one-sided reassertion race", error.details); + expect(error.details).toEqual({ + retractedAssertion: inherited, + retractedBy: BRANCH_A, + reassertedAssertion: reasserted, + reassertedBy: BRANCH_B, + }); + }); + + it("is not a race when the reasserting branch retracted the pair itself: both writes are planned", () => { + const ended: IdentityTransferAssertion = { + ...inherited, + validTo: reasserted.validFrom, + }; + const planned = planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_A, assertion: reasserted }], + [{ branchId: BRANCH_A, assertion: ended }], + [inherited], + ), + new Map(), + ); + console.info("convergent plan", planned); + expect(planned.assertions).toEqual([reasserted]); + // The base row ends at the instant the branch itself staged. + expect(planned.retractions).toEqual([ended]); + expect(planned.dropped).toEqual([]); + }); + + /** + * Only an assertion that holds NOW reasserts the pair. Branch B retracted the + * pair, asserted it again under a new id, and retracted that too, so B agrees + * with A that the pair no longer holds; its ended row is history. + */ + it("is not a race when the other branch's reassertion has already ended", () => { + const endedReassertion: IdentityTransferAssertion = { + ...reasserted, + validTo: "2024-03-01T00:00:00.000Z", + }; + const planned = planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_B, assertion: endedReassertion }], + [ + { branchId: BRANCH_A, assertion: inherited }, + { branchId: BRANCH_B, assertion: inherited }, + ], + ), + new Map(), + ); + console.info("ended reassertion plan", planned); + expect(planned.assertions).toEqual([endedReassertion]); + expect(planned.retractions).toEqual([inherited]); + expect(planned.dropped).toEqual([]); + }); + + it("is not a race when the other branch only records the pair's history", () => { + const history: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "a-history", + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }; + const planned = planIdentityChanges( + stagingWithIdentityChanges( + [{ branchId: BRANCH_B, assertion: history }], + [{ branchId: BRANCH_A, assertion: inherited }], + ), + new Map(), + ); + console.info("history-only plan", planned); + expect(planned.assertions).toEqual([history]); + expect(planned.retractions).toEqual([inherited]); + expect(planned.dropped).toEqual([]); + }); +}); + +/** + * The refusal's witness is the `same` and `different` whose windows overlap — + * not whichever of each relation sorts first. Here the first `same` is a + * bounded window that ended before the `different` began, so naming it would + * point the caller at a claim that contradicts nothing. + */ +describe("an opposing-relations refusal", () => { + it("names the overlapping pair, not the first of each relation", () => { + const boundedSame: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "o-1", + validFrom: "2000-01-01T00:00:00.000Z", + validTo: "2001-01-01T00:00:00.000Z", + }; + const currentDifferent: IdentityTransferAssertion = { + ...SAME_PAIR, + relation: "different", + id: "o-2", + }; + const currentSame: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "o-3", + validFrom: "2024-03-01T00:00:00.000Z", + }; + const caught = caughtFrom(() => + planIdentityChanges( + stagingWithIdentityChanges( + [ + { branchId: BRANCH_A, assertion: boundedSame }, + { branchId: BRANCH_A, assertion: currentDifferent }, + { branchId: BRANCH_B, assertion: currentSame }, + ], + [], + [], + ), + new Map(), + ), + ); + expect(caught).toBeInstanceOf(IdentityMergeConflictError); + const error = caught as IdentityMergeConflictError; + console.info("opposing refusal", error.details); + expect(error.details["assertions"]).toEqual([ + currentSame, + currentDifferent, + ]); + }); +}); + +describe("duplicate assertions of one pair", () => { + const earlier: IdentityTransferAssertion = { ...SAME_PAIR, id: "b-1" }; + const later: IdentityTransferAssertion = { + ...SAME_PAIR, + id: "b-2", + validFrom: "2024-02-01T00:00:00.000Z", + }; + const duplicateStaging = (): StagingSet => + stagingWithIdentityChanges([ + { branchId: BRANCH_B, assertion: later }, + { branchId: BRANCH_A, assertion: earlier }, + ]); + + it("keep the earliest and report every other id as dropped", () => { + const planned = planIdentityChanges(duplicateStaging(), new Map()); + expect(planned.assertions.map((entry) => entry.id)).toEqual(["b-1"]); + expect(planned.dropped).toEqual([ + { + kind: "identity", + id: "b-2", + reason: DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, + }, + ]); + }); + + it("keep the id the target already holds, whatever its validFrom", () => { + const planned = planIdentityChanges( + duplicateStaging(), + new Map([["b-2", later]]), + ); + expect(planned.assertions.map((entry) => entry.id)).toEqual(["b-2"]); + expect(planned.dropped).toEqual([ + { + kind: "identity", + id: "b-1", + reason: DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, + }, + ]); + }); + + it("collapse silently when two branches staged the identical row", () => { + const planned = planIdentityChanges( + stagingWithIdentityChanges([ + { branchId: BRANCH_A, assertion: earlier }, + { branchId: BRANCH_B, assertion: earlier }, + ]), + new Map(), + ); + // The one row is written; reporting it as dropped too would contradict + // the write. + expect(planned.assertions).toEqual([earlier]); + expect(planned.dropped).toEqual([]); + }); +}); + +/** + * ORDER IS BEHAVIOR: a staging set that trips more than one check reports the + * classifier's cross-cutting refusals first, then the structural + * one-id-one-truth checks — moving either past the other changes which error a + * caller sees for the same input. + */ +describe("validation order across the classifier and the structural checks", () => { + it("reports the opposing-relations refusal, not the id collision it also carries", () => { + const opposing: IdentityTransferAssertion = { + ...SAME_PAIR, + relation: "different", + id: "d-1", + }; + const staging = stagingWithIdentityChanges([ + { branchId: BRANCH_A, assertion: { ...SAME_PAIR, id: "dup" } }, + { + branchId: BRANCH_B, + assertion: { + ...SAME_PAIR, + id: "dup", + validFrom: "2024-05-01T00:00:00.000Z", + }, + }, + { branchId: BRANCH_B, assertion: opposing }, + ]); + const caught = caughtFrom(() => planIdentityChanges(staging, new Map())); + expect(caught).toBeInstanceOf(IdentityMergeConflictError); + const error = caught as IdentityMergeConflictError; + expect(error.message).toBe( + "Branches asserted opposing identity relations for one endpoint pair.", + ); + expect( + (error.details["assertions"] as readonly IdentityTransferAssertion[]).map( + (assertion) => assertion.relation, + ), + ).toEqual(["same", "different"]); + }); +}); + +/** + * The base slice is the target's CURRENT truth (open rows only); a retraction + * is derived from an ARCHIVAL read, so a branch retracting a row the target + * has already ended stages a retraction with no base group to classify + * against. It must still reach the plan — or be dropped with a typed reason — + * never vanish. + */ +describe("a staged retraction whose base row is already ended", () => { + const alreadyEnded: IdentityTransferAssertion = { ...SAME_PAIR, id: "a-1" }; + const orphanStaging = (): StagingSet => + stagingWithIdentityChanges( + [], + [{ branchId: BRANCH_A, assertion: alreadyEnded }], + // The target's CURRENT truth holds nothing for this pair: the row the + // branch retracts was ended before the merge. + [], + ); + + it("is planned, not silently dropped", () => { + const planned = planIdentityChanges(orphanStaging(), new Map()); + expect(planned.retractions.map((entry) => entry.id)).toEqual(["a-1"]); + expect(planned.dropped).toEqual([]); + expect(planned.assertions).toEqual([]); + }); + + it("is dropped with a typed reason when the target's OPEN row is a different truth", () => { + const planned = planIdentityChanges( + orphanStaging(), + new Map([ + [ + "a-1", + { + id: "a-1", + relation: "same" as const, + a: { kind: "Person", id: "first" }, + b: { kind: "Person", id: "third" }, + validFrom: SAME_PAIR.validFrom, + }, + ], + ]), + ); + expect(planned.retractions).toEqual([]); + expect(planned.dropped).toEqual([ + { + kind: "identity", + id: "a-1", + reason: RETRACTION_TARGET_MISMATCH_DROP_REASON, + }, + ]); + }); + + /** + * Only an OPEN row of other truth is truth the retraction could wrongly end. + * A row the target already ENDED under the reused id holds nothing now, so + * the retraction stays planned (ending no current row) rather than being + * reported as a mismatch against truth the target's ledger no longer holds. + */ + it("stays planned when the target's row under that id is an ENDED different truth", () => { + const planned = planIdentityChanges( + orphanStaging(), + new Map([ + [ + "a-1", + { + id: "a-1", + relation: "same" as const, + a: { kind: "Person", id: "first" }, + b: { kind: "Person", id: "third" }, + validFrom: SAME_PAIR.validFrom, + validTo: "2024-06-01T00:00:00.000Z", + }, + ], + ]), + ); + expect(planned.retractions.map((entry) => entry.id)).toEqual(["a-1"]); + expect(planned.dropped).toEqual([]); + }); +}); + +/** + * Two branches ending the SAME base identity assertion at DIFFERENT + * valid-time instants reduce to one retraction: the EARLIEST staged `validTo` + * wins, order-independent — never "the last one staged". + */ +describe("ending a doubly-retracted base row picks the EARLIEST end, not the last staged", () => { + const basePair: IdentityTransferAssertion = { ...SAME_PAIR, id: "base-1" }; + const earlyEnd: IdentityTransferAssertion = { + ...basePair, + validTo: "2024-03-01T00:00:00.000Z", + }; + const lateEnd: IdentityTransferAssertion = { + ...basePair, + validTo: "2024-09-01T00:00:00.000Z", + }; + const earlyRetraction = { + branchId: BRANCH_A, + assertion: earlyEnd, + cause: { kind: "explicit" } as const, + }; + const lateRetraction = { + branchId: BRANCH_B, + assertion: lateEnd, + cause: { kind: "explicit" } as const, + }; + + it("picks the earlier end whichever order the retractions are staged in", () => { + const forward = classifyIdentityPair( + [basePair], + [], + [earlyRetraction, lateRetraction], + new Set(), + ); + const backward = classifyIdentityPair( + [basePair], + [], + [lateRetraction, earlyRetraction], + new Set(), + ); + expect(forward).toEqual({ kind: "retracted", retraction: earlyEnd }); + expect(backward).toEqual({ kind: "retracted", retraction: earlyEnd }); + }); + + it("agrees end to end through planIdentityChanges", () => { + const staging = stagingWithIdentityChanges( + [], + [lateRetraction, earlyRetraction], + [basePair], + ); + const planned = planIdentityChanges(staging, new Map()); + expect(planned.retractions).toEqual([earlyEnd]); + }); +}); + +describe("every staged identity change is accounted for", () => { + it("names each staged id as a planned write or an explicit drop", () => { + const pair = ( + id: string, + second: string, + validFrom: string = SAME_PAIR.validFrom, + ): IdentityTransferAssertion => ({ + ...SAME_PAIR, + b: { kind: "Person", id: second }, + id, + validFrom, + }); + const retractedBase = pair("base-retracted", "retracted"); + const replacedBase = pair("base-replaced", "replaced"); + const newAssertions: StagedAssertion[] = [ + // A lone assertion. + { branchId: BRANCH_A, assertion: pair("lone", "lone") }, + // Three ids for one pair: one survives, two are dropped. + { branchId: BRANCH_A, assertion: pair("dup-1", "dup") }, + { + branchId: BRANCH_B, + assertion: pair("dup-2", "dup", "2024-02-01T00:00:00.000Z"), + }, + { + branchId: BRANCH_B, + assertion: pair("dup-3", "dup", "2024-03-01T00:00:00.000Z"), + }, + // A replacement its own branch retracted the base row for. + { + branchId: BRANCH_A, + assertion: pair("replacement", "replaced", "2024-04-01T00:00:00.000Z"), + }, + ]; + const retractedAssertions: StagedAssertion[] = [ + { + branchId: BRANCH_B, + assertion: { ...retractedBase, validTo: "2024-05-01T00:00:00.000Z" }, + }, + { + branchId: BRANCH_A, + assertion: { ...replacedBase, validTo: "2024-04-01T00:00:00.000Z" }, + }, + // The target already ended this row. + { + branchId: BRANCH_A, + assertion: { + ...pair("already-ended", "ended"), + validTo: "2024-06-01T00:00:00.000Z", + }, + }, + ]; + const planned = planIdentityChanges( + stagingWithIdentityChanges(newAssertions, retractedAssertions, [ + retractedBase, + replacedBase, + ]), + new Map(), + ); + console.info("accounted plan", planned); + + const idsOf = (entries: readonly Readonly<{ id: string }>[]) => + entries.map((entry) => entry.id).toSorted(); + expect(idsOf(planned.assertions)).toEqual(["dup-1", "lone", "replacement"]); + expect(idsOf(planned.dropped)).toEqual(["dup-2", "dup-3"]); + expect(idsOf(planned.retractions)).toEqual([ + "already-ended", + "base-replaced", + "base-retracted", + ]); + const accounted = new Set([ + ...idsOf(planned.assertions), + ...idsOf(planned.dropped), + ...idsOf(planned.retractions), + ]); + const staged = [...newAssertions, ...retractedAssertions].map( + (entry) => entry.assertion.id, + ); + expect(staged.filter((id) => !accounted.has(id))).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/identity-universe-guards.test.ts b/packages/typegraph/tests/graph-merge/identity-universe-guards.test.ts index 47b26bd20..c680a5e7f 100644 --- a/packages/typegraph/tests/graph-merge/identity-universe-guards.test.ts +++ b/packages/typegraph/tests/graph-merge/identity-universe-guards.test.ts @@ -1066,7 +1066,16 @@ describe.each(backendMatrix())("identity universe guards [$name]", (entry) => { }); expect(isErr(result)).toBe(true); if (isOk(result)) throw new Error("Expected typed replan refusal"); - expect(result.error).toBeInstanceOf(BaseVersionMismatchError); + // Deleting the bridge leaves its assertions holding on the target + // until the merge (see "refuses backdating a post-deletion relation"), + // so planning already refuses that variant, before the window write + // could matter; only the retraction variant reaches the commit guard. + expect(result.error).toBeInstanceOf( + mode === "delete" ? + IdentityMergeConflictError + : BaseVersionMismatchError, + ); + expect(injected).toBe(mode === "retract"); } finally { (target as { transaction: unknown }).transaction = original; } diff --git a/packages/typegraph/tests/graph-merge/merge-plan.test.ts b/packages/typegraph/tests/graph-merge/merge-plan.test.ts index 5c3ec5807..2bb8604bb 100644 --- a/packages/typegraph/tests/graph-merge/merge-plan.test.ts +++ b/packages/typegraph/tests/graph-merge/merge-plan.test.ts @@ -27,7 +27,8 @@ import { planMergeIncremental, } from "../../src/graph-merge/merge"; import { canonicalMergePlanJson } from "../../src/graph-merge/plan-canonical"; -import type { MergePlanArtifactV1 } from "../../src/graph-merge/plan-schema"; +import type { MergePlanArtifactV2 } from "../../src/graph-merge/plan-schema"; +import { MERGE_PLAN_FORMAT_VERSION } from "../../src/graph-merge/plan-schema"; import { constructMergePlanArtifact } from "../../src/graph-merge/plan-wire"; import { isErr, isOk, unwrap } from "../../src/graph-merge/result"; import type { Embedder, GraphBranch } from "../../src/graph-merge/types"; @@ -118,9 +119,9 @@ describe("public merge plan lifecycle", () => { } async function rehashTarget( - artifact: MergePlanArtifactV1, - target: MergePlanArtifactV1["target"], - ): Promise { + artifact: MergePlanArtifactV2, + target: MergePlanArtifactV2["target"], + ): Promise { const { digest: _digest, ...input } = artifact; return constructMergePlanArtifact({ ...input, target }); } @@ -219,7 +220,7 @@ describe("public merge plan lifecycle", () => { } const malformed = await applyMergePlan(base, { - formatVersion: 1, + formatVersion: MERGE_PLAN_FORMAT_VERSION, } as unknown as typeof artifact); expect(isErr(malformed)).toBe(true); if (isErr(malformed)) diff --git a/packages/typegraph/tests/graph-merge/namespace-fork.test.ts b/packages/typegraph/tests/graph-merge/namespace-fork.test.ts index ab7eb7626..f3e936b27 100644 --- a/packages/typegraph/tests/graph-merge/namespace-fork.test.ts +++ b/packages/typegraph/tests/graph-merge/namespace-fork.test.ts @@ -10,12 +10,22 @@ import { type Store, } from "../../src"; import { createPostgresBackend } from "../../src/backend/drizzle/postgres"; +import { defaultPostgresTableNames } from "../../src/backend/drizzle/schema/postgres-table-names"; +import { + GRAPH_RELATION_KEYS, + type GraphRelationKey, + resolveGraphRelationNames, +} from "../../src/backend/graph-relations"; import { createLocalPgliteBackend } from "../../src/backend/postgres/pglite"; import { installRevisionChangesJournal } from "../../src/backend/revision-journal"; +import type { GraphBackend } from "../../src/backend/types"; import { forkGraphNamespace, prepareNamespaceForkTarget, } from "../../src/graph-merge/namespace-fork"; +import { sql } from "../../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../../src/query/sql-intent"; +import { sha256Hex } from "../../src/utils/hash"; const Item = defineNode("Item", { schema: z.object({ name: z.string() }) }); const graph = defineGraph({ @@ -52,6 +62,59 @@ const clusteredGraph = defineGraph({ }); const QUERY = [1, 0, 0]; +/** + * The graph-scoped relations as the first release that shipped namespace forks + * knew them. A proof that release recorded hashes one entry per relation + * here, empty or not, so this list is a fixture, never derived from the + * current inventory. + */ +const RELEASED_FORK_RELATION_KEYS = [ + "nodes", + "edges", + "recordedNodes", + "recordedEdges", + "recordedClock", + "revisionOrigins", + "revisionChanges", + "identityAssertions", + "recordedIdentityAssertions", + "identityClosure", + "identitySeparation", + "uniques", + "edgeClaims", + "schemaVersions", + "fulltext", + "indexMaterializations", + "contributionMaterializations", + "kindRemovals", + "reconciliationMarkers", +] as const satisfies readonly GraphRelationKey[]; + +const RELATION_NAMES = resolveGraphRelationNames(defaultPostgresTableNames); + +/** The content digest exactly as the released library computed it. */ +async function releasedForkDigest( + backend: GraphBackend, + graphId: string, +): Promise { + const digests: [string, string][] = []; + for (const key of RELEASED_FORK_RELATION_KEYS) { + if (key === "fulltext" && backend.fulltextStrategy === undefined) continue; + const table = RELATION_NAMES[key]; + const rows = await backend.execute>( + asCompiledRowsSql( + sql`SELECT to_jsonb(row) AS row FROM ${sql.identifier(table)} AS row WHERE graph_id = ${graphId}`, + ), + ); + const rowDigest = await sha256Hex( + JSON.stringify(rows.map((entry) => JSON.stringify(entry.row)).sort()), + 32, + ); + digests.push([table, rowDigest]); + } + return sha256Hex(JSON.stringify(digests), 32); +} + const cleanups: (() => Promise)[] = []; afterEach(async () => { @@ -173,6 +236,108 @@ describe("forkGraphNamespace", () => { expect(fork.proof.contentDigest).toMatch(/^[a-f\d]{64}$/); }); + it("verifies a proof recorded before later relations joined the inventory", async () => { + // A relation added to the graph-scoped inventory after a fork was taken + // holds no rows for that fork. Its arrival must not change the digest a + // retry or an abort verifies, or every unpublished fork is stranded by + // the upgrade that adds it. + expect(GRAPH_RELATION_KEYS.slice(0, 19)).toEqual( + RELEASED_FORK_RELATION_KEYS, + ); + expect(GRAPH_RELATION_KEYS.length).toBeGreaterThan( + RELEASED_FORK_RELATION_KEYS.length, + ); + const sourceFixture = await createLocalPgliteBackend({ vector: false }); + const targetFixture = await createLocalPgliteBackend({ vector: false }); + cleanups.push(sourceFixture.backend.close, targetFixture.backend.close); + const [source] = await createStoreWithSchema(graph, sourceFixture.backend, { + history: true, + }); + await prepareNamespaceForkTarget(source, targetFixture.backend); + await source.nodes.Item.create({ name: "kept" }); + + const fork = await forkGraphNamespace( + source, + targetFixture.backend, + "pre-upgrade-fork", + ); + const released = await releasedForkDigest(targetFixture.backend, graph.id); + console.info("fork digests", { + recorded: fork.proof.contentDigest, + released, + }); + expect(fork.proof.contentDigest).toBe(released); + + // The ledger row as the released library left it. + await targetFixture.client.query( + "UPDATE typegraph_namespace_fork_operations SET content_digest = $1 WHERE operation_key = $2", + [released, "pre-upgrade-fork"], + ); + const retried = await forkGraphNamespace( + source, + targetFixture.backend, + "pre-upgrade-fork", + ); + expect(retried.proof).toEqual(fork.proof); + + // A target the released library prepared has no table for a relation + // that did not exist yet. Retry and abort treat it as holding no rows. + for (const key of GRAPH_RELATION_KEYS.slice( + RELEASED_FORK_RELATION_KEYS.length, + )) { + await targetFixture.client.query( + `DROP TABLE ${RELATION_NAMES[key]} CASCADE`, + ); + } + const retriedWithoutLaterTables = await forkGraphNamespace( + source, + targetFixture.backend, + "pre-upgrade-fork", + ); + expect(retriedWithoutLaterTables.proof).toEqual(fork.proof); + await retriedWithoutLaterTables.abort(); + const remaining = await targetFixture.client.query<{ count: number }>( + `SELECT count(*)::int AS count FROM ${RELATION_NAMES.nodes} WHERE graph_id = $1`, + [graph.id], + ); + expect(remaining.rows[0]?.count).toBe(0); + }); + + it("covers a later relation in the digest once it holds rows", async () => { + const sourceFixture = await createLocalPgliteBackend({ vector: false }); + const targetFixture = await createLocalPgliteBackend({ vector: false }); + cleanups.push(sourceFixture.backend.close, targetFixture.backend.close); + const [source] = await createStoreWithSchema(graph, sourceFixture.backend, { + history: true, + }); + await prepareNamespaceForkTarget(source, targetFixture.backend); + const first = await source.nodes.Item.create({ name: "first" }); + const second = await source.nodes.Item.create({ name: "second" }); + await source.identity.assertSame(first, second); + + const fork = await forkGraphNamespace( + source, + targetFixture.backend, + "transition-fork", + ); + const transitions = await targetFixture.client.query<{ count: number }>( + `SELECT count(*)::int AS count FROM ${RELATION_NAMES.identityTransitions} WHERE graph_id = $1`, + [graph.id], + ); + expect(transitions.rows[0]?.count).toBeGreaterThan(0); + expect(fork.proof.contentDigest).not.toBe( + await releasedForkDigest(targetFixture.backend, graph.id), + ); + + await targetFixture.client.query( + `DELETE FROM ${RELATION_NAMES.identityTransitions} WHERE graph_id = $1`, + [graph.id], + ); + await expect( + forkGraphNamespace(source, targetFixture.backend, "transition-fork"), + ).rejects.toThrow("changed target namespace"); + }); + it("refuses strategy-owned contributions before copying any target rows", async () => { const sourceFixture = await createLocalPgliteBackend({ vector: false }); const targetFixture = await createLocalPgliteBackend({ vector: false }); diff --git a/packages/typegraph/tests/graph-merge/node-delete-policy-wiring.test.ts b/packages/typegraph/tests/graph-merge/node-delete-policy-wiring.test.ts new file mode 100644 index 000000000..2f6a8f0ec --- /dev/null +++ b/packages/typegraph/tests/graph-merge/node-delete-policy-wiring.test.ts @@ -0,0 +1,258 @@ +/** + * Merge apply's node deletion routes through the internal runtime port + * `transactionDeleteNodeWithPolicy`, passing + * `{ enforceDeleteBehavior: true, cascadeComposition: false }`. This does NOT + * cover the facade-to-port switch itself: merge's own delete already went + * through `txNodeOperations`, built from the same `txNodeOperationContext` + * (`store.ts`), so its hooks were already buffered before that switch — the + * switch is behavior-neutral in production today. What these two tests DO + * guard, each under its own case: + * + * - ENFORCEMENT: the policy merge apply passes does not drop delete-behavior + * enforcement — a target node's own `restrict` edge still aborts the + * merge, and zero rows change on that refusal. + * - CONSUMED EDGES: an edge the plan itself deletes does not block its + * `restrict` endpoint's delete. The branch had to delete the edge before + * the node, and the apply must accept the plan that ordering produced, + * through both the live merge and an applied plan artifact. + * - ROUTING: the delete is bound to the SAME transaction merge apply is + * already running in — its buffered hook runner and attempt — not a + * freshly-built context off the outer Store. A delete inside a merge that + * later rolls back must never report `onOperationEnd`; a context built + * fresh from the outer Store fires that hook the instant the delete runs, + * before the enclosing transaction's outcome is known. + */ +import type { GraphBackend } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import type { MergePlan } from "../../src/graph-merge/merge"; +import { + applyMergePlan, + commitPlan, + merge, + planMerge, +} from "../../src/graph-merge/merge"; +import { mergeKey } from "../../src/graph-merge/node-key"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import { requireDefined } from "../../src/utils/presence"; +import { backendMatrix } from "./test-utils"; + +const Whole = defineNode("Whole", { schema: z.object({}) }); +const Part = defineNode("Part", { schema: z.object({}) }); +const Other = defineNode("Other", { schema: z.object({}) }); +const holds = defineEdge("holds", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "node-delete-policy-wiring", + nodes: { + Whole: { type: Whole, onDelete: "restrict" }, + Part: { type: Part }, + Other: { type: Other }, + }, + edges: { + holds: { type: holds, from: [Whole], to: [Part] }, + }, +}); +type G = typeof graph; + +/** An empty plan; the test overrides only the slice it exercises. */ +function emptyPlan(): MergePlan { + return { + canonicalEntities: [], + survivingModifications: [], + nodeDeletions: new Map(), + edgeDeletions: new Map(), + mergedEdges: [], + inheritedEdgeBaseProps: new Map(), + retypeMap: new Map(), + nodeValidityEnds: new Map(), + edgeValidityEnds: new Map(), + validityEnds: [], + resolutions: [], + propertyConflicts: [], + deleteModifyConflicts: [], + typeReconciliations: [], + dropped: [], + baseAmbiguities: [], + provenanceRecords: [], + warnings: [], + identityAssertions: [], + identityRetractions: [], + identityConflicts: [], + canonicalOf: new Map(), + }; +} + +describe.each(backendMatrix())( + "merge apply's deleteNodeWithPolicy wiring [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + afterEach(async () => { + for (const cleanup of cleanups ?? []) { + await cleanup(); + } + cleanups = []; + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + it("a target-only restrict edge still aborts the merge, and nothing changes", async () => { + cleanups = []; + const [target] = await createStoreWithSchema(graph, await makeBackend()); + + const whole = requireDefined( + (await target.nodes.Whole.bulkCreate([{ id: "w1", props: {} }]))[0], + ); + const part = requireDefined( + (await target.nodes.Part.bulkCreate([{ id: "p1", props: {} }]))[0], + ); + await target.edges.holds.create(whole, part, {}); + + // The plan deletes the Whole but knows nothing of its `holds` edge to + // the Part — exactly the shape a plan built before that edge existed + // would have. Deleting through the old public `.delete(id)` facade + // would already refuse this; the assertion is that the new port + // preserves that refusal instead of silently dropping enforcement. + const plan: MergePlan = { + ...emptyPlan(), + nodeDeletions: new Map([[mergeKey("Whole", "w1"), "Whole"]]), + }; + + await expect(commitPlan(target, plan)).rejects.toThrow( + /RESTRICTED_DELETE|connected edge/i, + ); + + // Zero rows changed: the Whole is still live, and its edge survives. + await expect(target.nodes.Whole.getById(whole.id)).resolves.toBeDefined(); + await expect(target.nodes.Part.getById(part.id)).resolves.toBeDefined(); + const survivingEdges = await target.edges.holds.findFrom(whole); + expect(survivingEdges).toHaveLength(1); + }); + + async function storeWithRestrictedEdge() { + const [target] = await createStoreWithSchema(graph, await makeBackend(), { + revisionTracking: true, + }); + const whole = await target.nodes.Whole.create({}, { id: "w1" }); + const part = await target.nodes.Part.create({}, { id: "p1" }); + const edge = await target.edges.holds.create(whole, part, {}); + const source = unwrap(await branch(target, () => makeBackend())); + await source.store.edges.holds.delete(edge.id); + await source.store.nodes.Whole.delete(whole.id); + return { target, source, whole, part, edge }; + } + + it("merges a branch that deletes an edge and then its restrict endpoint", async () => { + cleanups = []; + const { target, source, whole, part } = await storeWithRestrictedEdge(); + + const result = await merge(target, [source]); + if (isErr(result)) { + console.info("merge refused", result.error.message, result.error.cause); + throw result.error; + } + console.info("merged", result.data.merged); + await expect(target.nodes.Whole.getById(whole.id)).resolves.toBe( + undefined, + ); + await expect(target.nodes.Part.getById(part.id)).resolves.toBeDefined(); + expect(await target.edges.holds.find()).toEqual([]); + }); + + it("applies a plan artifact that deletes an edge and then its restrict endpoint", async () => { + cleanups = []; + const { target, source, whole } = await storeWithRestrictedEdge(); + + const plan = unwrap(await planMerge(target, [source])); + expect(plan.writes.edgeDeletes).toHaveLength(1); + expect(plan.writes.nodeDeletes).toEqual([{ kind: "Whole", id: "w1" }]); + const applied = await applyMergePlan(target, plan); + if (isErr(applied)) { + console.info("apply refused", applied.error.message); + throw applied.error; + } + await expect(target.nodes.Whole.getById(whole.id)).resolves.toBe( + undefined, + ); + expect(await target.edges.holds.find()).toEqual([]); + }); + + it("does not report a hook success for a delete the rolled-back merge never committed", async () => { + cleanups = []; + // `onError`'s context is the generic `HookContext` (it also fires for + // query and bulk-operation failures, neither of which has a single + // node kind/id) so it carries no `kind`/`id` at the type level; capture + // the label from the fully-typed `onOperationStart` context and + // correlate by `operationId`, the field both contexts DO share. + const operationLabels = new Map(); + const endedIds: string[] = []; + const erroredIds: string[] = []; + const [target] = await createStoreWithSchema(graph, await makeBackend(), { + hooks: { + onOperationStart: (ctx) => + operationLabels.set(ctx.operationId, `${ctx.kind}:${ctx.id}`), + onOperationEnd: (ctx) => endedIds.push(`${ctx.kind}:${ctx.id}`), + onError: (ctx) => { + const label = operationLabels.get(ctx.operationId); + if (label !== undefined) erroredIds.push(label); + }, + }, + }); + + const whole = requireDefined( + (await target.nodes.Whole.bulkCreate([{ id: "w1", props: {} }]))[0], + ); + const part = requireDefined( + (await target.nodes.Part.bulkCreate([{ id: "p1", props: {} }]))[0], + ); + await target.edges.holds.create(whole, part, {}); + const other = requireDefined( + (await target.nodes.Other.bulkCreate([{ id: "o1", props: {} }]))[0], + ); + + operationLabels.clear(); + endedIds.length = 0; + erroredIds.length = 0; + + // The plan deletes Other FIRST (unrestricted — it would succeed on its + // own) and Whole SECOND (blocked by its live `holds` edge to Part). Map + // iteration order is insertion order, so applyNodeRows runs Other's + // delete before Whole's, inside the same merge transaction; Whole's + // RestrictedDeleteError then rolls the whole transaction back. + const plan: MergePlan = { + ...emptyPlan(), + nodeDeletions: new Map([ + [mergeKey("Other", "o1"), "Other"], + [mergeKey("Whole", "w1"), "Whole"], + ]), + }; + + await expect(commitPlan(target, plan)).rejects.toThrow( + /RESTRICTED_DELETE|connected edge/i, + ); + + // Other's delete ran and would have committed had the transaction + // succeeded — routed through the Store's OWN immediate-hook context + // (the pre-fix bug) it reports `onOperationEnd` right there, before the + // rollback. Routed through this transaction's own buffered hook runner + // (the fix), a rolled-back attempt's outcomes are discarded and + // reported as `onError` instead, once the failure is final. + expect(endedIds).not.toContain("Other:o1"); + expect(erroredIds).toContain("Other:o1"); + await expect(target.nodes.Other.getById(other.id)).resolves.toBeDefined(); + }); + }, +); diff --git a/packages/typegraph/tests/graph-merge/plan-retype-resolution.test.ts b/packages/typegraph/tests/graph-merge/plan-retype-resolution.test.ts new file mode 100644 index 000000000..1cda6c416 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/plan-retype-resolution.test.ts @@ -0,0 +1,94 @@ +/** + * A durable plan under `reconcileTypes: "ontology"` with a CROSS-ID retype + * cluster: `p` staged as both `P` and its subclass `E`, similarity-fused with + * `c`. The cluster is written under the reconciled kind `E`, and the plan's + * resolution names that same kind — the artifact's own resolution-evidence + * check compares the two, so a resolution that still named the staged + * survivor kind failed every such plan's self-validation. + */ +import { + createStoreWithSchema, + defineGraph, + defineNode, + subClassOf, +} from "@nicia-ai/typegraph"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import { branch } from "../../src/graph-merge/branch"; +import { applyMergePlan, planMerge } from "../../src/graph-merge/merge"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; +import { requireDefined } from "../../src/utils/presence"; + +const P = defineNode("P", { schema: z.object({ name: z.string() }) }); +const E = defineNode("E", { schema: z.object({ name: z.string() }) }); +const graph = defineGraph({ + id: "plan_retype_resolution", + nodes: { P: { type: P }, E: { type: E } }, + edges: {}, + ontology: [subClassOf(E, P)], +}); + +describe("plan artifact — a cross-id retype cluster's resolution", () => { + const disposers: (() => Promise)[] = []; + afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose(); + }); + + function makeBackend() { + const result = createLocalSqliteBackend(); + disposers.push(() => result.backend.close()); + return result.backend; + } + + it("names the reconciled kind, so the plan validates and applies", async () => { + const [base] = await createStoreWithSchema(graph, makeBackend(), { + history: true, + }); + const source = unwrap( + await branch(base, () => Promise.resolve(makeBackend()), { + id: asBranchId("a"), + }), + ); + await source.store.nodes.P.create({ name: "C" }, { id: "c" }); + await source.store.nodes.P.create({ name: "P" }, { id: "p" }); + await source.store.nodes.E.create({ name: "P" }, { id: "p" }); + + const planned = await planMerge(base, [source], { + reconcileTypes: "ontology", + resolve: { + P: { + block: () => "all", + threshold: 0.5, + similarity: { kind: "custom", score: () => 1 }, + }, + }, + }); + if (isErr(planned)) throw planned.error; + const artifact = planned.data; + const resolution = requireDefined(artifact.review.resolutions[0]); + console.info( + "resolution:", + resolution, + artifact.review.typeReconciliations, + ); + expect(resolution.canonicalId).toBe("c"); + expect(resolution.kind).toBe("E"); + expect(artifact.review.typeReconciliations).toEqual([ + expect.objectContaining({ entityId: "c", toType: "E" }), + ]); + + const applied = unwrap(await applyMergePlan(base, artifact)); + expect(requireDefined(applied.resolutions[0]).kind).toBe("E"); + expect((await base.nodes.E.find()).map((row) => row.id)).toEqual(["c"]); + expect(await base.nodes.P.find()).toEqual([]); + }); + // MUTATION CHECK: in `buildInternalMergePlan` (src/graph-merge/merge.ts) + // drop the retype map lookup from the returned `resolutions` (name the + // staged survivor kind `P` again) — `planMerge` then fails its own artifact + // validation with "A resolution must name its complete guarded cluster and + // carry exactly N-1 decisive edges." and `throw planned.error` fails this + // test. +}); diff --git a/packages/typegraph/tests/graph-merge/plan-wire.test.ts b/packages/typegraph/tests/graph-merge/plan-wire.test.ts index 1df54c634..bf770ebc6 100644 --- a/packages/typegraph/tests/graph-merge/plan-wire.test.ts +++ b/packages/typegraph/tests/graph-merge/plan-wire.test.ts @@ -5,7 +5,8 @@ import { computeMergePlanDigest, finalizeMergePlanArtifact, } from "../../src/graph-merge/plan-canonical"; -import type { MergePlanArtifactV1Input } from "../../src/graph-merge/plan-schema"; +import type { MergePlanArtifactV2Input } from "../../src/graph-merge/plan-schema"; +import { MERGE_PLAN_FORMAT_VERSION } from "../../src/graph-merge/plan-schema"; import { constructMergePlanArtifact, parseMergePlanArtifact, @@ -14,9 +15,9 @@ import { } from "../../src/graph-merge/plan-wire"; import { requireDefined } from "../../src/utils/presence"; -function planInput(): MergePlanArtifactV1Input { +function planInput(): MergePlanArtifactV2Input { return { - formatVersion: 1, + formatVersion: MERGE_PLAN_FORMAT_VERSION, mode: "snapshot", target: { graphId: "care", @@ -63,13 +64,14 @@ function planInput(): MergePlanArtifactV1Input { baseAmbiguities: [], provenanceRecords: [], warnings: [], + compositionOrphans: [], diagnostics: { entries: [], total: 0, limit: 10, truncated: false }, }, provenance: { includeInReport: true, persist: false }, }; } -function resolutionPlanInput(): MergePlanArtifactV1Input { +function resolutionPlanInput(): MergePlanArtifactV2Input { const input = planInput(); const evidence = { a: { kind: "Patient", id: "a" }, @@ -103,7 +105,7 @@ function resolutionPlanInput(): MergePlanArtifactV1Input { }; } -describe("merge plan V1 wire format", () => { +describe("merge plan wire format", () => { it("round-trips through JSON with explicit property removals intact", async () => { const artifact = await constructMergePlanArtifact(planInput()); const roundTripped: unknown = JSON.parse(JSON.stringify(artifact)); @@ -120,7 +122,7 @@ describe("merge plan V1 wire format", () => { it("canonicalizes nested object keys before hashing", async () => { const left = planInput(); - const right: MergePlanArtifactV1Input = { + const right: MergePlanArtifactV2Input = { ...left, writes: { ...left.writes, @@ -163,10 +165,27 @@ describe("merge plan V1 wire format", () => { }); }); - it("distinguishes unsupported versions from malformed V1 artifacts", async () => { - expect(parseMergePlanArtifact({ formatVersion: 2 })).toEqual({ + it("distinguishes unsupported versions from malformed artifacts", async () => { + // A stale but genuinely PRE-COMPOSITION artifact: `formatVersion: 1` + // (this library's PREVIOUS format, before `review.compositionOrphans` + // was added — see the version-bump comment on + // `MERGE_PLAN_FORMAT_VERSION`) must be reported as `unsupported-version`, + // never as `malformed` — a caller that stored a v1 plan for later + // application is told the format moved, not that its payload is corrupt. + expect(parseMergePlanArtifact({ formatVersion: 1 })).toEqual({ success: false, - error: { kind: "unsupported-version", received: 2 }, + error: { kind: "unsupported-version", received: 1 }, + }); + expect( + parseMergePlanArtifact({ + formatVersion: MERGE_PLAN_FORMAT_VERSION + 1, + }), + ).toEqual({ + success: false, + error: { + kind: "unsupported-version", + received: MERGE_PLAN_FORMAT_VERSION + 1, + }, }); const artifact = await constructMergePlanArtifact(planInput()); @@ -176,6 +195,73 @@ describe("merge plan V1 wire format", () => { if (!parsed.success) expect(parsed.error.kind).toBe("malformed"); }); + describe("refuses identity arbitration shapes the merge no longer produces", () => { + const SEPARATION_CONFLICT = { + kind: "separation", + a: { kind: "Patient", id: "a" }, + b: { kind: "Patient", id: "b" }, + assertionIds: ["assertion-1"], + }; + + async function artifactWithReview( + extraReview: Readonly>, + ): Promise { + const artifact = await constructMergePlanArtifact(planInput()); + return { ...artifact, review: { ...artifact.review, ...extraReview } }; + } + + function expectMalformed(artifact: unknown): void { + const parsed = parseMergePlanArtifact(artifact); + expect(parsed.success).toBe(false); + if (!parsed.success) expect(parsed.error.kind).toBe("malformed"); + } + + it("accepts a separation conflict, the one identity conflict arm a merge still writes", async () => { + const parsed = parseMergePlanArtifact( + await artifactWithReview({ identityConflicts: [SEPARATION_CONFLICT] }), + ); + expect(parsed.success).toBe(true); + }); + + it.each(["assertion", "edge", "uniqueness"])( + "rejects an identity conflict of removed kind %s", + async (kind) => { + expectMalformed( + await artifactWithReview({ + identityConflicts: [{ ...SEPARATION_CONFLICT, kind }], + }), + ); + }, + ); + + it("rejects review.identityReconciliations", async () => { + expectMalformed( + await artifactWithReview({ identityReconciliations: [] }), + ); + }); + + it("rejects an identity match source on a resolution", async () => { + const input = resolutionPlanInput(); + const artifact = await constructMergePlanArtifact(input); + const [resolution] = artifact.review.resolutions; + const [edge] = requireDefined(resolution).decisiveEdges; + expectMalformed({ + ...artifact, + review: { + ...artifact.review, + resolutions: [ + { + ...requireDefined(resolution), + decisiveEdges: [ + { ...requireDefined(edge), sources: [{ kind: "identity" }] }, + ], + }, + ], + }, + }); + }); + }); + it("rejects non-finite evidence numbers", async () => { const artifact = await constructMergePlanArtifact(planInput()); const malformed = { diff --git a/packages/typegraph/tests/graph-merge/test-utils.test.ts b/packages/typegraph/tests/graph-merge/test-utils.test.ts index fc09cd24a..a2b2f089f 100644 --- a/packages/typegraph/tests/graph-merge/test-utils.test.ts +++ b/packages/typegraph/tests/graph-merge/test-utils.test.ts @@ -197,6 +197,9 @@ describe("MergeError hierarchy", () => { edgeKind: "primaryEncounter", fromKind: "Patient", fromId: "pat-1", + direction: "source", + toKind: "primaryEncounterTarget", + toId: "target-1", cardinality: "oneActive", existingCount: 1, }); @@ -279,6 +282,9 @@ describe("MergeError hierarchy", () => { edgeKind: "e", fromKind: "N", fromId: "n", + direction: "source", + toKind: "eTarget", + toId: "target-1", cardinality: "one", existingCount: 1, }), diff --git a/packages/typegraph/tests/graph-merge/test-utils.ts b/packages/typegraph/tests/graph-merge/test-utils.ts index 119f00fd9..c609f7a1b 100644 --- a/packages/typegraph/tests/graph-merge/test-utils.ts +++ b/packages/typegraph/tests/graph-merge/test-utils.ts @@ -165,6 +165,8 @@ function sharedPgliteTableNames(fixtureSequence: number): PostgresTableNames { recordedIdentityAssertions: `${prefix}_recorded_identity_assertions`, identityClosure: `${prefix}_identity_closure`, identitySeparation: `${prefix}_identity_separation`, + identityTransitions: `${prefix}_identity_transitions`, + identityTransitionRetention: `${prefix}_identity_transition_retention`, uniques: `${prefix}_uniques`, edgeClaims: `${prefix}_edge_claims`, baseSchemaVersions: `${prefix}_base_schema_versions`, diff --git a/packages/typegraph/tests/graph-merge/type-reconcile.test.ts b/packages/typegraph/tests/graph-merge/type-reconcile.test.ts index 7638d8893..0118a16d9 100644 --- a/packages/typegraph/tests/graph-merge/type-reconcile.test.ts +++ b/packages/typegraph/tests/graph-merge/type-reconcile.test.ts @@ -4,22 +4,44 @@ import { defineNode, equivalentTo, generateId, + type NodeType, + type OntologyRelation, subClassOf, } from "@nicia-ai/typegraph"; import { beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; -import type { SubClassClosure } from "../../src/graph-merge/closures"; -import { buildSubClassClosure } from "../../src/graph-merge/closures"; import type { MergeKey } from "../../src/graph-merge/node-key"; import type { ReconcileClusterInput } from "../../src/graph-merge/type-reconcile"; import { INCOMPATIBLE_TYPES_FLAG_REASON, + mostSpecificCommonKind, reconcileTypes, } from "../../src/graph-merge/type-reconcile"; +import { metaEdgesByName } from "../../src/ontology/core-meta-edges"; +import type { KindRegistry } from "../../src/registry/kind-registry"; import { requireDefined } from "../../src/utils/presence"; import { createSqliteMergeBackend } from "./test-utils"; +/** + * `sameAs` has no public factory any more (it was removed, alongside + * `MetaEdgeOptions`/`InferenceType`), and its meta-edge object is absent + * from the public `core` export too. A document persisted before the + * removal that still names a `sameAs` relation must keep loading and + * folding exactly like `equivalentTo` (`collectOntologyRelations`, + * `src/registry/kind-registry.ts`, still switches on the meta-edge NAME, a + * plain string in that path); building the relation directly from the + * internal `metaEdgesByName.sameAs` object — the same object + * `compileOntologyRelation` resolves that name to — exercises the identical + * registry fold a persisted `sameAs` document would produce. + */ +function sameAsRelation( + kindA: NodeType, + kindBOrIri: NodeType | string, +): OntologyRelation { + return { metaEdge: metaEdgesByName.sameAs, from: kindA, to: kindBOrIri }; +} + /** * Brands a plain string as a canonical identity key for the pure reconciliation * tests. The bare reports (`entityId`, dropped `id`) project this back via `idOf`, @@ -70,8 +92,25 @@ const taxonomyGraph = defineGraph({ ], }); -describe("reconcileTypes over the public-closure glue (T10)", () => { - let closure: SubClassClosure; +/** + * Builds a real store from `graph` and returns its validated `KindRegistry` — + * the SAME registry a query runs on, so a reconciliation test and a query test + * can never silently drift onto two different notions of "same class". + */ +async function registryFromGraph( + graph: Parameters[0], +): Promise { + const fixture = createSqliteMergeBackend(); + try { + const [store] = await createStoreWithSchema(graph, fixture.backend); + return store.registry; + } finally { + await fixture.cleanup(); + } +} + +describe("reconcileTypes over the store's KindRegistry (T10)", () => { + let registry: KindRegistry; beforeEach(async () => { const fixture = createSqliteMergeBackend(); @@ -80,7 +119,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { taxonomyGraph, fixture.backend, ); - closure = buildSubClassClosure(store.introspect().ontology); + registry = store.registry; } finally { await fixture.cleanup(); } @@ -94,7 +133,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { memberKinds: ["Doctor", "SpecialistDoctor"], }, ]; - const result = reconcileTypes(clusters, closure, "off"); + const result = reconcileTypes(clusters, registry, "off"); expect(result.reconciliations).toEqual([]); expect(result.dropped).toEqual([]); expect(result.retypeMap.size).toBe(0); @@ -107,7 +146,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { const clusters: readonly ReconcileClusterInput[] = [ { canonicalId, memberKinds: ["Doctor", "SpecialistDoctor"] }, ]; - const result = reconcileTypes(clusters, closure, "ontology"); + const result = reconcileTypes(clusters, registry, "ontology"); expect(result.reconciliations).toHaveLength(1); const reconciliation = requireDefined(result.reconciliations[0]); @@ -129,7 +168,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { memberKinds: ["Person", "Doctor", "SpecialistDoctor"], }, ]; - const result = reconcileTypes(clusters, closure, "ontology"); + const result = reconcileTypes(clusters, registry, "ontology"); expect(result.reconciliations).toHaveLength(1); expect(requireDefined(result.reconciliations[0]).toType).toBe( @@ -148,7 +187,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { memberKinds: ["Doctor", "SpecialistDoctor", "Person"], }, ], - closure, + registry, "ontology", ); const reversed = reconcileTypes( @@ -158,7 +197,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { memberKinds: ["Person", "SpecialistDoctor", "Doctor"], }, ], - closure, + registry, "ontology", ); expect(requireDefined(forward.reconciliations[0]).toType).toBe( @@ -175,14 +214,14 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { const canonicalId = nodeId("patient-4"); const result = reconcileTypes( [{ canonicalId, memberKinds: ["Physician", "Doctor"] }], - closure, + registry, "ontology", ); expect(result.dropped).toEqual([]); expect(result.reconciliations).toHaveLength(1); // Physician ≡ Doctor: either qualifies as the minimum, so the - // lexicographically-smallest representative ("Doctor") is chosen + // code-point-smallest representative ("Doctor") is chosen // deterministically — never flagged as incompatible. expect(requireDefined(result.reconciliations[0]).toType).toBe("Doctor"); expect(result.retypeMap.get(canonicalId)).toBe("Doctor"); @@ -193,7 +232,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { // SpecialistDoctor ⊑ Doctor ≡ Physician, so SpecialistDoctor is below both. const result = reconcileTypes( [{ canonicalId, memberKinds: ["SpecialistDoctor", "Physician"] }], - closure, + registry, "ontology", ); expect(result.dropped).toEqual([]); @@ -202,6 +241,35 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { ); expect(result.retypeMap.get(canonicalId)).toBe("SpecialistDoctor"); }); + + it("collapses a bare equivalence pair with no subClassOf at all", async () => { + // Regression for the pre-fold private closure, which could only express + // equivalence found through an INTROSPECTED subClassOf chain. The + // registry fold makes equivalentTo mutual subsumption directly, with no + // subClassOf relation anywhere in the graph. + const Client = defineNode("Client", { schema: z.object({}) }); + const Customer = defineNode("Customer", { schema: z.object({}) }); + const bareEquivalenceGraph = defineGraph({ + id: "type-reconcile-bare-equivalence", + nodes: { Client: { type: Client }, Customer: { type: Customer } }, + edges: {}, + ontology: [equivalentTo(Client, Customer)], + }); + const bareRegistry = await registryFromGraph(bareEquivalenceGraph); + + const canonicalId = nodeId("bare-1"); + const result = reconcileTypes( + [{ canonicalId, memberKinds: ["Customer", "Client"] }], + bareRegistry, + "ontology", + ); + + expect(result.dropped).toEqual([]); + expect(result.reconciliations).toHaveLength(1); + // Both qualify as the minimum; "Client" is the code-point-smallest. + expect(requireDefined(result.reconciliations[0]).toType).toBe("Client"); + expect(result.retypeMap.get(canonicalId)).toBe("Client"); + }); }); describe("incompatible kinds are flagged, not collapsed", () => { @@ -209,7 +277,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { const canonicalId = nodeId("mixed-1"); const result = reconcileTypes( [{ canonicalId, memberKinds: ["Animal", "Person"] }], - closure, + registry, "ontology", ); @@ -230,7 +298,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { const canonicalId = nodeId("mixed-2"); const result = reconcileTypes( [{ canonicalId, memberKinds: ["SpecialistDoctor", "Animal"] }], - closure, + registry, "ontology", ); expect(result.reconciliations).toEqual([]); @@ -247,7 +315,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { { canonicalId: nodeId("a"), memberKinds: ["Doctor"] }, { canonicalId: nodeId("b"), memberKinds: ["Doctor", "Doctor"] }, ], - closure, + registry, "ontology", ); expect(result.reconciliations).toEqual([]); @@ -266,7 +334,7 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { }, { canonicalId: incompatible, memberKinds: ["Animal", "Person"] }, ], - closure, + registry, "ontology", ); @@ -288,10 +356,231 @@ describe("reconcileTypes over the public-closure glue (T10)", () => { const canonicalId = generateId() as MergeKey; const result = reconcileTypes( [{ canonicalId, memberKinds: ["Person", "SpecialistDoctor"] }], - closure, + registry, "ontology", ); expect(result.retypeMap.get(canonicalId)).toBe("SpecialistDoctor"); }); }); }); + +describe("mostSpecificCommonKind and reconcileTypes port cases (formerly graph-merge/closures.test.ts)", () => { + const closuresTaxonomyGraph = defineGraph({ + id: "type-reconcile-closures-port-taxonomy", + nodes: { + Person: { type: Person }, + Doctor: { type: Doctor }, + SpecialistDoctor: { type: SpecialistDoctor }, + Animal: { type: Animal }, + }, + edges: {}, + ontology: [ + subClassOf(SpecialistDoctor, Doctor), + subClassOf(Doctor, Person), + ], + }); + + it("is reachable transitively (SpecialistDoctor -> Person)", async () => { + const registry = await registryFromGraph(closuresTaxonomyGraph); + expect(registry.isAssignableTo("SpecialistDoctor", "Doctor")).toBe(true); + expect(registry.isAssignableTo("Doctor", "Person")).toBe(true); + expect(registry.isAssignableTo("SpecialistDoctor", "Person")).toBe(true); + }); + + it("is not reachable in the reverse (superclass -> subclass) direction", async () => { + const registry = await registryFromGraph(closuresTaxonomyGraph); + expect(registry.isSubClassOf("Person", "SpecialistDoctor")).toBe(false); + expect(registry.isSubClassOf("Person", "Doctor")).toBe(false); + expect(registry.isSubClassOf("Doctor", "SpecialistDoctor")).toBe(false); + }); + + it("treats the subclass relation as strict (irreflexive)", async () => { + const registry = await registryFromGraph(closuresTaxonomyGraph); + expect(registry.isSubClassOf("Doctor", "Doctor")).toBe(false); + expect(registry.isSubClassOf("SpecialistDoctor", "SpecialistDoctor")).toBe( + false, + ); + }); + + it("reports unrelated types as mutually unassignable", async () => { + const graph = defineGraph({ + id: "type-reconcile-closures-port-disjoint", + nodes: { + Person: { type: Person }, + Doctor: { type: Doctor }, + Animal: { type: Animal }, + }, + edges: {}, + ontology: [subClassOf(Doctor, Person)], + }); + const registry = await registryFromGraph(graph); + expect(registry.isAssignableTo("Animal", "Person")).toBe(false); + expect(registry.isAssignableTo("Person", "Animal")).toBe(false); + expect(registry.isAssignableTo("Doctor", "Animal")).toBe(false); + expect(registry.isAssignableTo("Animal", "Doctor")).toBe(false); + }); + + it("returns false for types absent from the ontology, true for reflexive self-assignment", async () => { + const registry = await registryFromGraph(closuresTaxonomyGraph); + expect(registry.isAssignableTo("Nurse", "Person")).toBe(false); + expect(registry.isAssignableTo("Person", "Nurse")).toBe(false); + expect(registry.isAssignableTo("Nurse", "Nurse")).toBe(true); + }); + + describe("equivalentTo folding", () => { + const equivalenceGraph = defineGraph({ + id: "type-reconcile-closures-port-equivalence", + nodes: { + Person: { type: Person }, + Doctor: { type: Doctor }, + Physician: { type: Physician }, + SpecialistDoctor: { type: SpecialistDoctor }, + }, + edges: {}, + ontology: [ + subClassOf(SpecialistDoctor, Doctor), + subClassOf(Doctor, Person), + equivalentTo(Physician, Doctor), + ], + }); + + it("makes equivalent types mutually assignable", async () => { + const registry = await registryFromGraph(equivalenceGraph); + expect(registry.isAssignableTo("Physician", "Doctor")).toBe(true); + expect(registry.isAssignableTo("Doctor", "Physician")).toBe(true); + }); + + it("shares ancestors and descendants across an equivalence class", async () => { + const registry = await registryFromGraph(equivalenceGraph); + // Physician ≡ Doctor, and Doctor ⊑ Person, so Physician ⊑ Person too. + expect(registry.isAssignableTo("Physician", "Person")).toBe(true); + // SpecialistDoctor ⊑ Doctor ≡ Physician, so SpecialistDoctor ⊑ Physician. + expect(registry.isAssignableTo("SpecialistDoctor", "Physician")).toBe( + true, + ); + }); + }); + + describe("sameAs folding", () => { + /** + * `sameAs` was the deprecated alias of `equivalentTo`, removed as a + * public factory — only a persisted document can still name + * it, via {@link sameAsRelation}. The registry fold + * (`collectOntologyRelations`) folds both meta-edges into the same + * `equivalent` bucket, so a closure recognizing only `equivalentTo` would + * silently give these two graphs — identical up to that one meta-edge — + * different type-reconciliation outcomes. + */ + function equivalenceGraphUsing( + // The shared, NodeType-only shape both `equivalentTo` and + // `sameAsRelation` satisfy. + relation: ( + kindA: NodeType, + kindBOrIri: NodeType | string, + ) => OntologyRelation, + id: string, + ): ReturnType { + return defineGraph({ + id, + nodes: { + Person: { type: Person }, + Doctor: { type: Doctor }, + Physician: { type: Physician }, + SpecialistDoctor: { type: SpecialistDoctor }, + }, + edges: {}, + ontology: [ + subClassOf(SpecialistDoctor, Doctor), + subClassOf(Doctor, Person), + relation(Physician, Doctor), + ], + }); + } + + it("makes sameAs types mutually assignable", async () => { + const registry = await registryFromGraph( + equivalenceGraphUsing( + sameAsRelation, + "type-reconcile-closures-port-same-as", + ), + ); + expect(registry.isAssignableTo("Physician", "Doctor")).toBe(true); + expect(registry.isAssignableTo("Doctor", "Physician")).toBe(true); + expect(registry.isAssignableTo("Physician", "Person")).toBe(true); + expect(registry.isAssignableTo("SpecialistDoctor", "Physician")).toBe( + true, + ); + }); + + it("reconciles a sameAs ontology exactly like an equivalentTo one", async () => { + const normalize = ( + closureRegistry: KindRegistry, + ): Readonly<{ + subClassAncestors: Record; + subClassDescendants: Record; + equivalenceSets: Record; + }> => ({ + subClassAncestors: Object.fromEntries( + [...closureRegistry.subClassAncestors].map(([kind, ancestors]) => [ + kind, + [...ancestors].sort(), + ]), + ), + subClassDescendants: Object.fromEntries( + [...closureRegistry.subClassDescendants].map( + ([kind, descendants]) => [kind, [...descendants].sort()], + ), + ), + equivalenceSets: Object.fromEntries( + [...closureRegistry.equivalenceSets].map(([kind, members]) => [ + kind, + [...members].sort(), + ]), + ), + }); + + const viaSameAs = await registryFromGraph( + equivalenceGraphUsing( + sameAsRelation, + "type-reconcile-closures-port-same-as-parity", + ), + ); + const viaEquivalentTo = await registryFromGraph( + equivalenceGraphUsing( + equivalentTo, + "type-reconcile-closures-port-equivalent-to-parity", + ), + ); + expect(normalize(viaSameAs)).toEqual(normalize(viaEquivalentTo)); + }); + }); + + it("mostSpecificCommonKind: the deterministic tie-break is code-point order", async () => { + const registry = await registryFromGraph(closuresTaxonomyGraph); + expect( + mostSpecificCommonKind(registry, ["Doctor", "SpecialistDoctor"]), + ).toBe("SpecialistDoctor"); + expect(mostSpecificCommonKind(registry, ["Animal", "Person"])).toBe( + undefined, + ); + }); + + it("mostSpecificCommonKind: a genuine tie is broken by code-point order regardless of input order", async () => { + // `equivalentTo` makes both kinds mutually qualify as the minimum — a + // REAL tie, unlike the single-qualifier case above — so this is the case + // that actually exercises the tie-break sort rather than the filter. + const equivalenceGraph = defineGraph({ + id: "type-reconcile-tie-break-order-independence", + nodes: { Doctor: { type: Doctor }, Physician: { type: Physician } }, + edges: {}, + ontology: [equivalentTo(Physician, Doctor)], + }); + const registry = await registryFromGraph(equivalenceGraph); + expect(mostSpecificCommonKind(registry, ["Doctor", "Physician"])).toBe( + "Doctor", + ); + expect(mostSpecificCommonKind(registry, ["Physician", "Doctor"])).toBe( + "Doctor", + ); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/undeclared-options.test.ts b/packages/typegraph/tests/graph-merge/undeclared-options.test.ts new file mode 100644 index 000000000..9c537e275 --- /dev/null +++ b/packages/typegraph/tests/graph-merge/undeclared-options.test.ts @@ -0,0 +1,348 @@ +/** + * A merge option is applied or refused, never ignored. Normalization reads each + * declared option by name, so a key `MergeOptions` does not declare — a typo, + * or an option this release does not offer, such as an `identity` policy bag — + * would otherwise be dropped and the merge would run as though the caller had + * never stated it. Every entry point that accepts options refuses it instead, + * naming the key, before anything is planned or written. + * + * Also pins the option evidence a review artifact stores: the frozen literal + * below is what an options-free caller produced before identity-aware merges + * existed, and such an artifact must keep revalidating `compatible`. + */ +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStoreWithSchema, defineGraph, defineNode } from "../../src"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import { + branch, + captureCandidateWriteSetTarget, + InvalidMergeOptionsError, + isErr, + merge, + MERGE_PLAN_FORMAT_VERSION, + type MergeOptions, + planCandidateWriteSetReview, + planMerge, + revalidateCandidateWriteSetReview, + unwrap, +} from "../../src/graph-merge"; +import { normalizeMergeOptions } from "../../src/graph-merge/options"; +import { reviewOptionEvidence } from "../../src/graph-merge/review-evidence"; +import { asBranchId } from "../../src/graph-merge/types"; + +const OPTION_EVIDENCE_WITHOUT_OPTIONS = [ + "object", + [ + ["onBasePropertyConflict", ["literal", "flag"]], + ["onComparisonCeiling", ["literal", "error"]], + ["onDeleteModifyConflict", ["literal", "flag"]], + ["onPropertyConflict", ["literal", "flag"]], + ["persistProvenance", ["literal", false]], + ["provenance", ["literal", true]], + ["reconcileTypes", ["literal", "off"]], + ["resolve", ["object", []]], + ], +] as const; + +const Item = defineNode("Item", { schema: z.object({ name: z.string() }) }); +const graph = defineGraph({ + id: "undeclared_merge_options", + nodes: { Item: { type: Item } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); +const BRANCH = asBranchId("branch-a"); + +/** An options bag stating a key the type does not declare, as an untyped caller would. */ +function undeclared( + options: Readonly>, +): MergeOptions { + return options; +} + +const UNDECLARED_IDENTITY_OPTIONS = undeclared({ + identity: { onAssertionConflict: "assertWins" }, +}); +const REVIEW_POLICY = { id: "review-policy", context: {} } as const; + +const disposers: (() => Promise)[] = []; +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose(); +}); + +function openBackend() { + const { backend } = createLocalSqliteBackend(); + disposers.push(() => backend.close()); + return backend; +} + +async function targetWithBranch() { + const [target] = await createStoreWithSchema(graph, openBackend(), { + history: true, + }); + const source = unwrap( + await branch(target, () => Promise.resolve(openBackend()), { id: BRANCH }), + ); + await source.store.nodes.Item.create({ name: "Fresh" }, { id: "fresh" }); + await source.store.nodes.Item.create({ name: "Other" }, { id: "other" }); + await source.store.identity.assertSame( + { kind: "Item", id: "fresh" }, + { kind: "Item", id: "other" }, + ); + return { target, source }; +} + +async function reviewArgs() { + const [target] = await createStoreWithSchema(graph, openBackend(), { + history: true, + }); + return { + target, + makeBackend: () => Promise.resolve(createLocalSqliteBackend().backend), + writeSet: { + formatVersion: 1 as const, + sourceId: "source", + target: await captureCandidateWriteSetTarget(target), + nodes: [ + { + kind: "Item", + id: "candidate", + properties: { name: "New" }, + validFrom: "2026-01-01T00:00:00.000Z", + }, + ], + edges: [], + }, + policy: REVIEW_POLICY, + }; +} + +function expectUndeclaredOptionRefusal(error: unknown, option: string): void { + console.info("refusal", error); + expect(error).toBeInstanceOf(InvalidMergeOptionsError); + expect((error as InvalidMergeOptionsError).details["option"]).toBe(option); +} + +describe("an undeclared merge option is refused, never ignored", () => { + it("normalization refuses an `identity` bag, naming the key", () => { + let thrown: unknown; + try { + normalizeMergeOptions(UNDECLARED_IDENTITY_OPTIONS); + } catch (error) { + thrown = error; + } + expectUndeclaredOptionRefusal(thrown, "identity"); + }); + + it("the options type refuses it at compile time too", () => { + const typed: MergeOptions = { + // @ts-expect-error -- `identity` is not a declared merge option. + identity: { onAssertionConflict: "assertWins" }, + }; + expect(() => normalizeMergeOptions(typed)).toThrow( + InvalidMergeOptionsError, + ); + }); + + it("normalization refuses a mistyped option rather than running without it", () => { + let thrown: unknown; + try { + normalizeMergeOptions(undeclared({ reconcileType: "ontology" })); + } catch (error) { + thrown = error; + } + expectUndeclaredOptionRefusal(thrown, "reconcileType"); + }); + + it("normalization still accepts every declared option", () => { + expect( + normalizeMergeOptions({ + reconcileTypes: "ontology", + onDeleteModifyConflict: "deleteWins", + branchOrder: [BRANCH], + candidateDiagnostics: { limit: 1 }, + }), + ).toMatchObject({ + reconcileTypes: "ontology", + onDeleteModifyConflict: "deleteWins", + }); + }); + + it("merge() refuses it and writes nothing", async () => { + const { target, source } = await targetWithBranch(); + const result = await merge(target, [source], UNDECLARED_IDENTITY_OPTIONS); + expect(isErr(result)).toBe(true); + if (!isErr(result)) return; + expectUndeclaredOptionRefusal(result.error, "identity"); + expect(await target.nodes.Item.find()).toEqual([]); + }); + + it("planMerge() refuses it", async () => { + const { target, source } = await targetWithBranch(); + const result = await planMerge( + target, + [source], + UNDECLARED_IDENTITY_OPTIONS, + ); + expect(isErr(result)).toBe(true); + if (!isErr(result)) return; + expectUndeclaredOptionRefusal(result.error, "identity"); + }); + + it("review evidence refuses it rather than digesting an option the merge would drop", () => { + let thrown: unknown; + try { + reviewOptionEvidence(UNDECLARED_IDENTITY_OPTIONS); + } catch (error) { + thrown = error; + } + expectUndeclaredOptionRefusal(thrown, "identity"); + }); + + it("a candidate write-set review refuses it", async () => { + const result = await planCandidateWriteSetReview({ + ...(await reviewArgs()), + options: UNDECLARED_IDENTITY_OPTIONS, + }); + expect(isErr(result)).toBe(true); + if (!isErr(result)) return; + expectUndeclaredOptionRefusal(result.error, "identity"); + }); +}); + +describe("an undeclared key inside a resolve config is refused, never ignored", () => { + const SCORE_ALL = { kind: "custom", score: () => 1 } as const; + + function refusalOf(options: MergeOptions): unknown { + try { + normalizeMergeOptions(options); + } catch (error) { + return error; + } + return undefined; + } + + it.each([ + { + option: "resolve.Item.blok", + resolve: { + Item: { threshold: 0.5, blok: () => "all", similarity: SCORE_ALL }, + }, + }, + { + option: "resolve.Item.keyless.windw", + resolve: { + Item: { threshold: 0.5, keyless: { windw: 2 }, similarity: SCORE_ALL }, + }, + }, + { + option: "resolve.Item.similarity.field", + resolve: { + Item: { + threshold: 0.5, + similarity: { kind: "fulltext", field: "name" }, + }, + }, + }, + { + option: "resolve.Item.similarity.weights.text", + resolve: { + Item: { + threshold: 0.5, + similarity: { + kind: "hybrid", + fields: ["name"], + weights: { text: 1 }, + }, + }, + }, + }, + { + option: "resolve.Item.similarity.kind", + resolve: { + Item: { + threshold: 0.5, + similarity: { kind: "fuzzy", fields: ["name"] }, + }, + }, + }, + ])("normalization refuses $option", ({ option, resolve }) => { + expectUndeclaredOptionRefusal(refusalOf(undeclared({ resolve })), option); + }); + + it("normalization accepts every declared resolve key", () => { + expect( + normalizeMergeOptions({ + resolve: { + Item: { + block: () => "all", + blockIndex: "item_name", + keyless: { window: 2 }, + threshold: 0.5, + similarity: { + kind: "hybrid", + fields: ["name"], + weights: { vector: 1, fulltext: 1 }, + }, + }, + }, + }).resolve, + ).toHaveProperty("Item"); + }); + + it("merge() and planMerge() refuse a resolve config for a kind the graph does not declare, writing nothing", async () => { + const { target, source } = await targetWithBranch(); + const typoKind = undeclared({ + resolve: { Itme: { threshold: 0.5, similarity: SCORE_ALL } }, + }); + const merged = await merge(target, [source], typoKind); + expect(isErr(merged)).toBe(true); + if (!isErr(merged)) return; + expectUndeclaredOptionRefusal(merged.error, "resolve.Itme"); + expect(await target.nodes.Item.find()).toEqual([]); + + const planned = await planMerge(target, [source], typoKind); + expect(isErr(planned)).toBe(true); + if (!isErr(planned)) return; + expectUndeclaredOptionRefusal(planned.error, "resolve.Itme"); + }); +}); + +describe("stored review and plan artifacts stay compatible", () => { + it("an options-free bag encodes to the frozen option evidence, field for field", () => { + expect(reviewOptionEvidence({})).toEqual(OPTION_EVIDENCE_WITHOUT_OPTIONS); + }); + + it("a review stored with the frozen option evidence still revalidates compatible", async () => { + const planned = await reviewArgs(); + const review = unwrap(await planCandidateWriteSetReview(planned)); + // Both sides computed by the current code could not fail; the stored side + // is the frozen literal. + const stored = { + ...(JSON.parse(JSON.stringify(review)) as typeof review), + options: OPTION_EVIDENCE_WITHOUT_OPTIONS, + }; + const revalidated = unwrap( + await revalidateCandidateWriteSetReview({ + ...(await reviewArgs()), + target: planned.target, + review: stored, + }), + ); + expect(revalidated.status).toBe("compatible"); + }); + + it("a merge that writes identity assertions plans a format-version-2 artifact with no identity review entries", async () => { + const { target, source } = await targetWithBranch(); + const artifact = unwrap(await planMerge(target, [source])); + expect(MERGE_PLAN_FORMAT_VERSION).toBe(2); + expect(artifact.formatVersion).toBe(MERGE_PLAN_FORMAT_VERSION); + expect(artifact.writes.identityAssertions).toHaveLength(1); + expect(Object.keys(artifact.review)).not.toContain("identityConflicts"); + expect(Object.keys(artifact.review)).not.toContain( + "identityReconciliations", + ); + }); +}); diff --git a/packages/typegraph/tests/graph-merge/uniqueness-handoff.test.ts b/packages/typegraph/tests/graph-merge/uniqueness-handoff.test.ts new file mode 100644 index 000000000..74ada184a --- /dev/null +++ b/packages/typegraph/tests/graph-merge/uniqueness-handoff.test.ts @@ -0,0 +1,100 @@ +/** + * The RELEASE half of the resolved-write-set uniqueness decision + * (`validateResolvedNodeClaims`, src/store/claims/resolved-node-claims.ts): + * a claim the set itself gives back is available to the set. A merge that + * deletes the row holding a unique key and creates a new row under that key + * must commit, leaving only the new row — the persisted holder is among the + * set's releases, so its claim is not a foreign one. + * + * Runs on every backend in the merge matrix: the probe is the store's own + * batched claim read, which is backend-specific code. + */ +import type { GraphBackend } from "@nicia-ai/typegraph"; +import { + createStoreWithSchema, + defineGraph, + defineNode, +} from "@nicia-ai/typegraph"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { branch } from "../../src/graph-merge/branch"; +import { merge } from "../../src/graph-merge/merge"; +import { isErr, unwrap } from "../../src/graph-merge/result"; +import { asBranchId } from "../../src/graph-merge/types"; +import { backendMatrix } from "./test-utils"; + +const Account = defineNode("Account", { + schema: z.object({ email: z.string() }), +}); +const graph = defineGraph({ + id: "uniqueness_handoff", + nodes: { + Account: { + type: Account, + unique: [ + { + name: "account_email", + fields: ["email"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: {}, +}); + +const BRANCH_A = asBranchId("branch-a"); +const EMAIL = "ada@example.test"; + +describe.each(backendMatrix())( + "resolved write set — a released unique key hands off to a new row [$name]", + (entry) => { + let cleanups: (() => Promise)[]; + + beforeEach(() => { + cleanups = []; + }); + + afterEach(async () => { + for (const cleanup of cleanups.toReversed()) { + await cleanup(); + } + }); + + async function makeBackend(): Promise { + const fixture = await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + + it("commits a merge that deletes the key's holder and creates a new row under the same key", async () => { + const [base] = await createStoreWithSchema(graph, await makeBackend(), { + history: true, + }); + await base.nodes.Account.create({ email: EMAIL }, { id: "old" }); + + const source = unwrap( + await branch(base, () => makeBackend(), { id: BRANCH_A }), + ); + await source.store.nodes.Account.delete("old" as never); + await source.store.nodes.Account.create({ email: EMAIL }, { id: "new" }); + + const result = await merge(base, [source], { branchOrder: [BRANCH_A] }); + if (isErr(result)) throw result.error; + console.info(`[${entry.name}] handoff merged:`, result.data.merged); + + const remaining = await base.nodes.Account.find(); + expect(remaining.map((row) => `${row.id}:${row.email}`)).toEqual([ + `new:${EMAIL}`, + ]); + expect(await base.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: drop `...releases` from `affectedOwnerKeys` + // (src/store/claims/resolved-node-claims.ts) — the persisted `old` row's + // claim then reads as a foreign holder of the key `new` claims, the + // resolved-set validation refuses the merge with a uniqueness violation, + // and `throw result.error` fails this test. + }, +); diff --git a/packages/typegraph/tests/guarded-edge-cardinality-claim.test.ts b/packages/typegraph/tests/guarded-edge-cardinality-claim.test.ts index a5e914cce..6aef13a2c 100644 --- a/packages/typegraph/tests/guarded-edge-cardinality-claim.test.ts +++ b/packages/typegraph/tests/guarded-edge-cardinality-claim.test.ts @@ -2,11 +2,14 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; import { + CardinalityError, CompilerInvariantError, + ConfigurationError, defineEdge, defineGraph, defineNode, } from "../src"; +import { claimsVerdict } from "../src/backend/capabilities/resolve"; import { graphCommandExecutionContext } from "../src/backend/command-contract"; import { deriveBackend } from "../src/backend/derive-backend"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; @@ -20,26 +23,39 @@ import { createSqlSchema } from "../src/query/compiler/schema"; import { sql } from "../src/query/sql-fragment"; import { asCompiledRowsSql } from "../src/query/sql-intent"; import { createStore, createStoreWithSchema } from "../src/store"; +import { + claimEdgeCardinalities, + edgeCardinalityAxisReferences, + edgeCardinalityClaims, +} from "../src/store/claims/edge-claims"; import { requireDefined } from "../src/utils/presence"; +import { + type EdgeClaimRow, + expectOnlyClaimsOf, + readEdgeClaimRows, +} from "./edge-claim-rows"; import { createRecordedPostgresStore } from "./statement-recorder"; import { createInitializedStore } from "./test-utils"; const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); -const relation = defineEdge("relation", { schema: z.object({}) }); +const one = defineEdge("one", { schema: z.object({}) }); +const unique = defineEdge("unique", { schema: z.object({}) }); +const oneActive = defineEdge("oneActive", { schema: z.object({}) }); +const both = defineEdge("both", { schema: z.object({}) }); const graph = defineGraph({ id: "guarded_edge_cardinality_claim", nodes: { Person: { type: Person } }, edges: { - one: { type: relation, from: [Person], to: [Person], cardinality: "one" }, + one: { type: one, from: [Person], to: [Person], cardinality: "one" }, unique: { - type: relation, + type: unique, from: [Person], to: [Person], cardinality: "unique", }, oneActive: { - type: relation, + type: oneActive, from: [Person], to: [Person], cardinality: "oneActive", @@ -66,23 +82,35 @@ function ordinaryEdgeCreatePlan( plan: { entity: "edge", params, - cardinalityClaim: claim, + cardinalityClaims: [claim], }, }; } -async function readClaimRows( +function readClaimRows( backend: GraphBackend, -): Promise { - const schema = createSqlSchema(backend.tableNames); - return backend.execute<{ axis: string; key: string; edge_id: string }>( - asCompiledRowsSql(sql` - SELECT axis, key, edge_id - FROM ${sql.identifier(schema.tables.edgeClaims)} - WHERE graph_id = ${graph.id} - ORDER BY axis, key - `), - ); + graphId: string = graph.id, +): Promise { + return readEdgeClaimRows(backend, graphId); +} + +const bothAxesGraph = defineGraph({ + id: "guarded_edge_cardinality_claim_both_axes", + nodes: { Person: { type: Person } }, + edges: { + both: { + type: both, + from: [Person], + to: [Person], + cardinality: "one", + targetCardinality: "one", + }, + }, +}); + +/** Simulates a database bootstrapped before `typegraph_edge_claims` existed. */ +function claimRelationMissing(): never { + throw new Error("no such table: typegraph_edge_claims"); } describe("guarded edge cardinality claim", () => { @@ -152,6 +180,7 @@ describe("guarded edge cardinality claim", () => { } as const; const claim = { graphId: graph.id, + direction: "source" as const, cardinality: "one" as const, edgeKind: "one", edgeId: params.id, @@ -169,7 +198,7 @@ describe("guarded edge cardinality claim", () => { entity: "edge", params, schemaFence: { graphId: graph.id, expectedVersion: 1 }, - cardinalityClaim: claim, + cardinalityClaims: [claim], }, }, graphCommandExecutionContext("transaction"), @@ -232,7 +261,7 @@ describe("guarded edge cardinality claim", () => { execute(plan) { if ( plan.kind === "edge.create" && - plan.plan.cardinalityClaim !== undefined + (plan.plan.cardinalityClaims ?? []).length > 0 ) { return Promise.resolve({ outcome: "unsupported" as const, @@ -296,7 +325,7 @@ describe("guarded edge cardinality claim", () => { execute(command, context) { if ( command.kind === "edge.create" && - command.plan.cardinalityClaim !== undefined + (command.plan.cardinalityClaims ?? []).length > 0 ) { return Promise.resolve({ outcome: "unsupported" as const, @@ -332,6 +361,7 @@ describe("guarded edge cardinality claim", () => { const to = await fixture.store.nodes.Person.create({ name: "to" }); const claim = { graphId: graph.id, + direction: "source" as const, cardinality: "one" as const, edgeKind: "one", edgeId: "planned-outcome", @@ -472,6 +502,7 @@ describe("guarded edge cardinality claim", () => { await claim({ graphId: graph.id, + direction: "source", cardinality: "one", edgeKind: "one", edgeId: "stale-holder", @@ -529,6 +560,7 @@ describe("guarded edge cardinality claim", () => { }, { graphId: graph.id, + direction: "source", cardinality: "one", edgeKind: "one", edgeId: "duplicate-edge-id", @@ -573,6 +605,7 @@ describe("guarded edge cardinality claim", () => { }, { graphId: graph.id, + direction: "source", cardinality: "one", edgeKind: "one", edgeId: "different-edge-id", @@ -728,6 +761,7 @@ describe("guarded edge cardinality claim", () => { await backend.claimEdgeCardinality?.({ graphId: graph.id, + direction: "source", cardinality: "one", edgeKind: "one", edgeId: "missing-stale-holder", @@ -754,4 +788,180 @@ describe("guarded edge cardinality claim", () => { await backend.close(); } }); + + describe("a two-axis declaration (source AND target both constrained)", () => { + it("takes the portable path for a two-axis plan, issuing both claims", async () => { + const fixture = await createRecordedPostgresStore(bothAxesGraph); + const from = await fixture.store.nodes.Person.create({ name: "from" }); + const to = await fixture.store.nodes.Person.create({ name: "to" }); + + fixture.reset(); + const created = await fixture.store.edges.both.create(from, to, {}); + + const statements = fixture.statements.map((statement) => statement.query); + // Never fused: no single statement carries both a claim insert and an + // edge insert for this write. + expect( + statements.some( + (query) => + /insert into "typegraph_edge_claims"/iu.test(query) && + /insert into "typegraph_edges"/iu.test(query), + ), + ).toBe(false); + expect( + statements.some((query) => + /insert into "typegraph_edge_claims"/iu.test(query), + ), + ).toBe(true); + expect( + statements.some((query) => + /insert into "typegraph_edges"/iu.test(query), + ), + ).toBe(true); + + const rows = await readClaimRows(fixture.backend, bothAxesGraph.id); + expect(rows.filter((row) => row.edge_id === created.id)).toHaveLength(2); + }); + + // The store never hands the port a two-axis plan — `usesGuardedCardinalityClaim` + // requires exactly one constrained axis, so the test above always takes the + // portable path. The port's own `claims.length > 1` refusal + // (`operation-backend-core.ts`, before the fused CTE builder runs) is + // therefore reachable only by calling `transaction.commands.execute` + // directly, bypassing the store gate — which is what this test does. Per + // AGENTS.md ("Every new command dimension ships with a custom-port + // refusal test that reaches the fallback and proves no partial row or + // sidecar write occurred"), this pins the port's OWN refusal rather than + // relying on the store never asking for it. + it("refuses a hand-built two-axis plan at the port before any SQL runs", async () => { + const fixture = await createRecordedPostgresStore(bothAxesGraph); + const from = await fixture.store.nodes.Person.create({ + name: "port-from", + }); + const to = await fixture.store.nodes.Person.create({ name: "port-to" }); + + fixture.reset(); + const params = { + graphId: bothAxesGraph.id, + id: "two-axis-port-probe", + kind: "both", + fromKind: "Person", + fromId: from.id, + toKind: "Person", + toId: to.id, + props: {}, + } as const; + const claims = edgeCardinalityClaims( + edgeCardinalityAxisReferences({ + cardinality: "one", + targetCardinality: "one", + }), + { + graphId: params.graphId, + id: params.id, + kind: params.kind, + fromKind: params.fromKind, + fromId: params.fromId, + toKind: params.toKind, + toId: params.toId, + }, + ); + expect(claims).toHaveLength(2); + + const result = await fixture.backend.transaction(async (transaction) => + transaction.commands.execute( + { + kind: "edge.create", + plan: { entity: "edge", params, cardinalityClaims: claims }, + }, + graphCommandExecutionContext("transaction"), + ), + ); + expect(result).toEqual({ + outcome: "unsupported", + entity: "edge", + dimensions: ["cardinalityClaim"], + }); + expect(fixture.statements).toEqual([]); + expect(await fixture.store.edges.both.find()).toEqual([]); + expect(await readClaimRows(fixture.backend, bothAxesGraph.id)).toEqual( + [], + ); + }); + // MUTATION CHECK (verified): drop `targetCardinality` from `declarations` + // in `validateAndPrepareEdgeCreate` + // (`src/store/operations/edge-operations.ts`) — the same construction + // site `atomic-edge-target-cardinality.test.ts` mutates. `rows.filter(...) + // .toHaveLength(2)` drops to 1 (only the source claim lands) and this + // test fails. + + it("refuses on a contended target axis, leaving no edge row and no residue on the source axis", async () => { + const fixture = await createRecordedPostgresStore(bothAxesGraph); + const alice = await fixture.store.nodes.Person.create({ name: "alice" }); + const bob = await fixture.store.nodes.Person.create({ name: "bob" }); + const carol = await fixture.store.nodes.Person.create({ name: "carol" }); + const dana = await fixture.store.nodes.Person.create({ name: "dana" }); + + await fixture.store.edges.both.create(alice, bob, {}); + // carol's source axis (empty) claims fine; bob's target axis is + // already held by alice's edge, so the whole write refuses. + await expect( + fixture.store.edges.both.create(carol, bob, {}), + ).rejects.toBeInstanceOf(CardinalityError); + expect(await fixture.store.edges.both.findTo(bob)).toHaveLength(1); + + // The refused attempt left nothing behind: the claim relation holds + // exactly alice's edge's two axis rows, none for carol's source. + const [incumbent] = await fixture.store.edges.both.findTo(bob); + expectOnlyClaimsOf( + await readClaimRows(fixture.backend, bothAxesGraph.id), + bothAxesGraph.id, + { cardinality: "one", targetCardinality: "one" }, + [requireDefined(incumbent)], + ); + + // And a fresh, otherwise-valid write for carol still succeeds. + await expect( + fixture.store.edges.both.create(carol, dana, {}), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): planting a claim for a never-inserted edge + // on carol's source axis (via `backend.claimEdgeCardinality`) before the + // `expectOnlyClaimsOf` call fails it with a third claim row, while the + // later `create(carol, dana)` still passes because a stale claim is taken + // over by design. + + it("refuses before writing (typed ConfigurationError) when the claim relation is missing, for a two-axis kind", async () => { + const { backend } = createLocalSqliteBackend(); + const claims = edgeCardinalityClaims( + edgeCardinalityAxisReferences({ + cardinality: "one", + targetCardinality: "one", + }), + { + graphId: bothAxesGraph.id, + id: "e1", + kind: "both", + fromKind: "Person", + fromId: "alice", + toKind: "Person", + toId: "bob", + }, + ); + expect(claims).toHaveLength(2); + const failingBackend = deriveBackend(backend, { + claimEdgeCardinality: claimRelationMissing, + claimEdgeCardinalityGuarded: claimRelationMissing, + claimEdgeCardinalityBatch: claimRelationMissing, + }); + await expect( + claimEdgeCardinalities( + failingBackend, + claimsVerdict(failingBackend), + claims, + ), + ).rejects.toBeInstanceOf(ConfigurationError); + await backend.close(); + }); + }); }); diff --git a/packages/typegraph/tests/heterogeneous-node-upsert-batch-composition-pglite.test.ts b/packages/typegraph/tests/heterogeneous-node-upsert-batch-composition-pglite.test.ts new file mode 100644 index 000000000..5ed4a481b --- /dev/null +++ b/packages/typegraph/tests/heterogeneous-node-upsert-batch-composition-pglite.test.ts @@ -0,0 +1,147 @@ +/** + * `tx.writeNodeUpsertBatch` writes node rows only: it has no `partOf` input + * and no composition-edge shape. A required-existence part kind therefore + * must be refused before the upsert CTE runs — otherwise a fresh insert + * writes a live part with no whole, and the CTE's `ON CONFLICT … deleted_at = + * NULL` arm resurrects a part the composition cascade tombstoned together + * with its whole. + * + * PGlite is the in-process backend that implements `upsertHeterogeneousNodes` + * (SQLite has no such command), so this is where the refusal is reachable. + */ +import { sql } from "drizzle-orm"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + createAdapterStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { createPostgresBackend } from "../src/backend/drizzle/postgres"; +import { + createLocalPgliteBackend, + type LocalPgliteBackendResult, +} from "../src/backend/postgres/pglite"; +import { ConfigurationError } from "../src/errors"; + +const Album = defineNode("Album", { schema: z.object({}) }); +const Track = defineNode("Track", { schema: z.object({ title: z.string() }) }); +const trackOf = defineEdge("trackOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "heterogeneous-upsert-required-composition", + nodes: { + Album: { type: Album }, + Track: { type: Track }, + }, + edges: { + trackOf: { type: trackOf, from: [Track], to: [Album], cardinality: "one" }, + }, + ontology: [partOf(Track, Album, { via: trackOf, existence: "required" })], +}); + +const opened: LocalPgliteBackendResult[] = []; + +afterEach(async () => { + await Promise.all(opened.splice(0).map((local) => local.backend.close())); +}); + +async function openRecordedStore() { + const local = await createLocalPgliteBackend({ vector: false }); + opened.push(local); + const backend = createPostgresBackend(local.db, { vector: false }); + const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + return { db: local.db, backend, store }; +} + +type RecordedStore = Awaited>; + +async function recordedTrackRowCount( + recorded: RecordedStore, + id: string, +): Promise { + const result = await recorded.db.execute<{ count: number }>( + sql`SELECT count(*)::int AS count FROM typegraph_recorded_nodes WHERE graph_id = ${graph.id} AND kind = 'Track' AND id = ${id}`, + ); + return result.rows[0]?.count ?? 0; +} + +function upsertTrack(recorded: RecordedStore, id: string) { + return recorded.db.transaction(async (pgTx) => + recorded.store.withRecordedTransaction(pgTx, async (tx) => + tx.writeNodeUpsertBatch([ + { + kind: "Track", + id: asNodeId(id), + props: { title: "upserted" }, + }, + ] as const), + ), + ); +} + +function isRequiredCompositionRefusal(error: unknown): boolean { + return ( + error instanceof ConfigurationError && + error.details["code"] === "HETEROGENEOUS_NODE_BATCH_UNSUPPORTED_KIND" && + error.details["kind"] === "Track" + ); +} + +describe("tx.writeNodeUpsertBatch refuses required-existence part kinds", () => { + it("refuses a fresh insert of a required part, writing no row and no recorded row", async () => { + const recorded = await openRecordedStore(); + + await expect(upsertTrack(recorded, "fresh")).rejects.toSatisfy( + (error: unknown) => isRequiredCompositionRefusal(error), + ); + + await expect(recordedTrackRowCount(recorded, "fresh")).resolves.toBe(0); + await expect( + recorded.backend.getNode(graph.id, "Track", "fresh"), + ).resolves.toBeUndefined(); + }); + + it("refuses to resurrect a part the composition cascade tombstoned, leaving it deleted", async () => { + const recorded = await openRecordedStore(); + const album = await recorded.store.nodes.Album.create({}); + const track = await recorded.store.nodes.Track.create( + { title: "original" }, + { id: "cascaded", partOf: { whole: { kind: "Album", id: album.id } } }, + ); + await recorded.store.nodes.Album.delete(album.id); + await expect( + recorded.store.nodes.Track.getById(track.id), + ).resolves.toBeUndefined(); + const tombstone = await recorded.backend.getNode( + graph.id, + "Track", + "cascaded", + ); + expect(tombstone?.deleted_at).toBeDefined(); + const recordedRowsBefore = await recordedTrackRowCount( + recorded, + "cascaded", + ); + + await expect(upsertTrack(recorded, "cascaded")).rejects.toSatisfy( + (error: unknown) => isRequiredCompositionRefusal(error), + ); + + await expect(recordedTrackRowCount(recorded, "cascaded")).resolves.toBe( + recordedRowsBefore, + ); + await expect( + recorded.store.nodes.Track.getById(track.id), + ).resolves.toBeUndefined(); + await expect( + recorded.backend.getNode(graph.id, "Track", "cascaded"), + ).resolves.toEqual(tombstone); + }); +}); diff --git a/packages/typegraph/tests/identity-history-completeness.ts b/packages/typegraph/tests/identity-history-completeness.ts new file mode 100644 index 000000000..2923733dc --- /dev/null +++ b/packages/typegraph/tests/identity-history-completeness.ts @@ -0,0 +1,28 @@ +import { + type IdentityHistoryCompleteness, + type IdentityHistoryIncompleteReason, + type RecordedInstant, +} from "../src"; + +type HistoryPage = Readonly<{ completeness: IdentityHistoryCompleteness }>; + +/** The `reason` entry of a page's completeness, or `undefined` when that reason does not hold. */ +export function completenessReason< + R extends IdentityHistoryIncompleteReason["reason"], +>( + page: HistoryPage, + reason: R, +): Extract | undefined { + if (page.completeness.status === "complete") return undefined; + return page.completeness.reasons.find( + (entry): entry is Extract => + entry.reason === reason, + ); +} + +/** The retention cut a page reports, or `undefined` when its range is untouched by pruning. */ +export function truncatedBeforeOf( + page: HistoryPage, +): RecordedInstant | undefined { + return completenessReason(page, "retention-truncated")?.before; +} diff --git a/packages/typegraph/tests/identity-import-hardening.test.ts b/packages/typegraph/tests/identity-import-hardening.test.ts index 905ee42d6..614a5e955 100644 --- a/packages/typegraph/tests/identity-import-hardening.test.ts +++ b/packages/typegraph/tests/identity-import-hardening.test.ts @@ -4,19 +4,25 @@ import { z } from "zod"; import { type CompiledRowsSql, type CompiledStatementSql, + createAdapterStoreWithSchema, defineEdge, defineGraph, defineNode, type GraphBackend, + pruneIdentityTransitions, type TransactionBackend, } from "../src"; import { deriveBackend } from "../src/backend/derive-backend"; -import { IdentityContradictionError } from "../src/errors"; +import { RECORDED_MAX_REVISION } from "../src/core/temporal"; +import { IdentityContradictionError, ValidationError } from "../src/errors"; import { type IdentityTransferAssertion } from "../src/identity/service"; +import { applyIdentityChangesForContext } from "../src/identity/service-interchange-write"; import { exportGraph, + exportGraphStream, importGraph, importGraphStream, + type ImportResult, } from "../src/interchange"; import { type GraphData, @@ -30,8 +36,10 @@ import { asCompiledRowsSql, asCompiledStatementSql, } from "../src/query/sql-intent"; -import { storeRuntime } from "../src/store/runtime-port"; +import { storeBackend, storeRuntime } from "../src/store/runtime-port"; +import { generateId } from "../src/utils/id"; import { requireDefined } from "../src/utils/presence"; +import { truncatedBeforeOf } from "./identity-history-completeness"; import { createInitializedStore, createTestBackend, @@ -83,6 +91,52 @@ async function* chunkStream( } } +/** A history-enabled source with one assertion, one retraction and one re-assertion. */ +async function sourceWithIdentityHistory() { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create({ name: "Bob" }, { id: "bob" }); + const carol = await source.nodes.Person.create( + { name: "Carol" }, + { id: "carol" }, + ); + const first = await source.identity.assertSame(alice, bob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, carol); + return { source }; +} + +async function collectArchivalStream( + source: Awaited>["source"], + batchSize?: number, +): Promise { + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + includeDeleted: true, + ...(batchSize === undefined ? {} : { batchSize }), + })) { + chunks.push(chunk); + } + return chunks; +} + +async function historyTarget() { + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + return target; +} + function isoAt(offsetMs: number): string { return new Date(Date.now() + offsetMs).toISOString(); } @@ -394,6 +448,1214 @@ describe("archival identity import window bounds", () => { }); expect(restored.identity).toEqual(archive.identity); }); + + // An archived transition's `recordedRevision` is read back through + // `createRecordedInstant`, so a restore must refuse every value that + // function refuses. Accepting one would commit a row no read can return: + // `transitionsOf` and `replay` throw for every lineage that reaches it. + it.each([ + { label: "zero", recordedRevision: 0 }, + { label: "the open ceiling", recordedRevision: RECORDED_MAX_REVISION }, + ])( + "refuses an archived transition whose recordedRevision is $label", + async ({ recordedRevision }) => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const identity = requireDefined(archive.identity); + const poisoned = { + ...archive, + identity: { + ...identity, + transitions: requireDefined(identity.transitions).map( + (transition) => ({ ...transition, recordedRevision }), + ), + }, + }; + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const refusal = await importGraph(target, poisoned, { + onConflict: "skip", + }).catch((error: unknown) => error); + console.info("unreadable recordedRevision", recordedRevision, refusal); + expect(refusal).toBeInstanceOf(ValidationError); + // Refused before any write: nothing was restored to trip a later read. + expect(await target.nodes.Person.count()).toBe(0); + + // The boundary values the reader accepts are still restored. + const accepted = await importGraph( + target, + { + ...archive, + identity: { + ...identity, + transitions: requireDefined(identity.transitions).map( + (transition, index) => ({ + ...transition, + recordedRevision: index === 0 ? 1 : RECORDED_MAX_REVISION - 1, + }), + ), + }, + }, + { onConflict: "skip" }, + ); + expect(accepted.errors).toEqual([]); + const { transitions } = await target.identity.transitionsOf(alice); + expect(transitions.length).toBeGreaterThan(0); + }, + ); + + // Load-bearing (L10): the archival round trip carries the transitions + // section verbatim and the restore watermark reports the + // explanations-without-snapshots seam honestly (identity.md, "Archival + // transitions and the retention watermark"). + it("archival round trip carries transitions and the watermark (L10)", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + + const first = await source.identity.assertSame(alice, bob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, bob); + + const { transitions: sourceTransitions } = + await source.identity.transitionsOf(alice); + expect(sourceTransitions.length).toBeGreaterThanOrEqual(3); + expect(sourceTransitions.map((transition) => transition.cause)).toEqual( + expect.arrayContaining(["assert", "retract"]), + ); + + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + expect(archive.identity?.transitions?.length).toBe( + sourceTransitions.length, + ); + // Nothing was ever pruned at the source, so no watermark is carried. + expect(archive.identity?.retention).toBeUndefined(); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const result = await importGraph(target, archive, { onConflict: "skip" }); + expect(result.errors).toEqual([]); + expect(result.success).toBe(true); + + // `transitionsOf` answers FULLY from a restored archive: every restored + // transition is visible by walking from either endpoint's current class + // canonical. The target's own set is a SUPERSET of the + // restored ids — importing the still-open assertion is itself a genuine + // merge on the target, and that generates its own new "assert" note on + // top of the transplanted explanatory history, exactly as any other + // identity-affecting write would. + const { transitions: targetTransitions } = + await target.identity.transitionsOf(alice); + expect(targetTransitions.length).toBeGreaterThanOrEqual( + sourceTransitions.length, + ); + const targetTransitionIds = new Set( + targetTransitions.map((transition) => transition.transitionId), + ); + for (const sourceTransition of sourceTransitions) { + expect(targetTransitionIds.has(sourceTransition.transitionId)).toBe(true); + // Load-bearing (R-S4): the restore marker is PUBLIC on the transition, + // so an audit surface can tell an imported explanation (excluded from + // `replay`'s steps) from a locally replayable event without inferring + // it from a revision comparison — the inference the marker exists to + // replace. Mutation check: drop the `restored` spread from + // `publicTransition` (src/identity/replay.ts) and this expectation + // fails on the first restored id. + const restoredHere = targetTransitions.find( + (transition) => + transition.transitionId === sourceTransition.transitionId, + ); + expect(restoredHere?.restored?.at).toEqual(expect.any(String)); + // ...and the SOURCE's own copy of the identical row is not marked: + // the marker names where the row was inserted, never what it explains. + expect(sourceTransition.restored).toBeUndefined(); + } + // The target's own post-restore note (the import's genuine merge) is + // native, so `restored` distinguishes rather than blanketing everything. + const nativeTargetTransitions = targetTransitions.filter( + (transition) => transition.restored === undefined, + ); + expect(nativeTargetTransitions.length).toBeGreaterThan(0); + + // `replay`, by contrast, reports the seam honestly rather than silently + // claiming a complete history: the restore set the watermark to the + // target's own current revision + 1 at restore time, which — since the + // target committed at least the alice/bob creates before this import — + // is above 0, the point an unbounded replay call's requested range + // starts from. + const targetReplay = await target.identity.replay(alice); + expect(truncatedBeforeOf(targetReplay)).toBeDefined(); + }); + + describe("archived decision provenance", () => { + const DECISION = { + branchId: "branch-a", + branchAncestry: [graph.id, "branch-a"], + mergePlanDigest: "plan-digest", + reviewDigest: "review-digest", + sourceId: "source-id", + } as const; + + /** An archive whose one governed transition carries {@link DECISION}. */ + async function archiveWithGovernedTransition() { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await source.nodes.Person.create({ name: "Alice" }, { id: "alice" }); + await source.nodes.Person.create({ name: "Bob" }, { id: "bob" }); + await applyIdentityChangesForContext( + storeRuntime(source).identityContext(), + [], + [ + { + id: generateId(), + relation: "same", + a: { kind: "Person", id: "alice" }, + b: { kind: "Person", id: "bob" }, + validFrom: new Date().toISOString(), + }, + ], + DECISION, + ); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const transitions = requireDefined(archive.identity?.transitions); + const governed = requireDefined( + transitions.find((transition) => transition.decision !== undefined), + ); + return { archive, transitions, governed }; + } + + async function restoreTarget() { + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + return target; + } + + it("round-trips every decision field through an archival restore", async () => { + const { archive, governed } = await archiveWithGovernedTransition(); + expect(governed.cause).toBe("reconcile"); + expect(governed.decision).toEqual(DECISION); + + const target = await restoreTarget(); + const result = await importGraph(target, archive, { onConflict: "skip" }); + expect(result.errors).toEqual([]); + expect(result.success).toBe(true); + + const { transitions } = await target.identity.transitionsOf({ + kind: "Person", + id: "alice", + }); + const restored = requireDefined( + transitions.find( + (transition) => transition.transitionId === governed.transitionId, + ), + ); + console.info("restored transition", restored); + expect(restored.cause).toBe("reconcile"); + expect(restored.decision).toEqual(DECISION); + expect(restored.restored?.at).toEqual(expect.any(String)); + }); + + it("rejects an archive whose decision carries evidence the format does not define, rather than stripping it", async () => { + const { archive, transitions, governed } = + await archiveWithGovernedTransition(); + const tampered = { + ...archive, + identity: { + ...requireDefined(archive.identity), + transitions: transitions.map((transition) => + transition === governed ? + { + ...transition, + decision: { + ...transition.decision, + policy: { assertion: ["flag"] }, + }, + } + : transition, + ), + }, + } as GraphData; + + const target = await restoreTarget(); + let refusal: unknown; + try { + await importGraph(target, tampered, { onConflict: "skip" }); + } catch (error) { + refusal = error; + } + console.info("archive refusal", refusal); + expect(refusal).toBeInstanceOf(Error); + expect((refusal as Error).message).toContain( + "Invalid identity interchange section", + ); + expect((refusal as Error).message).toContain("policy"); + // Refused before anything was written. + expect(await target.nodes.Person.count()).toBe(0); + const { transitions: written } = await target.identity.transitionsOf({ + kind: "Person", + id: "alice", + }); + expect(written).toEqual([]); + }); + }); + + it("refuses a state-mode payload naming a transitions section (IDENTITY_STATE_IMPORT_TRANSITIONS)", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + + const stateExport = await exportGraph(source); + expect(stateExport.identity?.mode).toBe("state"); + const archivalExport = await exportGraph(source, { + identityMode: "archival", + }); + const archivalTransitions = archivalExport.identity?.transitions; + expect(archivalTransitions?.length).toBeGreaterThan(0); + const tampered: GraphData = { + ...stateExport, + identity: { + ...requireDefined(stateExport.identity), + transitions: archivalTransitions, + }, + }; + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await expect( + importGraph(target, tampered, { onConflict: "skip" }), + ).rejects.toMatchObject({ + name: "ValidationError", + details: matchingObject({ + issues: matchingArray([ + expect.objectContaining({ + code: "IDENTITY_STATE_IMPORT_TRANSITIONS", + }), + ]), + }), + }); + }); + + it("refuses an archival payload whose recorded revision decreases (IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE)", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + const first = await source.identity.assertSame(alice, bob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, bob); + + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const transitions = requireDefined(archive.identity?.transitions); + expect(transitions.length).toBeGreaterThanOrEqual(2); + // Reversing preserves every row's own (valid) recordedRevision, but + // delivers them out of order — exactly the "same rows, wrong order" + // shape defect the monotonicity check exists to catch. + const tampered: GraphData = { + ...archive, + identity: { + ...requireDefined(archive.identity), + transitions: transitions.toReversed(), + }, + }; + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await expect( + importGraph(target, tampered, { onConflict: "skip" }), + ).rejects.toMatchObject({ + name: "ValidationError", + details: matchingObject({ + issues: matchingArray([ + expect.objectContaining({ + code: "IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE", + }), + ]), + }), + }); + }); + + // Load-bearing: the restore watermark must be resolved on the + // DESTINATION's own recorded-revision scale, never the source's — writing + // a foreign, source-scale number would misclassify the destination's own + // later, fully-retained transitions as pruned. Revert check: change + // `destinationFloor` back to `Math.max(highestRestoredRevision + 1, + // carriedWatermark ?? 0)` in `importIdentityTransitionsIntoTarget` + // (service-interchange-write.ts) and this test's final assertion fails — + // the reported retention cut swallows the target's own never-pruned + // transition. + it("does not misclassify the destination's own later transitions as pruned after an archival restore", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const sourceAlice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const sourceBob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + const first = await source.identity.assertSame(sourceAlice, sourceBob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(sourceAlice, sourceBob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await importGraph(target, archive, { onConflict: "skip" }); + + const alice = { kind: "Person" as const, id: "alice" }; + const targetAssertions = await target.identity.assertionsOf(alice); + const openAssertion = requireDefined( + targetAssertions.find((assertion) => assertion.validTo === undefined), + ); + // A brand-new transition the TARGET itself records after the restore — + // never pruned, never restored, purely this graph's own later history. + await target.identity.retractAssertion(openAssertion.id); + + const replay = await target.identity.replay(alice); + const retractedNow = requireDefined( + replay.steps.find((step) => step.transition.cause === "retract"), + ); + const truncatedBefore = requireDefined(truncatedBeforeOf(replay)); + expect( + retractedNow.transition.recorded.localeCompare(truncatedBefore), + ).toBeGreaterThanOrEqual(0); + await expect( + target.identity.replay(alice, { + toRecorded: retractedNow.transition.recorded, + }), + ).resolves.toBeDefined(); + }); + + // Load-bearing: a restored transition's row carries the SOURCE graph's own + // revision number, which `replay` must never pair with a membership + // snapshot reconstructed on the DESTINATION's historical reader — that + // pairs a foreign transition with a fabricated before/after that has + // nothing to do with it. Revert check: remove the + // `!isRestoredTransitionRow(row)` filter (both the `nativeRows` line and + // the row-match inside the boundary loop) in `identityReplay` (replay.ts) + // and this test's `steps` assertion fails — the restored transitions + // reappear as steps despite carrying `restored_at`. + it("excludes restored pre-restore boundaries from replay steps", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const sourceAlice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const sourceBob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(sourceAlice, sourceBob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const sourceTransitions = requireDefined(archive.identity?.transitions); + expect(sourceTransitions.length).toBeGreaterThan(0); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + // Advance the TARGET's own clock well past every revision number the + // archive's transitions carry, so the destination's real floor at + // restore time sits above all of them — exactly the case where a + // restored boundary and a live one could otherwise collide. + for (let index = 0; index < sourceTransitions.length + 5; index += 1) { + await target.nodes.Company.create( + { name: `filler-${String(index)}` }, + { id: `filler-${index}` }, + ); + } + await importGraph(target, archive, { onConflict: "skip" }); + + const alice = { kind: "Person" as const, id: "alice" }; + // `transitionsOf` (unaffected by the watermark) still answers fully. + const { transitions: targetTransitions } = + await target.identity.transitionsOf(alice); + expect(targetTransitions.length).toBeGreaterThanOrEqual( + sourceTransitions.length, + ); + + const replay = await target.identity.replay(alice); + expect(truncatedBeforeOf(replay)).toBeDefined(); + // Every restored transition sits strictly below the destination's own + // floor, so none of them may surface as a fabricated replay step. + const restoredTransitionIds = new Set( + sourceTransitions.map((transition) => transition.transitionId), + ); + for (const step of replay.steps) { + expect(restoredTransitionIds.has(step.transition.transitionId)).toBe( + false, + ); + } + }); + + // Load-bearing: restoring into a brand-new target — no filler writes + // inflating its own clock ahead of the archive's revisions, unlike the + // test above — is the ORDINARY restore shape, and must not fabricate + // replay steps either. A per-row `restored_at` marker (not a revision + // comparison against the destination's own low, fresh clock) is what + // makes this sound regardless of which side's numbers happen to be + // larger. Revert check: drop the `restoredAt` argument from the + // `encodeIdentityTransitionRow` call inside `importIdentityTransitionsIntoTarget` + // (service-interchange-write.ts) and this test's `steps` assertion fails — + // the restored `assert` reappears as a step claiming membership never + // changed, when the source's own history shows it did. + it("does not fabricate a replay step for a transition restored into a brand-new target", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const sourceAlice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const sourceBob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + // Retract/reassert, exactly like the destination-scoping test above, so + // the source's own clock reaches a revision higher than 1 — the + // destination's floor the moment it is fresh. A single `assertSame` + // alone would not discriminate the bug: on a truly empty source AND + // target, both floors coincide at revision 1 by accident. + const first = await source.identity.assertSame(sourceAlice, sourceBob); + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(sourceAlice, sourceBob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const sourceTransitions = requireDefined(archive.identity?.transitions); + expect(sourceTransitions.length).toBeGreaterThan(0); + + // A genuinely fresh target — no filler writes inflating its own clock + // ahead of the archive's revisions beforehand, unlike the test above. + // This is what an ordinary restore into a brand-new graph looks like. + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await importGraph(target, archive, { onConflict: "skip" }); + + const alice = { kind: "Person" as const, id: "alice" }; + // `transitionsOf` still answers fully — the restore itself is complete. + // (Archival mode ALSO imports the current assertion as live truth, which + // notes one native transition of its own, so this is `>=`, not `==`.) + const { transitions: targetTransitions } = + await target.identity.transitionsOf(alice); + expect(targetTransitions.length).toBeGreaterThanOrEqual( + sourceTransitions.length, + ); + + // None of the RESTORED transitions may surface as a replay step — doing + // so would pair a foreign transition with a before/after reconstructed + // from the destination's own, unrelated state (the fabrication this test + // guards against; e.g. a restored `assert` step falsely claiming + // membership did not change). + const replay = await target.identity.replay(alice); + const restoredTransitionIds = new Set( + sourceTransitions.map((transition) => transition.transitionId), + ); + for (const step of replay.steps) { + expect(restoredTransitionIds.has(step.transition.transitionId)).toBe( + false, + ); + } + expect(truncatedBeforeOf(replay)).toBeDefined(); + }); + + // Load-bearing: an archival restore that brings in unrelated foreign + // history must never erase, exclude, or report as pruned this graph's OWN + // already-retained transitions — restoring is additive, not a retention + // policy statement about classes the restore never touched. Revert check: + // remove the `hasOwnHistory` guard around the watermark write in + // `importIdentityTransitionsIntoTarget` (service-interchange-write.ts) and + // this test's final two assertions fail — carol's own steps drop to zero + // and her already-recorded history is reported as truncated. + it("preserves the destination's own retained transitions when restoring an unrelated archive", async () => { + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const carol = await target.nodes.Person.create( + { name: "Carol" }, + { id: "carol" }, + ); + const dave = await target.nodes.Person.create( + { name: "Dave" }, + { id: "dave" }, + ); + const carolDave = await target.identity.assertSame(carol, dave); + await target.identity.retractAssertion(carolDave.assertion.id); + const carolReplayBefore = await target.identity.replay(carol); + expect(carolReplayBefore.steps.length).toBeGreaterThan(0); + expect(truncatedBeforeOf(carolReplayBefore)).toBeUndefined(); + + // An archive from a source that never mentions carol or dave. + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const sourceAlice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const sourceBob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(sourceAlice, sourceBob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + await importGraph(target, archive, { onConflict: "skip" }); + + // Carol's own, fully-retained history survives the unrelated restore + // unchanged: same step count, and no false claim of truncation. + const carolReplayAfter = await target.identity.replay(carol); + expect(carolReplayAfter.steps.length).toBe(carolReplayBefore.steps.length); + await expect( + target.identity.replay(carol, { + toRecorded: requireDefined(carolReplayBefore.steps.at(-1)).transition + .recorded, + }), + ).resolves.toBeDefined(); + }); + + // Load-bearing: re-importing the identical archival document — + // `importGraph(..., { onConflict: "skip" })` re-run over the same + // archive, exactly what a repeated backup restore does — must not crash + // with a raw driver UNIQUE-constraint error on `transition_id`. Revert + // check: drop `ON CONFLICT (graph_id, transition_id) DO NOTHING` from + // `insertIdentityTransitionValues` (transition-log.ts) and the second + // `importGraph` call below rejects with a driver error instead of + // resolving. + it("re-imports the same archival transitions document without crashing", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const sourceAlice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const sourceBob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(sourceAlice, sourceBob); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const firstImport = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(firstImport.success).toBe(true); + const alice = { kind: "Person" as const, id: "alice" }; + const { transitions: transitionsAfterFirst } = + await target.identity.transitionsOf(alice); + + const secondImport = await importGraph(target, archive, { + onConflict: "skip", + }); + expect(secondImport.success).toBe(true); + expect(secondImport.errors).toEqual([]); + // Verbatim restore of the SAME rows: re-importing must not duplicate them. + const { transitions: transitionsAfterSecond } = + await target.identity.transitionsOf(alice); + expect(transitionsAfterSecond.length).toBe(transitionsAfterFirst.length); + }); + + it("refuses to restore archival transitions into a target opened without history: true", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + const archive = await exportGraph(source, { identityMode: "archival" }); + expect(archive.identity?.transitions?.length).toBeGreaterThan(0); + + // A history-off target has no way to ever read these rows back + // (`transitionsOf` / `replay` both refuse without `history: true`), so + // restoring them here must refuse up front rather than write data the + // store's own API can never surface again. + const target = await createInitializedStore(graph, createTestBackend()); + await expect( + importGraph(target, archive, { onConflict: "skip" }), + ).rejects.toMatchObject({ + name: "IdentityReplayError", + details: matchingObject({ code: "IDENTITY_REPLAY_REQUIRES_HISTORY" }), + }); + }); + + // Load-bearing: the SAME refusal, on the streaming path, must fire at the + // header — before any node, edge, or identity-assertion chunk is applied — + // not from the last import section, after those chunks already committed. + // Revert check: delete the `assertIdentityTransitionsRestoreSupported` + // call from the "header" chunk arm of `importGraphStream` + // (interchange/import.ts) and this test's node-count assertion fails — + // the refusal still fires (store.ts's backstop still catches it), but only + // after Alice and Bob are already durably written. + it("refuses an archival-transitions restore via importGraphStream before writing any node", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push(chunk); + } + const header = requireDefined( + chunks.find((chunk) => chunk.type === "header"), + ); + // The header carries the announcement the refusal reads — proving this + // test actually exercises the header-time signal, not a coincidence. + expect(header.header.identity?.hasTransitions).toBe(true); + + const target = await createInitializedStore(graph, createTestBackend()); + await expect( + importGraphStream(target, chunkStream(chunks), { onConflict: "skip" }), + ).rejects.toMatchObject({ + name: "IdentityReplayError", + details: matchingObject({ code: "IDENTITY_REPLAY_REQUIRES_HISTORY" }), + }); + + // Nothing from the stream was ever applied — the refusal fired before + // the "nodes" chunk, not after it. + await expect(target.nodes.Person.count()).resolves.toBe(0); + }); + + it("streams the identity-transitions chunk through exportGraphStream / importGraphStream", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + const { transitions: sourceTransitions } = + await source.identity.transitionsOf(alice); + expect(sourceTransitions.length).toBeGreaterThan(0); + + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push(chunk); + } + expect(chunks.some((chunk) => chunk.type === "identity-transitions")).toBe( + true, + ); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const result = await importGraphStream(target, chunkStream(chunks), { + onConflict: "skip", + }); + expect(result.errors).toEqual([]); + expect(result.success).toBe(true); + + const { transitions: targetStreamedTransitions } = + await target.identity.transitionsOf(alice); + const targetTransitionIds = new Set( + targetStreamedTransitions.map((transition) => transition.transitionId), + ); + for (const sourceTransition of sourceTransitions) { + expect(targetTransitionIds.has(sourceTransition.transitionId)).toBe(true); + } + }); + + it("refuses an identity-transitions chunk its header never announced", async () => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + await source.identity.assertSame(alice, bob); + + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + })) { + chunks.push( + chunk.type === "header" && chunk.header.identity !== undefined ? + { + ...chunk, + header: { + ...chunk.header, + identity: { ...chunk.header.identity, hasTransitions: false }, + }, + } + : chunk, + ); + } + expect(chunks.some((chunk) => chunk.type === "identity-transitions")).toBe( + true, + ); + + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await expect( + importGraphStream(target, chunkStream(chunks), { onConflict: "skip" }), + ).rejects.toThrow(/did not announce/); + }); + + it("refuses a stream whose header announces a transition restore that never arrives", async () => { + const { source } = await sourceWithIdentityHistory(); + const whole = await collectArchivalStream(source); + expect( + requireDefined(whole.find((chunk) => chunk.type === "header")).header + .identity?.hasTransitions, + ).toBe(true); + + const target = await historyTarget(); + await expect( + importGraphStream( + target, + chunkStream( + whole.filter((chunk) => chunk.type !== "identity-transitions"), + ), + { onConflict: "skip" }, + ), + ).rejects.toMatchObject({ + name: "ValidationError", + details: matchingObject({ + issues: matchingArray([ + expect.objectContaining({ + code: "IDENTITY_IMPORT_STREAM_TRANSITIONS_MISSING", + }), + ]), + }), + }); + }); + + it("refuses a stream whose header carries a pruned watermark but no transitions chunk", async () => { + const { source } = await sourceWithIdentityHistory(); + await pruneIdentityTransitions(source, { + beforeRecorded: requireDefined(await source.recordedNow()), + }); + const whole = await collectArchivalStream(source); + const header = requireDefined( + whole.find((chunk) => chunk.type === "header"), + ).header; + expect(header.identity?.retention?.prunedBeforeRevision).toBeGreaterThan(0); + + // The whole stream restores the watermark... + const complete = await historyTarget(); + await importGraphStream(complete, chunkStream(whole), { + onConflict: "skip", + }); + const restored = await storeRuntime( + complete, + ).identityTransitionRetentionAtTarget(storeBackend(complete)); + expect(restored.prunedBeforeRevision).toBeGreaterThan(0); + + // ...so the same stream cut before its transitions chunk is refused + // rather than reporting success with no watermark. + const truncated = await historyTarget(); + await expect( + importGraphStream( + truncated, + chunkStream( + whole.filter((chunk) => chunk.type !== "identity-transitions"), + ), + { onConflict: "skip" }, + ), + ).rejects.toThrow(/ended without the identity transitions its header/); + }); + + it("holds streamed transitions to one non-decreasing order across chunks", async () => { + const { source } = await sourceWithIdentityHistory(); + const chunks = await collectArchivalStream(source, 1); + const transitionChunks = chunks.filter( + (chunk) => chunk.type === "identity-transitions", + ); + expect(transitionChunks.length).toBeGreaterThan(1); + const reordered = [ + ...chunks.filter((chunk) => chunk.type !== "identity-transitions"), + ...transitionChunks.toReversed(), + ]; + + const target = await historyTarget(); + await expect( + importGraphStream(target, chunkStream(reordered), { onConflict: "skip" }), + ).rejects.toMatchObject({ + name: "ValidationError", + details: matchingObject({ + issues: matchingArray([ + expect.objectContaining({ + code: "IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE", + }), + ]), + }), + }); + }); + + it("refuses a state-mode payload carrying a retention watermark, on either entry point", async () => { + const { source } = await sourceWithIdentityHistory(); + await pruneIdentityTransitions(source, { + beforeRecorded: requireDefined(await source.recordedNow()), + }); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const identity = requireDefined(archive.identity); + const stateWithRetention: GraphData = { + ...archive, + identity: { + profile: identity.profile, + mode: "state", + assertions: identity.assertions.filter( + (assertion) => assertion.validTo === undefined, + ), + retention: requireDefined(identity.retention), + }, + }; + const refusal = { + name: "ValidationError", + details: matchingObject({ + issues: matchingArray([ + expect.objectContaining({ code: "IDENTITY_STATE_IMPORT_RETENTION" }), + ]), + }), + }; + await expect( + importGraph(await historyTarget(), stateWithRetention, { + onConflict: "skip", + }), + ).rejects.toMatchObject(refusal); + + const streamed = await collectArchivalStream(source); + const header = requireDefined( + streamed.find((chunk) => chunk.type === "header"), + ).header; + const stateHeader: GraphDataHeader = { + ...header, + identity: { + profile: IDENTITY_PROFILE, + mode: "state", + retention: requireDefined(header.identity?.retention), + }, + }; + const streamTarget = await historyTarget(); + await expect( + importGraphStream( + streamTarget, + chunkStream([ + { type: "header", header: stateHeader }, + { type: "nodes", nodes: archive.nodes }, + ]), + { onConflict: "skip" }, + ), + ).rejects.toMatchObject(refusal); + await expect(streamTarget.nodes.Person.count()).resolves.toBe(0); + }); + + // The shape checks need only the payload in hand, so they run before the + // import opens its write transaction; on a backend that writes without one, + // a refusal after the node writes would leave them committed. + it("refuses a misordered or state-mode transitions payload before opening a write transaction", async () => { + const { source } = await sourceWithIdentityHistory(); + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + const identity = requireDefined(archive.identity); + const transitions = requireDefined(identity.transitions); + expect(transitions.length).toBeGreaterThanOrEqual(2); + const misordered: GraphData = { + ...archive, + identity: { ...identity, transitions: transitions.toReversed() }, + }; + const stateWithTransitions: GraphData = { + ...archive, + identity: { + profile: identity.profile, + mode: "state", + assertions: [], + transitions, + }, + }; + + const base = createTestBackend(); + let transactionsOpened = 0; + const counting = deriveBackend(base, { + transaction: (fn, options) => { + transactionsOpened += 1; + return base.transaction(fn, options); + }, + }); + const [target] = await createAdapterStoreWithSchema(graph, counting, { + history: true, + }); + for (const [document, code] of [ + [misordered, "IDENTITY_IMPORT_TRANSITIONS_NOT_MONOTONE"], + [stateWithTransitions, "IDENTITY_STATE_IMPORT_TRANSITIONS"], + ] as const) { + transactionsOpened = 0; + await expect( + importGraph(target, document, { onConflict: "skip" }), + ).rejects.toMatchObject({ + details: matchingObject({ + issues: matchingArray([expect.objectContaining({ code })]), + }), + }); + expect(transactionsOpened).toBe(0); + } + }); + + it("treats a zero retention watermark as no restore", async () => { + const { formatVersion } = await exportGraph(await historyTarget()); + const zeroWatermark: GraphData = { + formatVersion, + exportedAt: new Date().toISOString(), + source: { type: "external", description: "zero watermark" }, + nodes: [{ kind: "Person", id: "p1", properties: { name: "A" } }], + edges: [], + identity: { + profile: IDENTITY_PROFILE, + mode: "archival", + assertions: [], + retention: { + prunedBeforeRevision: 0, + prunedAt: new Date().toISOString(), + }, + }, + }; + const withoutHistory = await createInitializedStore( + graph, + createTestBackend(), + ); + const result = await importGraph(withoutHistory, zeroWatermark, { + onConflict: "error", + }); + expect(result.errors).toEqual([]); + await expect(withoutHistory.nodes.Person.count()).resolves.toBe(1); + }); + + // A restore into a destination that has recorded no identity history of its + // own sets the retention watermark at the destination's floor, so replay + // reports the restored history as explanations without snapshots. Whether + // the destination is such a graph is one fact about it before the import + // began, so `importGraph` and `importGraphStream` must reach the same + // answer for the same archive. + it.each([ + { label: "an unpruned archive", pruneSource: false }, + { label: "a pruned archive", pruneSource: true }, + ])( + "importGraph and importGraphStream leave the same retention watermark for $label", + async ({ pruneSource }) => { + const [source] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await source.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + const bob = await source.nodes.Person.create( + { name: "Bob" }, + { id: "bob" }, + ); + const first = await source.identity.assertSame(alice, bob); + if (pruneSource) { + await pruneIdentityTransitions(source, { + beforeRecorded: requireDefined(await source.recordedNow()), + }); + } + await source.identity.retractAssertion(first.assertion.id); + await source.identity.assertSame(alice, bob); + + const archive = await exportGraph(source, { + identityMode: "archival", + includeDeleted: true, + }); + expect(archive.identity?.retention !== undefined).toBe(pruneSource); + const chunks: GraphInterchangeChunk[] = []; + for await (const chunk of exportGraphStream(source, { + identityMode: "archival", + includeDeleted: true, + })) { + chunks.push(chunk); + } + + async function restoredWatermark( + restore: (target: typeof source) => Promise, + ): Promise { + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const result = await restore(target); + expect(result.errors).toEqual([]); + const retention = await storeRuntime( + target, + ).identityTransitionRetentionAtTarget(storeBackend(target)); + return retention.prunedBeforeRevision; + } + + const viaDocument = await restoredWatermark((target) => + importGraph(target, archive, { onConflict: "skip" }), + ); + const viaStream = await restoredWatermark((target) => + importGraphStream(target, chunkStream(chunks), { onConflict: "skip" }), + ); + + console.info("restore watermark", { viaDocument, viaStream }); + expect(viaDocument).toBeGreaterThan(0); + expect(viaStream).toBe(viaDocument); + }, + ); }); // ============================================================ diff --git a/packages/typegraph/tests/identity-replay.test.ts b/packages/typegraph/tests/identity-replay.test.ts new file mode 100644 index 000000000..19b871b1b --- /dev/null +++ b/packages/typegraph/tests/identity-replay.test.ts @@ -0,0 +1,568 @@ +/** + * The replay algorithm: shape, limits, refusals, retention watermark, and the + * equivalence pin (L3) — replay's before/after NEVER comes from the + * transition log's own `class`/`priorClass` columns, only from + * `historicalIdentityReconstructionCtes` via `loadHistoricalClasses`. + * + * `identityReplay` / `identityTransitionsOf` are reached by module path + * throughout most of this file (the fine-grained refusal/limit/watermark + * cases below); the trailing "public facade surface" suite exercises the + * public release surface, `store.identity.replay` / `transitionsOf` and + * their transaction-facade counterparts, directly. + */ +import { sql as drizzleSql } from "drizzle-orm"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStoreWithSchema, + defineGraph, + defineNode, + type IdentityReplayStep, + type RestoredIdentityTransition, + type TransitionPageCursor, +} from "../src"; +import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; +import { + asRecordedInstant, + createRecordedInstant, + type RecordedInstant, + recordedInstantRevision, +} from "../src/core/temporal"; +import { IdentityReplayError, ValidationError } from "../src/errors"; +import { + IDENTITY_REPLAY_MAX_LIMIT, + identityReplay, + identityReplayWalkIncompleteError, + identityTransitionsOf, +} from "../src/identity/replay"; +import { pruneIdentityTransitionsForContext } from "../src/identity/transition-log"; +import { exportGraph, importGraph } from "../src/interchange"; +import { storeRuntime } from "../src/store/runtime-port"; +import { nowIso } from "../src/utils/date"; +import { requireDefined } from "../src/utils/presence"; +import { truncatedBeforeOf } from "./identity-history-completeness"; +import { createTestBackend } from "./test-utils"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); + +const graph = defineGraph({ + id: "identity_replay", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +async function buildAbcStore() { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.nodes.Person.create({ name: "C" }, { id: "c" }); + return store; +} + +describe("identity replay", () => { + it("refuses replay on a store without history: true", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: false }, + ); + const ctx = storeRuntime(store).identityContext(); + await expect( + identityReplay(ctx, { kind: "Person", id: "a" }), + ).rejects.toThrow(IdentityReplayError); + await expect( + identityReplay(ctx, { kind: "Person", id: "a" }), + ).rejects.toMatchObject({ + details: { code: "IDENTITY_REPLAY_REQUIRES_HISTORY" }, + }); + }); + + it("rejects a limit above IDENTITY_REPLAY_MAX_LIMIT", async () => { + const store = await buildAbcStore(); + const ctx = storeRuntime(store).identityContext(); + await expect( + identityReplay( + ctx, + { kind: "Person", id: "a" }, + { + limit: IDENTITY_REPLAY_MAX_LIMIT + 1, + }, + ), + ).rejects.toThrow(); + }); + + // Load-bearing: a page size below 1, or a fractional one, is a typed + // refusal — never a page. Mutation check: drop the + // `!Number.isInteger(resolved) || resolved < 1` guard from `resolveLimit` + // (replay.ts) and every case below fails — `limit: 0` resolves to an empty + // page whose `nextCursor` names the FIRST boundary, so the documented + // `while (cursor !== undefined)` loop re-reads that same empty page + // forever, and the fractional cases escape as a bare `TypeError` from + // `requireDefined` instead of a `ValidationError`. + it("rejects a limit below 1 or fractional, so a page always advances the cursor", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + await store.identity.assertSame(a, b); + const ctx = storeRuntime(store).identityContext(); + + for (const limit of [0, -1, 0.5, 1.5]) { + await expect(identityTransitionsOf(ctx, a, { limit })).rejects.toThrow( + ValidationError, + ); + await expect(identityReplay(ctx, a, { limit })).rejects.toThrow( + ValidationError, + ); + } + await expect( + identityTransitionsOf(ctx, a, { limit: 0 }), + ).rejects.toMatchObject({ + details: { issues: [{ path: "limit", message: "Got 0." }] }, + }); + + // The smallest ACCEPTED page still moves: one boundary, and a cursor + // pointing past it. + const page = await identityTransitionsOf(ctx, a, { limit: 1 }); + expect(page.transitions.length).toBeGreaterThan(0); + }); + + it("replays merge / split / re-merge: every step's before/after matches an independent asOfRecorded read", async () => { + // Comparing `current.before` against `previous.after` only compares two + // of replay's own reconstructions with each other. Every assertion below + // instead checks against `store.asOfRecorded(...)`, a + // read path replay's OWN reconstruction never touches, matching the + // cross-backend twin (tests/backends/integration/identity-replay.ts). + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + + const same1 = await store.identity.assertSame(a, b); + await store.identity.assertSame(b, { kind: "Person", id: "c" }); + await store.identity.retractAssertion(same1.assertion.id); + await store.identity.assertSame(a, b); + + const ctx = storeRuntime(store).identityContext(); + const replay = await identityReplay(ctx, a); + + // The exact cause sequence, not merely "contains" — merge (assert a-b), + // merge (assert b-c, absorbing a transitively), split (retract a-b, in + // TWO records sharing one boundary: a departs alone, b/c's own record + // shares the same self-referential canonical), re-merge (assert a-b). + expect(replay.steps.map((step) => step.transition.cause)).toEqual([ + "assert", + "assert", + "retract", + "retract", + "assert", + ]); + + // Every step's `after` — and the first step's `before` — independently + // verified against `asOfRecorded`, never against another step's own + // reconstructed value. + for (const [index, step] of replay.steps.entries()) { + const after = await store + .asOfRecorded(step.transition.recorded) + .identity.membersOf(a); + expect(step.after.map((ref) => ref.id).toSorted()).toEqual( + after.map((ref) => ref.id).toSorted(), + ); + if (index > 0) continue; + const revision = recordedInstantRevision(step.transition.recorded); + const beforeInstant = createRecordedInstant(revision - 1, nowIso()); + const before = await store + .asOfRecorded(beforeInstant) + .identity.membersOf(a); + expect(step.before.map((ref) => ref.id).toSorted()).toEqual( + before.map((ref) => ref.id).toSorted(), + ); + } + + // The final step's `after` also matches a live membersOf read. + const lastStep = replay.steps.at(-1); + if (lastStep === undefined) throw new Error("expected at least one step"); + const liveMembers = await store.identity.membersOf(a); + expect(lastStep.after.map((ref) => ref.id).toSorted()).toEqual( + liveMembers.map((ref) => ref.id).toSorted(), + ); + }); + + it("transitionsOf and replay agree on the same transition rows", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + await store.identity.assertSame(a, b); + + const ctx = storeRuntime(store).identityContext(); + const { transitions } = await identityTransitionsOf(ctx, a); + const replay = await identityReplay(ctx, a); + expect(replay.steps.map((step) => step.transition.transitionId)).toEqual( + transitions.map((transition) => transition.transitionId), + ); + }); + + // Load-bearing: lineage DISCOVERY must ignore the caller's window. + // The seed set can only learn a class name from a note that mentions it, + // and the note that teaches it routinely sits ABOVE the window — the walk + // starts at the CURRENT canonical and hops backwards through `priorClass`. + // Mutation check: give `readIdentityTransitions` back its `fromRevision` / + // `toRevision` filters and pass the caller's bounds into + // `walkClassLineage` (its earlier shape) — the walk from `a` then reads + // nothing at or below the first merge's revision, both `windowed` and + // `windowedReplay` come back empty, and the first two expectations here + // fail. `pnpm exec vitest run tests/identity-replay.test.ts --maxWorkers=2`. + it("a windowed read discovers lineage the window itself cannot see", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + const c = { kind: "Person" as const, id: "c" }; + + // B and C merge FIRST. Every note at this boundary names the {b, c} + // class — `a` appears nowhere at or below it. + await store.identity.assertSame(b, c); + const throughFirstMerge = await store.recordedNow(); + if (throughFirstMerge === undefined) { + throw new Error("expected a recorded instant"); + } + // A joins and takes over as canonical (code-point-smallest member), so + // the walk from `b` now seeds on `a`. + await store.identity.assertSame(a, b); + + const ctx = storeRuntime(store).identityContext(); + const firstMergeRevision = recordedInstantRevision(throughFirstMerge); + + const { transitions: windowed } = await identityTransitionsOf(ctx, b, { + toRecorded: throughFirstMerge, + }); + expect(windowed.length).toBeGreaterThan(0); + const windowedReplay = await identityReplay(ctx, b, { + toRecorded: throughFirstMerge, + }); + expect(windowedReplay.steps.length).toBeGreaterThan(0); + + // The window still narrows the ANSWER — discovery being unbounded must + // not leak the later boundary back into the result. + for (const transition of windowed) { + expect( + recordedInstantRevision(asRecordedInstant(transition.recorded)), + ).toBeLessThanOrEqual(firstMergeRevision); + } + const { transitions: unbounded } = await identityTransitionsOf(ctx, b); + expect(unbounded.length).toBeGreaterThan(windowed.length); + }); + + // Load-bearing: the boundary limit pages, it never refuses. Mutation + // check: make `pageBoundaries` (replay.ts) throw when + // `boundaries.length > limit` instead of cutting the page — every + // `limit: 1` call below rejects and the whole test fails. + it("limit caps a page and hands back nextCursor, and the pages reassemble the whole lineage", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + const c = { kind: "Person" as const, id: "c" }; + const same1 = await store.identity.assertSame(a, b); + await store.identity.assertSame(b, c); + await store.identity.retractAssertion(same1.assertion.id); + await store.identity.assertSame(a, b); + + const ctx = storeRuntime(store).identityContext(); + const { transitions: whole, nextCursor: wholeNextCursor } = + await identityTransitionsOf(ctx, a); + expect(wholeNextCursor).toBeUndefined(); + const wholeBoundaries = new Set( + whole.map((transition) => transition.recorded), + ); + expect(wholeBoundaries.size).toBeGreaterThan(2); + + const paged: string[] = []; + let cursor: TransitionPageCursor | undefined; + for (let page = 0; page <= wholeBoundaries.size; page += 1) { + const result = await identityTransitionsOf(ctx, a, { + limit: 1, + ...(cursor === undefined ? {} : { cursor }), + }); + // Exactly one boundary per page, and the boundary the cursor named. + expect( + new Set(result.transitions.map((transition) => transition.recorded)) + .size, + ).toBe(1); + paged.push( + ...result.transitions.map((transition) => transition.transitionId), + ); + cursor = result.nextCursor; + if (cursor === undefined) break; + } + expect(cursor).toBeUndefined(); + expect(paged).toEqual(whole.map((transition) => transition.transitionId)); + + // `replay` pages on the identical boundaries — the two must agree about + // where a page ended, or an audit view pairing them would drift. + const firstReplayPage = await identityReplay(ctx, a, { limit: 1 }); + const firstTransitionPage = await identityTransitionsOf(ctx, a, { + limit: 1, + }); + expect(firstReplayPage.nextCursor).toBe(firstTransitionPage.nextCursor); + expect( + firstReplayPage.steps.map((step) => step.transition.transitionId), + ).toEqual( + firstTransitionPage.transitions.map( + (transition) => transition.transitionId, + ), + ); + }); + + it("L3: replay's before/after never come from the transition log's own class columns", async () => { + const { backend, db } = createLocalSqliteBackend(); + const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.nodes.Person.create({ name: "C" }, { id: "c" }); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + const c = { kind: "Person" as const, id: "c" }; + const same1 = await store.identity.assertSame(a, b); + await store.identity.assertSame(b, c); + // The retract's note carries a REAL (non-null) `priorClassRef` (the old + // canonical, `a`): corrupting only `class_kind`/`class_id` below leaves + // that field intact, so the row stays discoverable through the reverse + // lineage hop even once its own identity is garbage — exactly the + // discoverability the exhaustive-diff design relies on (transition-log.ts). + await store.identity.retractAssertion(same1.assertion.id); + + const ctx = storeRuntime(store).identityContext(); + const before = await identityReplay(ctx, a); + expect(before.steps.length).toBeGreaterThanOrEqual(3); + + // Hand-corrupt every transition row's class_kind/class_id to name `a` + // itself, so the log now claims every one of these transitions produced a + // class labelled `a` — including the retraction that actually left `b` and + // `c` together. The rows still name a member of `a`'s class, so they stay + // in `a`'s history; only what they say about the class changes. Written + // through the RAW drizzle handle, not the store's capture-guarded backend, + // which refuses raw statement execution while history capture is enabled. + expect(before.steps.some((step) => step.transition.class.id !== "a")).toBe( + true, + ); + db.run( + drizzleSql`UPDATE typegraph_identity_transitions SET class_kind = 'Person', class_id = 'a'`, + ); + + const after = await identityReplay(ctx, a); + expect(after.steps.length).toBe(before.steps.length); + for (const [index, step] of after.steps.entries()) { + const originalStep = before.steps[index]; + if (originalStep === undefined) throw new Error("index mismatch"); + // The explanation changed (we corrupted it)... + expect(step.transition.class).toEqual(a); + // ...but membership before/after did NOT, because it is reconstructed + // through historicalIdentityReconstructionCtes, never read off the row. + expect(step.before.map((ref) => ref.id).toSorted()).toEqual( + originalStep.before.map((ref) => ref.id).toSorted(), + ); + expect(step.after.map((ref) => ref.id).toSorted()).toEqual( + originalStep.after.map((ref) => ref.id).toSorted(), + ); + } + }); + + it("reports the retention cut once transitions are pruned, and throws when the requested range is entirely below the watermark", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + await store.identity.assertSame(a, b); + const ctx = storeRuntime(store).identityContext(); + const beforePruneRecorded = await store.recordedNow(); + if (beforePruneRecorded === undefined) { + throw new Error("expected a recorded instant"); + } + const currentAssertions = await store.identity.assertionsOf(a); + const assertionId = currentAssertions[0]?.id; + if (assertionId === undefined) throw new Error("expected an assertion"); + await store.identity.retractAssertion(assertionId); + const watermarkRecorded = await store.recordedNow(); + if (watermarkRecorded === undefined) { + throw new Error("expected a recorded instant"); + } + await pruneIdentityTransitionsForContext(ctx, { + beforeRecorded: watermarkRecorded, + }); + + // Open-ended range: partial truncation is reported, not thrown. + const partial = await identityReplay(ctx, a); + expect(truncatedBeforeOf(partial)).toBeDefined(); + + // Bounded entirely below the watermark: refused. + await expect( + identityReplay(ctx, a, { + fromRecorded: beforePruneRecorded, + toRecorded: beforePruneRecorded, + }), + ).rejects.toMatchObject({ + details: { + code: "IDENTITY_REPLAY_HISTORY_TRUNCATED", + requestedFrom: beforePruneRecorded, + requestedTo: beforePruneRecorded, + }, + }); + + // with no `fromRecorded` at all (an open start, closed only by + // `toRecorded`), `requestedFrom` must be ABSENT — never fabricated from + // `toRecorded`, which is the caller's range END, not its start. + await expect( + identityReplay(ctx, a, { toRecorded: beforePruneRecorded }), + ).rejects.toSatisfy((error: unknown) => { + if (!(error instanceof IdentityReplayError)) return false; + if (error.details.code !== "IDENTITY_REPLAY_HISTORY_TRUNCATED") { + return false; + } + return ( + !("requestedFrom" in error.details) && + error.details.requestedTo === beforePruneRecorded + ); + }); + }); + + // Load-bearing: `IDENTITY_REPLAY_WALK_INCOMPLETE`'s code, `ceiling` detail, + // and suggestion are pinned directly against the factory `walkClassLineage` + // throws, rather than against a fixture large enough to trigger the + // multi-million-row safety ceiling for real. Mutation check: change the + // `code`, drop the `ceiling` detail, or edit the suggestion string in + // `identityReplayWalkIncompleteError` (replay.ts) and this test fails on + // the exact field that changed. + it("types a restored transition's instant apart from this graph's recorded instants", async () => { + const source = await buildAbcStore(); + await source.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const archive = await exportGraph(source, { identityMode: "archival" }); + const [target] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const imported = await importGraph(target, archive, { onConflict: "skip" }); + expect(imported.errors).toEqual([]); + + const { transitions } = await target.identity.transitionsOf({ + kind: "Person", + id: "a", + }); + const restored = transitions.filter( + (transition): transition is RestoredIdentityTransition => + transition.restored !== undefined, + ); + expect(restored.length).toBeGreaterThan(0); + + // The source graph's instant has the same string form, but names a + // revision on the source's recorded axis, so it is not an anchor for a + // read of this graph. + expectTypeOf( + requireDefined(restored[0]).recorded, + ).not.toExtend(); + expectTypeOf< + IdentityReplayStep["transition"]["recorded"] + >().toEqualTypeOf(); + function anchorOnRestored( + transition: RestoredIdentityTransition, + ): void { + // @ts-expect-error a restored transition's instant is not this graph's recorded instant + target.asOfRecorded(transition.recorded); + } + void anchorOnRestored; + }); + + it("identityReplayWalkIncompleteError names the safety ceiling that tripped it", () => { + const error = identityReplayWalkIncompleteError(2_000_000); + expect(error).toBeInstanceOf(IdentityReplayError); + expect(error.details).toEqual({ + code: "IDENTITY_REPLAY_WALK_INCOMPLETE", + ceiling: 2_000_000, + }); + expect(error.suggestion).toBe( + "Prune older transitions with pruneIdentityTransitions. Narrowing fromRecorded/toRecorded does not help: lineage discovery reads the whole log on purpose, so that a window can never hide the notes that name a class.", + ); + }); +}); + +/** + * The public release surface: `store.identity.replay` / `transitionsOf` are + * the package's own facade methods (`createIdentityFacade`), not merely the + * internal `identityReplay` / `identityTransitionsOf` functions the suite + * above reaches by path. Both live on `IdentityFacade` (store and + * transaction) and deliberately NOT on `IdentityReadFacade`: a + * coordinate-pinned read-only lens cannot honor a method that answers across + * every recorded coordinate. + */ +describe("identity replay — public facade surface", () => { + it("store.identity and tx.identity both expose replay and transitionsOf, agreeing on already-committed history", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + await store.identity.assertSame(a, b); + + const { transitions: storeTransitions } = + await store.identity.transitionsOf(a); + expect(storeTransitions.length).toBeGreaterThan(0); + const storeReplay = await store.identity.replay(a); + expect(storeReplay.steps.length).toBe(storeTransitions.length); + + await store.transaction(async (tx) => { + const { transitions: txTransitions } = await tx.identity.transitionsOf(a); + expect(txTransitions).toEqual(storeTransitions); + const txReplay = await tx.identity.replay(a); + expect(txReplay).toEqual(storeReplay); + }); + }); + + // Load-bearing (receipts, "enabled + history" arm): end-to-end wiring from + // the recorded-capture flush through to `IdentityWriteSummary.transitions`, + // beside the disabled/history-off arms covered by + // `tests/transaction-receipt.test.ts` (recorder unit) and + // `tests/backends/integration/identity.ts` (history: false). Revert check: + // in `transactionOutcome` (`src/store/store.ts`), drop the + // `recorder.recordIdentityTransitions(flushed.identityTransitions)` call — + // `writes.identity.transitions` reports `0` here despite the assertSame + // above having noted a transition, and this assertion fails. + it("reports a nonzero writes.identity.transitions for a history-enabled graph", async () => { + const store = await buildAbcStore(); + const a = { kind: "Person" as const, id: "a" }; + const b = { kind: "Person" as const, id: "b" }; + + const outcome = await store.transactionWithReceipt(async (tx) => { + await tx.identity.assertSame(a, b); + }); + + expect(outcome.receipt.writes.identity.transitions).toBeGreaterThan(0); + expect(outcome.receipt.writes.identity.sameAssertions).toBe(1); + expect(outcome.receipt.writes.total).toBe( + outcome.receipt.writes.identity.sameAssertions + + outcome.receipt.writes.identity.differentAssertions + + outcome.receipt.writes.identity.retractions, + ); + }); + + // Load-bearing (API-surface): revert check — add `replay` and + // `transitionsOf` to `createIdentityReadFacade`'s returned object (the + // read-only lens `store.asOf(...).identity` is built from) and this test's + // `"replay" in view.identity` / `"transitionsOf" in view.identity` + // assertions flip to `true` and fail. `pnpm exec vitest run + // tests/identity-replay.test.ts --maxWorkers=2`. + it("a coordinate-pinned IdentityReadFacade (store.asOf(...).identity) does NOT expose replay or transitionsOf", async () => { + const store = await buildAbcStore(); + const view = store.asOf(nowIso()); + expect("replay" in view.identity).toBe(false); + expect("transitionsOf" in view.identity).toBe(false); + // The full read surface is otherwise intact — this is a narrow exclusion, + // not a broken lens. + expect(typeof view.identity.membersOf).toBe("function"); + expect(typeof view.identity.assertionsOf).toBe("function"); + }); +}); diff --git a/packages/typegraph/tests/identity-review-fixes.test.ts b/packages/typegraph/tests/identity-review-fixes.test.ts index 90a8345f0..f7c567a08 100644 --- a/packages/typegraph/tests/identity-review-fixes.test.ts +++ b/packages/typegraph/tests/identity-review-fixes.test.ts @@ -696,6 +696,46 @@ describe("identity review fixes", () => { }); }); + it("#closure a recompute that absorbs a member the stale closure left out rewrites that member's separations", async () => { + const store = await createInitializedStore(graph, createTestBackend()); + const a = await store.nodes.Person.create({ name: "A" }, { id: "aaa" }); + const b = await store.nodes.Person.create({ name: "B" }, { id: "bbb" }); + const c = await store.nodes.Person.create({ name: "C" }, { id: "ccc" }); + const d = await store.nodes.Person.create({ name: "D" }, { id: "ddd" }); + await store.identity.assertSame(a, b); + const bridge = await store.identity.assertSame(b, c); + await store.identity.assertDifferent(c, d); + expect(await store.identity.areDifferent(a, d)).toBe(true); + + // Drop `a` from the materialized closure, so the recompute below starts + // from b's class as {b, c} and ABSORBS `a` back from the ledger. + const schema = createSqlSchema(store.backend.tableNames); + const { executeStatement } = store.backend; + if (executeStatement === undefined) { + throw new Error("test backend must execute statements"); + } + await executeStatement( + asCompiledStatementSql(sql` + DELETE FROM ${schema.identityClosureTable} + WHERE graph_id = ${store.graphId} AND member_id = ${a.id} + `), + ); + + await store.identity.retractAssertion(bridge.assertion.id); + + // {a, b} no longer reaches c, so nothing separates it from d. + expect(await store.identity.membersOf(a)).toEqual([ + { kind: "Person", id: a.id }, + { kind: "Person", id: b.id }, + ]); + expect(await store.identity.areDifferent(a, d)).toBe(false); + expect(await store.identity.areDifferent(b, d)).toBe(false); + expect(await store.identity.areDifferent(c, d)).toBe(true); + await expect( + storeRuntime(store).validateIdentity(), + ).resolves.toBeUndefined(); + }); + it("#19 receipt pins identity write intent, not persisted effect", async () => { const store = await createInitializedStore(graph, createTestBackend()); const a = await store.nodes.Person.create({ name: "A" }, { id: "aaa" }); diff --git a/packages/typegraph/tests/identity-standalone-migrate-capture.test.ts b/packages/typegraph/tests/identity-standalone-migrate-capture.test.ts new file mode 100644 index 000000000..fd3064646 --- /dev/null +++ b/packages/typegraph/tests/identity-standalone-migrate-capture.test.ts @@ -0,0 +1,166 @@ +/** + * A standalone schema commit (`migrateSchema` with no Store driving it) takes + * its recorded-capture decision from the database: a graph whose writes + * TypeGraph captures gets its ledger pre-images and identity transition notes + * recorded, and a graph it does not capture — including one whose revision + * clock advances under `revisionTracking` alone — gets nothing recorded. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineGraph, + defineNode, + type GraphBackend, +} from "../src"; +import { RECORDED_MAX_REVISION } from "../src/core/temporal"; +import { readIdentityTransitions } from "../src/identity/transition-log"; +import { createSqlSchema } from "../src/query/compiler/schema"; +import { sql } from "../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../src/query/sql-intent"; +import { migrateSchema } from "../src/schema"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), +}); +const Tag = defineNode("Tag", { + schema: z.object({ label: z.string() }), +}); + +const GRAPH_ID = "identity_standalone_migrate_capture"; + +const withTag = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person }, Tag: { type: Tag } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const withoutTag = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "ignore" }, +}); + +const withoutTagOrIdentity = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person } }, + edges: {}, +}); + +type RecordedAssertionRow = Readonly<{ recorded_to: unknown }>; + +async function recordedAssertionWindows( + backend: GraphBackend, + assertionId: string, +): Promise { + const schema = createSqlSchema(backend.tableNames); + const rows = await backend.execute( + asCompiledRowsSql(sql` + SELECT recorded_to + FROM ${schema.recordedIdentityAssertionsTable} + WHERE graph_id = ${GRAPH_ID} AND id = ${assertionId} + `), + ); + return rows.map((row) => Number(row.recorded_to)); +} + +async function dropTagKind( + backend: GraphBackend, + target: typeof withoutTag | typeof withoutTagOrIdentity = withoutTag, +): Promise { + const active = requireDefined(await backend.getActiveSchema(GRAPH_ID)); + await migrateSchema(backend, target, active.version, { + discardDroppedKindRows: true, + }); +} + +async function kindDropHops(backend: GraphBackend): Promise { + const rows = await readIdentityTransitions( + backend, + createSqlSchema(backend.tableNames), + GRAPH_ID, + { + classRefs: [ + { kind: "Person", id: "p" }, + { kind: "Tag", id: "t" }, + ], + limit: 50, + }, + ); + return rows + .filter((row) => row.cause === "kind-drop") + .map((row) => `${row.class_kind}:${row.class_id} <- ${row.prior_class_id}`); +} + +async function seedSameClass( + backend: GraphBackend, + options: Readonly<{ history: boolean; revisionTracking?: boolean }>, +): Promise { + const [store] = await createStoreWithSchema(withTag, backend, { + history: options.history, + ...(options.revisionTracking === true ? { revisionTracking: true } : {}), + }); + const person = await store.nodes.Person.create({ name: "P" }, { id: "p" }); + const tag = await store.nodes.Tag.create({ label: "T" }, { id: "t" }); + const asserted = await store.identity.assertSame(person, tag); + return asserted.assertion.id; +} + +describe("standalone migrateSchema recorded capture", () => { + // Load-bearing: the kind-drop cascade and closure rebuild run in a raw + // schema-commit transaction no Store binds. Revert check: make the + // `"database"` capture source never bind (skip `bindSchemaCommitCapture`'s + // probe and return) and the assertion's recorded row stays open while the + // kind-drop note is dropped. + it("records ledger pre-images and kind-drop notes for a graph TypeGraph captures", async () => { + const backend = createTestBackend(); + const assertionId = await seedSameClass(backend, { history: true }); + expect(await recordedAssertionWindows(backend, assertionId)).toEqual([ + RECORDED_MAX_REVISION, + ]); + + await dropTagKind(backend); + + const windows = await recordedAssertionWindows(backend, assertionId); + expect(windows).toHaveLength(1); + expect(windows[0]).toBeLessThan(RECORDED_MAX_REVISION); + expect(await kindDropHops(backend)).toEqual(["Person:p <- p"]); + }); + + // The identity-disabled cascade (a commit that drops the kind while + // switching identity off) takes the same capture decision: the ledger rows + // it deletes still close their recorded rows. + it("records the ledger pre-images of a kind drop that also disables identity", async () => { + const backend = createTestBackend(); + const assertionId = await seedSameClass(backend, { history: true }); + + await dropTagKind(backend, withoutTagOrIdentity); + + const windows = await recordedAssertionWindows(backend, assertionId); + expect(windows).toHaveLength(1); + expect(windows[0]).toBeLessThan(RECORDED_MAX_REVISION); + }); + + // Load-bearing: the recorded clock is not capture evidence, since revision + // tracking alone advances it. Revert check: make + // `graphCapturesRecordedHistory` answer `true` and this commit writes a + // recorded identity row and a kind-drop note into a graph that never + // captured history. + it("records nothing for a graph TypeGraph does not capture, even with revision tracking", async () => { + const backend = createTestBackend(); + const assertionId = await seedSameClass(backend, { + history: false, + revisionTracking: true, + }); + + await dropTagKind(backend); + + expect(await recordedAssertionWindows(backend, assertionId)).toEqual([]); + expect(await kindDropHops(backend)).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/identity-transition-log-provisioning.test.ts b/packages/typegraph/tests/identity-transition-log-provisioning.test.ts new file mode 100644 index 000000000..95be3d68b --- /dev/null +++ b/packages/typegraph/tests/identity-transition-log-provisioning.test.ts @@ -0,0 +1,265 @@ +/** + * The identity transition log's two relations need a provisioning path for a + * database that predates them. + * + * `IDENTITY_TABLE_LOGICAL_NAMES` (identity-members.ts) and `IdentityTableNames` + * (backend/types.ts) now name `identityTransitions` / + * `identityTransitionRetention` alongside the four original identity + * relations, and base-schema release 6 (`base-schema.ts`) provisions both + * relations deployment-wide — regardless of whether any graph in the + * database has Operational Identity enabled — the same way release 2 did + * for the `fences` relation. Together they cover both places a pre-existing + * database can be missing the transition log: + * + * - an already identity-ENABLED graph reopened against a database whose + * physical schema predates this release (base-schema self-heals it + * before the identity preflight ever runs), and + * - identity's own FIRST enablement on an existing populated database + * (the enablement preflight's `ensureIdentityTables` now provisions all + * six identity relations together). + * + * Before the fix, both paths died with a raw `SqliteError: no such table: + * typegraph_identity_transitions`, surfaced as `history: true requires the + * recorded-time relations to exist` from `flushIdentityTransitions`. + */ +import type Database from "better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStore, + createStoreWithSchema, + defineGraph, + defineNode, +} from "../src"; +import { + createLocalSqliteBackend, + type LocalSqliteBackendResult, +} from "../src/backend/sqlite/local"; +import { readIdentityTransitions } from "../src/identity/transition-log"; +import { storeRuntime } from "../src/store/runtime-port"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); +const Author = defineNode("Author", { + schema: z.object({ penName: z.string() }), +}); + +const GRAPH_ID = "identity_transition_log_provisioning"; + +/** Identity already enabled — the shape a graph has BEFORE this release. */ +const enabledGraph = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person }, Author: { type: Author } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +/** Identity-disabled — the "already deployed" shape for the enablement case. */ +const disabledGraph = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person }, Author: { type: Author } }, + edges: {}, +}); + +// Alphabetical order, matching `ORDER BY name` in `transitionLogTableNames` +// below (`_retention` sorts before the bare `s` of `transitions`). +const TRANSITION_LOG_TABLES = [ + "typegraph_identity_transition_retention", + "typegraph_identity_transitions", +] as const; + +/** Every ORIGINAL (pre-transition-log) identity relation, plus the two new ones. */ +const ALL_IDENTITY_TABLES = [ + "typegraph_identity_assertions", + "typegraph_recorded_identity_assertions", + "typegraph_identity_closure", + ...TRANSITION_LOG_TABLES, +] as const; + +function rawClient(result: LocalSqliteBackendResult): Database.Database { + return (result.db as unknown as { $client: Database.Database }).$client; +} + +function dropTables( + result: LocalSqliteBackendResult, + tables: readonly string[], +): void { + for (const table of tables) { + rawClient(result).exec(`DROP TABLE IF EXISTS "${table}"`); + } +} + +function transitionLogTableNames( + result: LocalSqliteBackendResult, +): readonly string[] { + const rows = rawClient(result) + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (?, ?) ORDER BY name`, + ) + .all(...TRANSITION_LOG_TABLES) as { name: string }[]; + return rows.map((row) => row.name); +} + +describe("identity transition log provisioning", () => { + it("self-heals the transition log on an already-enabled graph whose database predates it", async () => { + const result = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + enabledGraph, + result.backend, + { + history: true, + }, + ); + // Fold a same-id pair so the write path actually exercises + // `flushIdentityTransitions`, not just schema provisioning. + await store.nodes.Person.create({ name: "Alice" }, { id: "alice" }); + await store.nodes.Author.create({ penName: "A." }, { id: "alice" }); + + // Simulate a deployment created by a release before the transition log + // shipped: its physical schema never had these two relations, even + // though identity itself is already committed and populated. The + // base-schema marker must be rolled back too — a real pre-existing + // installation would never have been stamped at the current version + // in the first place, and base-schema adoption is a NO-OP once the + // marker already reads "current". + dropTables(result, TRANSITION_LOG_TABLES); + rawClient(result).exec( + `UPDATE typegraph_base_schema_versions SET version = 2 WHERE installation = 1`, + ); + expect(transitionLogTableNames(result)).toEqual([]); + + // Reopening the SAME (already-enabled) graph must self-heal the + // missing relations via base-schema adoption before the identity + // preflight runs, rather than surfacing a raw "no such table" error + // out of `flushIdentityTransitions` the next time a fold happens. + const [reopened] = await createStoreWithSchema( + enabledGraph, + result.backend, + { history: true }, + ); + expect(transitionLogTableNames(result)).toEqual([ + ...TRANSITION_LOG_TABLES, + ]); + + await reopened.nodes.Person.create({ name: "Bob" }, { id: "bob" }); + await expect( + reopened.nodes.Author.create({ penName: "B." }, { id: "bob" }), + ).resolves.toBeDefined(); + + const ctx = storeRuntime(reopened).identityContext(); + const rows = await readIdentityTransitions( + ctx.backend, + ctx.schema, + GRAPH_ID, + { + classRefs: [ + { kind: "Person", id: "bob" }, + { kind: "Author", id: "bob" }, + ], + limit: 10, + }, + ); + expect(rows.length).toBeGreaterThan(0); + } finally { + await result.backend.close(); + } + }); + + it("names the transition-log relations when a write reaches a database that never adopted them", async () => { + const result = createLocalSqliteBackend(); + try { + await createStoreWithSchema(enabledGraph, result.backend, { + history: true, + }); + dropTables(result, TRANSITION_LOG_TABLES); + // A plain attach adopts nothing, so the first fold's capture flush is + // what reaches the missing relation. Every recorded-time relation is + // still there. + const store = createStore(enabledGraph, result.backend, { + history: true, + }); + await store.nodes.Person.create({ name: "Dana" }, { id: "dana" }); + const failure = await store.nodes.Author.create( + { penName: "D." }, + { id: "dana" }, + ).catch((error: unknown) => error); + + expect(failure).toBeInstanceOf(ConfigurationError); + const refusal = failure as ConfigurationError; + expect(refusal.details).toMatchObject({ + code: "IDENTITY_TRANSITION_RELATIONS_MISSING", + surface: "capture-flush", + relations: [ + "typegraph_identity_transitions", + "typegraph_identity_transition_retention", + ], + }); + expect(refusal.message).toMatch(/typegraph_identity_transitions/); + expect(refusal.message).not.toMatch(/recorded-time relations/); + expect(refusal.suggestion).toMatch(/adoptBaseSchema\(\)/); + } finally { + await result.backend.close(); + } + }); + + it("provisions the transition log on first enablement of an existing populated database", async () => { + const result = createLocalSqliteBackend(); + try { + // 1. Deploy the identity-disabled schema and populate a same-id pair + // across kinds (alice as both Person and Author) — the fold target + // for enablement. + const [disabledStore] = await createStoreWithSchema( + disabledGraph, + result.backend, + ); + await disabledStore.nodes.Person.create( + { name: "Alice" }, + { id: "alice" }, + ); + await disabledStore.nodes.Author.create( + { penName: "A." }, + { id: "alice" }, + ); + + // 2. Simulate a deployment whose identity relations — original AND + // transition-log — were never created (bring-your-own-connection: + // no DDL re-run) by dropping every one of them. + dropTables(result, ALL_IDENTITY_TABLES); + + // 3. Reopen with the identity-enabled graph under history:true. + // Enablement must provision all SIX identity relations together — + // including the transition log — not just the original four. + const [enabledStore, migration] = await createStoreWithSchema( + enabledGraph, + result.backend, + { history: true }, + ); + expect(migration.status).toBe("migrated"); + expect(transitionLogTableNames(result)).toEqual([ + ...TRANSITION_LOG_TABLES, + ]); + + const members = await enabledStore.identity.membersOf({ + kind: "Person", + id: "alice", + }); + expect(members).toHaveLength(2); + + // Whether enablement itself notes a `schema-transition` transition is + // a separate concern (tests/identity-transition-log.test.ts); + // this test only pins that the relations exist and the write path + // (exercised here by an ordinary post-enablement fold) does not throw. + await enabledStore.nodes.Person.create( + { name: "Carol" }, + { id: "carol" }, + ); + await expect( + enabledStore.nodes.Author.create({ penName: "C." }, { id: "carol" }), + ).resolves.toBeDefined(); + } finally { + await result.backend.close(); + } + }); +}); diff --git a/packages/typegraph/tests/identity-transition-log-ratchet.test.ts b/packages/typegraph/tests/identity-transition-log-ratchet.test.ts new file mode 100644 index 000000000..692e698aa --- /dev/null +++ b/packages/typegraph/tests/identity-transition-log-ratchet.test.ts @@ -0,0 +1,332 @@ +/** + * The condition on the identity transition log's "no-log + * line": *a ratchet test asserts no code path reads membership from + * `typegraph_identity_transitions`.* + * + * The invariant is stated directly: "a transition row + * carries no membership... every membership answer in the replay API is + * produced by calling `historicalIdentityReconstructionCtes`". That is + * enforced by construction inside `src/identity/replay.ts` (see its own + * top-of-file docblock), but nothing previously stopped a FUTURE writer + * elsewhere in `src/**` from reaching into the relation directly — through + * `SqlSchema.identityTransitionsTable`, a Drizzle table object's + * `identityTransitions` field, or the raw `"typegraph_identity_transitions"` + * string — and deriving a membership answer from it. This file is that + * stop: it scans every `.ts` file under `src/` for a reference to the + * relation and fails on any file outside `MODULE_ALLOWLIST`, in both + * directions (an allowlisted file whose reference disappeared is stale and + * must be removed, so the list stays an honest map of the tree rather than + * a rubber stamp). + * + * The allowlist is MODULE-level (a file may reference the relation more than + * once — write plumbing, an index, a doc comment — for one already-reviewed + * reason), matching how the invariant itself is phrased ("no CODE PATH reads + * membership"): a module either legitimately touches the physical relation + * (as a writer, a reader, a DDL/schema declaration, or the whole-graph + * `clear()` sweep) or it does not. + * + * *Mutation*: add a `readIdentityTransitions(...)`-shaped call — or any + * bare reference to `identityTransitionsTable` / `tables.identityTransitions` + * / the raw table name — to a module not on `MODULE_ALLOWLIST` (for example, + * reintroduce the fold-vs-restore probe that was removed from + * `service-maintenance.ts`) → the "undeclared" assertion fails, + * naming the file. *Mutation*: remove the ONE writer (`flush.ts`) from the + * allowlist while its INSERT still stands → also fails "undeclared" (the + * file is unnamed but the reference still exists). *Mutation*: delete an + * allowlisted file's only reference (e.g. drop the relation from + * `graph-relations.ts`) while its entry survives → the "stale" assertion fails, naming + * the entry. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../src", +); + +/** + * Matches the SqlSchema field (`identityTransitionsTable`) and the bare + * table-name identifier (`identityTransitions`, as in + * `tables.identityTransitions` or the `DEFAULT_TABLE_NAMES` key) — but NOT + * `identityTransitionsOf` (the replay-facing function — see + * {@link READ_TRANSITIONS_REFERENCE} for the relation access IT makes, one + * level down through `readIdentityTransitions`) and NOT + * `identityTransitionRetentionTable` / `identityTransitionRetention` (the + * SEPARATE, singular-"Transition" retention-watermark relation, which is + * out of this ratchet's scope). The raw string literal + * `"typegraph_identity_transitions"` is not scanned for separately: every + * site that carries it today does so as this identifier's value, on the + * same line. + */ +const TRANSITION_LOG_REFERENCE = /\bidentityTransitions(Table)?\b/; + +/** + * Matches the relation's ONE read primitive by name — a module can reach the + * relation's ROWS through this call without ever naming + * `identityTransitionsTable` or `tables.identityTransitions` itself (a + * probe appending a `membersFromLog(rows)`-shaped helper to + * `service-read.ts`, deriving membership from rows a caller already fetched + * via `readIdentityTransitions`, is exactly this shape). + */ +const READ_TRANSITIONS_REFERENCE = /\breadIdentityTransitions\b/; + +/** + * Matches an import statement pulling ANYTHING from the transition-log + * module, regardless of which name it imports — a backstop against a + * renamed re-export (`export { readIdentityTransitions as X }`) slipping a + * membership-capable import past {@link READ_TRANSITIONS_REFERENCE}'s bare + * identifier match. Every current import from this module (types, the + * `diffClosureTransitions` predicate, the one writer) is reviewed and + * allowlisted below; a NEW import path match is a new module to review, not + * necessarily a violation. + */ +const TRANSITION_LOG_IMPORT_PATH = + /from ["'](?:\.\/|(?:\.\.\/)+identity\/)transition-log["']/; + +function referencesTransitionLog(line: string): boolean { + return ( + TRANSITION_LOG_REFERENCE.test(line) || + READ_TRANSITIONS_REFERENCE.test(line) || + TRANSITION_LOG_IMPORT_PATH.test(line) + ); +} + +type AllowedModule = Readonly<{ + /** Path relative to `packages/typegraph/src`. */ + file: string; + /** Why this module legitimately references the relation. Mandatory. */ + reason: string; +}>; + +const MODULE_ALLOWLIST: readonly AllowedModule[] = [ + { + file: "identity/transition-log.ts", + reason: + "The relation's one owner: the row shape, the encoder, readIdentityTransitions (SELECT — boundaries and explanations only, never membership), and pruneIdentityTransitionsForContext (DELETE, retention).", + }, + { + file: "store/recorded-capture/flush.ts", + reason: + "The ONE writer: flushIdentityTransitions INSERTs the buffered notes against the recorded commit the same flush allocates for every other recorded relation.", + }, + { + file: "backend/graph-relations.ts", + reason: + "The graph-relation inventory declares the relation's key and how store.clear(), a namespace fork, a working-copy clone and the storage inventory treat its rows — whole-relation sweeps, copies and counts, never a membership read.", + }, + { + file: "backend/drizzle/schema/postgres-table-names.ts", + reason: + "The bundled PostgreSQL table names declare the relation's default physical name — structural schema wiring, not a read.", + }, + { + file: "query/compiler/schema.ts", + reason: + "SqlSchema / SqlTableNames declare the `identityTransitionsTable` fragment and the `identityTransitions` default table name — structural schema wiring, not a read.", + }, + { + file: "backend/drizzle/schema/sqlite.ts", + reason: + "SqliteTableNames / DEFAULT_TABLE_NAMES name the relation for DDL generation.", + }, + { + file: "backend/drizzle/schema/postgres.ts", + reason: + "The Drizzle `pgTable` definition (columns, PK, the three indexes) and the default table name.", + }, + { + file: "backend/drizzle/sqlite.ts", + reason: + "Resolves the configured/default table name into the SQLite backend's `SqlTableNames`, mirroring every other relation.", + }, + { + file: "backend/drizzle/postgres.ts", + reason: + "Resolves the configured/default table name into the PostgreSQL backend's `SqlTableNames`, mirroring every other relation.", + }, + { + file: "backend/types.ts", + reason: + "IdentityTableNames names the relation as one of the six Operational Identity table-name fields a backend port speaks about — structural, not a read.", + }, + { + file: "backend/drizzle/engine/members/identity-members.ts", + reason: + "IDENTITY_TABLE_LOGICAL_NAMES scopes ensureIdentityTables/identityTableDdl's DDL provisioning to include the relation — DDL as data, never executed here and never a membership read.", + }, + { + file: "identity/schema-transition.ts", + reason: + "identityTableNames() resolves the relation's configured physical name into the IdentityTableNames struct handed to the DDL-provisioning ports above — structural wiring, not a read.", + }, + { + file: "identity/replay.ts", + reason: + "The one consumer of readIdentityTransitions: reads rows for boundary/cause/provenance data ONLY — every membership answer it returns comes from historicalIdentityReconstructionCtes instead (its own top-of-file docblock), structurally pinned by tests/identity-replay.test.ts (L3).", + }, + { + file: "identity/service-mutation.ts", + reason: + "Imports diffClosureTransitions — the pure before/after class-diff predicate, not the relation — to compute the record a caller's own noteTransition then writes.", + }, + { + file: "identity/service-maintenance.ts", + reason: + "Imports diffClosureTransitions for the same reason as service-mutation.ts — a pure predicate over closure snapshots, never the relation's rows.", + }, + { + file: "identity/service-interchange-write.ts", + reason: + "Imports the IdentityDecisionProvenance type to type a governed-apply's decision metadata before handing it to noteTransition, and importIdentityTransitionsIntoTarget's archival restore, which calls transition-log.ts's own encodeIdentityTransitionRow / insertIdentityTransitionValues / writeIdentityTransitionRetentionWatermark / hasNativeIdentityTransitionsBefore to write and check rows verbatim — delegating to the relation's one owner, never a second inline write, and never a membership read.", + }, + { + file: "store/recorded-capture.ts", + reason: + "Imports IdentityDecisionProvenance / IdentityTransitionDraft / IdentityTransitionNote — the touch/noteTransition callback types the capture session's public surface is typed against. The one writer (flush.ts) INSERTs; this file only buffers notes callers hand it.", + }, + { + file: "store/runtime-port.ts", + reason: + "Imports the IdentityDecisionProvenance type to declare the optional `decision` parameter graph-merge's identity apply threads through applyIdentityMergeAtTarget, and IdentityTransitionCursor / IdentityTransitionTransfer to type the archival readIdentityTransitionPageAtTarget / importIdentityTransitionsAtTarget port members — every one a port signature, no relation access.", + }, + { + file: "store/store.ts", + reason: + "Implements the identityContext-adjacent port methods against the IdentityDecisionProvenance-typed decision parameter, and the three archival-transitions port methods (readIdentityTransitionPageAtTarget, identityTransitionRetentionAtTarget, importIdentityTransitionsAtTarget), which call transition-log.ts's own reader/writer functions — delegating, never re-implementing. `flushed.identityTransitions` in transactionOutcome is a COUNT off RecordedGraphFlushResult (how many notes this flush wrote), not a relation read — matched by this scanner's regex on the field name alone.", + }, + { + file: "identity/index.ts", + reason: + "Re-exports the IdentityDecisionProvenance type onto the identity barrel (and from there the package barrel), because a backend or store author implementing applyIdentityMergeAtTarget needs the decision shape by name — a type re-export, no relation access.", + }, + { + file: "graph-merge/typegraph-internal.ts", + reason: + "Re-exports the IdentityDecisionProvenance type through graph-merge's one seam onto the rest of the package, so the merge builds its decision against the owner's shape instead of re-spelling it — a type re-export, no relation access.", + }, + { + file: "store/recorded-capture/relations.ts", + reason: + "Imports IDENTITY_TRANSITION_COLUMN_NAMES for its .length only, to derive the per-statement chunk size flush.ts binds — column-count arithmetic, never a row.", + }, + { + file: "identity/service.ts", + reason: + "Re-exports transition-log.ts's readIdentityTransitionPageForInterchange / readTransitionRetentionDetails / toTransitionTransfer functions and the IdentityTransitionCursor / IdentityTransitionTransfer types onto the internal identity-service barrel store.ts imports from — the archival interchange boundary's own read functions, delegating to the one owner, never a second reader.", + }, + { + file: "interchange/import.ts", + reason: + "Imports identityTransitionLogUnavailableError to raise the exact typed refusal an archival transitions/retention restore into a target without a TypeGraph transition log gets, upfront (assertIdentityTransitionsRestoreSupported), matching store.ts's backstop of the same error — never a relation read.", + }, + { + file: "interchange/export.ts", + reason: + "Archival export pages storeRuntime(store).readIdentityTransitionPageAtTarget (including a limit-1 existence probe for the streaming header's `hasTransitions` flag) and accumulates the resulting EXPLANATION rows into the exported identity.transitions array — an export of the log's own rows for interchange, not a membership derivation. Also imports the IdentityTransitionCursor / IdentityTransitionTransfer types only, to type the paging cursor and widen the wire shape's readonly arrays.", + }, +]; + +type FoundSite = Readonly<{ file: string; lineNumber: number; line: string }>; + +function collectTypeScriptFiles(directory: string): readonly string[] { + const found: string[] = []; + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const full = path.join(directory, entry.name); + if (entry.isDirectory()) { + found.push(...collectTypeScriptFiles(full)); + } else if (entry.name.endsWith(".ts")) { + found.push(full); + } + } + return found; +} + +function scanForTransitionLogReferences(): readonly FoundSite[] { + const sites: FoundSite[] = []; + for (const file of collectTypeScriptFiles(SOURCE_ROOT)) { + const lines = fs.readFileSync(file, "utf8").split("\n"); + for (const [index, line] of lines.entries()) { + if (!referencesTransitionLog(line)) continue; + sites.push({ + file: path.relative(SOURCE_ROOT, file).replaceAll(path.sep, "/"), + lineNumber: index + 1, + line: line.trim(), + }); + } + } + return sites; +} + +describe("identity transition log module ratchet", () => { + const sites = scanForTransitionLogReferences(); + const filesWithReferences = new Set(sites.map((site) => site.file)); + const allowedFiles = new Set(MODULE_ALLOWLIST.map((entry) => entry.file)); + + it("references the relation only from an allowlisted module", () => { + const undeclared = [...filesWithReferences].filter( + (file) => !allowedFiles.has(file), + ); + if (undeclared.length > 0) { + const reported = undeclared.flatMap((file) => + sites + .filter((site) => site.file === file) + .map((site) => `${site.file}:${site.lineNumber} ${site.line}`), + ); + throw new Error( + `A module outside MODULE_ALLOWLIST references the identity transition log:\n\n${reported.join("\n")}\n\n` + + `Membership must never be derived from typegraph_identity_transitions — every membership answer comes from historicalIdentityReconstructionCtes (see src/identity/replay.ts). ` + + `If this reference does not read membership (a writer, a DDL/schema declaration, or a whole-graph clear/prune sweep), add it to MODULE_ALLOWLIST in this file with a one-line reason.`, + ); + } + expect(undeclared).toEqual([]); + }); + + it("has no stale allowlist entry", () => { + const stale = MODULE_ALLOWLIST.filter( + (entry) => !filesWithReferences.has(entry.file), + ); + if (stale.length > 0) { + throw new Error( + `These allowlist entries no longer reference the identity transition log — the code moved or was removed. Delete them:\n\n${stale.map((entry) => entry.file).join("\n")}`, + ); + } + expect(stale).toEqual([]); + }); + + it("requires a reason on every allowlist entry", () => { + const missing = MODULE_ALLOWLIST.filter( + (entry) => entry.reason.trim().length < 20, + ); + expect(missing.map((entry) => entry.file)).toEqual([]); + }); + + it("does not flag identityTransitionsOf, the retention relation, or a Set named `live`", () => { + // The scanner is itself load-bearing: it must distinguish the + // replay-facing function name and the SEPARATE retention relation from a + // genuine reference to typegraph_identity_transitions. + expect( + TRANSITION_LOG_REFERENCE.test("identityTransitionsOf(ctx, ref)"), + ).toBe(false); + expect( + TRANSITION_LOG_REFERENCE.test( + "await pruneIdentityTransitionsForContext(ctx, options)", + ), + ).toBe(false); + expect( + TRANSITION_LOG_REFERENCE.test( + "identityTransitionRetentionTable: SqlFragment;", + ), + ).toBe(false); + // ...and it DOES catch the shapes it exists to catch. + expect( + TRANSITION_LOG_REFERENCE.test("ctx.schema.identityTransitionsTable"), + ).toBe(true); + expect(TRANSITION_LOG_REFERENCE.test("tables.identityTransitions")).toBe( + true, + ); + }); +}); diff --git a/packages/typegraph/tests/identity-transition-log.test.ts b/packages/typegraph/tests/identity-transition-log.test.ts new file mode 100644 index 000000000..e9558b297 --- /dev/null +++ b/packages/typegraph/tests/identity-transition-log.test.ts @@ -0,0 +1,1098 @@ +/** + * The transition log's write plumbing: one note per structural cause, buffered + * through recorded-capture checkpoint/restore exactly like every other touch, + * sealed with the session, and absent entirely with `history: false`. + * + * `readIdentityTransitions` reads by module path directly — it is the + * internal reader `identityTransitionsOf` (`replay.ts`) and the public + * `store.identity.transitionsOf` both walk through, not a second copy of + * the query. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + ConfigurationError, + createAdapterStoreWithSchema, + createStoreWithSchema, + defineGraph, + defineGraphExtension, + defineNode, + type GraphDef, +} from "../src"; +import { + batchPointReadVerdict, + statementExecutionVerdict, +} from "../src/backend/capabilities/resolve"; +import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; +import { + type RecordedInstant, + recordedInstantRevision, +} from "../src/core/temporal"; +import { identityReferenceKey } from "../src/identity/reference"; +import { applyIdentityChangesForContext } from "../src/identity/service-interchange-write"; +import { type IdentityServiceContext } from "../src/identity/service-types"; +import { type PlainNodeRef } from "../src/identity/sql-target"; +import { + diffClosureTransitions, + type IdentityTransitionRow, + pruneIdentityTransitions, + pruneIdentityTransitionsForContext, + readIdentityTransitions, + readTransitionRetentionDetails, + transitionAssertionIds, + writeIdentityTransitionRetentionWatermark, +} from "../src/identity/transition-log"; +import { createSqlSchema } from "../src/query/compiler/schema"; +import { sql } from "../src/query/sql-fragment"; +import { asCompiledStatementSql } from "../src/query/sql-intent"; +import { + createRecordedTransactionScope, + hasPendingIdentityTransitionNotes, + runRecordedTransactionSavepoint, + withRecordedIdentityMutationTarget, +} from "../src/store/recorded-capture"; +import { storeRuntime } from "../src/store/runtime-port"; +import { generateId } from "../src/utils/id"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), +}); + +const graph = defineGraph({ + id: "identity_transition_log", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, +}); + +const PERSON_CLASS_REFS = [ + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + { kind: "Person", id: "c" }, +]; + +function requireRecordedNow( + recordedNow: RecordedInstant | undefined, +): RecordedInstant { + if (recordedNow === undefined) { + throw new Error("expected a recorded instant"); + } + return recordedNow; +} + +function readTransitions( + ctx: IdentityServiceContext, + classReferences: readonly Readonly<{ + kind: string; + id: string; + }>[] = PERSON_CLASS_REFS, +) { + return readIdentityTransitions(ctx.backend, ctx.schema, ctx.graphId, { + classRefs: classReferences, + limit: 200, + }); +} + +/** Each `cause` row as `class <- prior` (`<- -` for "no prior class"), sorted, so a test pins the EXACT record set. */ +function transitionHops( + rows: readonly IdentityTransitionRow[], + cause: IdentityTransitionRow["cause"], +): readonly string[] { + return rows + .filter((row) => row.cause === cause) + .map((row) => { + const prior = + row.prior_class_kind === undefined ? + "-" + : `${row.prior_class_kind}:${row.prior_class_id}`; + return `${row.class_kind}:${row.class_id} <- ${prior}`; + }) + .toSorted(); +} + +describe("identity transition log", () => { + it("notes an assert transition when assertSame fuses two singletons", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx); + const assertRows = rows.filter((row) => row.cause === "assert"); + expect(assertRows.length).toBeGreaterThanOrEqual(1); + expect(assertRows[0]?.assertion_ids.length).toBe(1); + }); + + // Load-bearing: `scope.after` keyset-pages `readIdentityTransitions` past a + // prior page's last row — this is what lets `walkClassLineage` (replay.ts) + // read a fixed-point round to exhaustion via many small pages instead of + // one read capped at a fixed ceiling. Mutation check: change the cursor + // filter's `recorded_revision > ${scope.after.recordedRevision}` to `>=` + // (transition-log.ts) and this fails: the second page re-includes the + // first page's own last row (and any earlier same-revision row), so + // `collected` gains a duplicate the equality check catches before the + // bounded round guard below would otherwise mask an infinite loop as a + // length mismatch. + it("keyset-pages past scope.after, converging to the same rows a single unbounded read returns", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const [firstAssertion] = await store.identity.assertionsOf({ + kind: "Person", + id: "a", + }); + if (firstAssertion === undefined) throw new Error("expected an assertion"); + await store.identity.retractAssertion(firstAssertion.id); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + + const ctx = storeRuntime(store).identityContext(); + const everything = await readTransitions(ctx); + expect(everything.length).toBeGreaterThanOrEqual(3); + + const pageSize = 1; + const collected: IdentityTransitionRow[] = []; + let after: + Readonly<{ recordedRevision: number; transitionId: string }> | undefined; + // Bounded by `everything.length + 1` rounds: a correct pager finishes in + // exactly `everything.length` rounds (one row per page), so one extra + // round of slack still turns a broken, non-advancing cursor into a + // length/content mismatch below rather than an unbounded loop. + for (let round = 0; round <= everything.length; round += 1) { + const page = await readIdentityTransitions( + ctx.backend, + ctx.schema, + ctx.graphId, + { + classRefs: PERSON_CLASS_REFS, + limit: pageSize, + ...(after === undefined ? {} : { after }), + }, + ); + collected.push(...page); + if (page.length < pageSize) break; + const last = page.at(-1); + if (last === undefined) break; + after = { + recordedRevision: last.recorded_revision, + transitionId: last.transition_id, + }; + } + + expect(collected.map((row) => row.transition_id)).toEqual( + everything.map((row) => row.transition_id), + ); + }); + + it("notes a retract transition when a same assertion is retracted", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + const asserted = await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + await store.identity.retractAssertion(asserted.assertion.id); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx); + const retractRows = rows.filter((row) => row.cause === "retract"); + // A 2-member class fully dissolving into two singletons produces TWO + // records — one per departing member, since NEITHER retains the other as + // a class-mate to carry the reverse-lineage hop (see diffClosureTransitions' + // same-canonical-but-shrunk-membership rule, proven load-bearing by the + // exhaustiveness property test). Every record still names the retracted + // assertion. + expect(retractRows.length).toBe(2); + for (const row of retractRows) { + expect(row.assertion_ids).toEqual([asserted.assertion.id]); + } + }); + + it("notes a fold transition for a same-id cross-kind create, and a restore transition on resurrection", async () => { + const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + const foldGraph = defineGraph({ + id: "identity_transition_log_fold", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + foldGraph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "shared" }); + await store.nodes.Org.create({ name: "A Org" }, { id: "shared" }); + const ctx = storeRuntime(store).identityContext(); + const sharedReferences = [ + { kind: "Person", id: "shared" }, + { kind: "Org", id: "shared" }, + ]; + const rows = await readTransitions(ctx, sharedReferences); + // Two singletons fold into one class labelled by its code-point-least + // member: one record, neither side having had a prior (>=2 member) class. + expect(transitionHops(rows, "fold")).toEqual(["Org:shared <- -"]); + + await store.nodes.Org.delete(asNodeId("shared")); + await store.nodes.Org.create({ name: "A Org 2" }, { id: "shared" }); + const afterRestore = await readTransitions(ctx, sharedReferences); + const restoreRows = afterRestore.filter((row) => row.cause === "restore"); + expect(restoreRows.length).toBeGreaterThanOrEqual(1); + }); + + // Load-bearing: `replaceAffectedClosure` diffs its recomputed classes BY + // MEMBER. Handing that diff the root-keyed distinct components instead + // (`buildDistinctComponents` without `indexComponentsByMember`) finds a new + // class for the union-find root only, so every other member's record is + // silently skipped — here the `Person:c` class's absorption into `Person:a`. + it("records every absorbed class when bulkAssertSame fuses two existing classes", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + for (const id of ["a", "b", "c", "d"]) { + await store.nodes.Person.create({ name: id }, { id }); + } + await store.identity.bulkAssertSame([ + { a: { kind: "Person", id: "a" }, b: { kind: "Person", id: "b" } }, + { a: { kind: "Person", id: "c" }, b: { kind: "Person", id: "d" } }, + ]); + const ctx = storeRuntime(store).identityContext(); + const references = ["a", "b", "c", "d"].map((id) => ({ + kind: "Person", + id, + })); + const beforeFuse = await readTransitions(ctx, references); + expect(transitionHops(beforeFuse, "assert")).toEqual([ + "Person:a <- -", + "Person:c <- -", + ]); + + await store.identity.bulkAssertSame([ + { a: { kind: "Person", id: "b" }, b: { kind: "Person", id: "c" } }, + ]); + const afterFuse = await readTransitions(ctx, references); + // `Person:a` survives as the label but its member set grew (a + // self-referential record), and the `Person:c` class became `Person:a`. + expect(transitionHops(afterFuse, "assert")).toEqual([ + "Person:a <- -", + "Person:a <- Person:a", + "Person:a <- Person:c", + "Person:c <- -", + ]); + }); + + it("records the absorbed class when a same-id create folds into an existing multi-member class", async () => { + const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + const foldGraph = defineGraph({ + id: "identity_transition_log_fold_class", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + foldGraph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "shared" }); + await store.nodes.Person.create({ name: "Z" }, { id: "z" }); + await store.identity.assertSame( + { kind: "Person", id: "shared" }, + { kind: "Person", id: "z" }, + ); + await store.nodes.Org.create({ name: "A Org" }, { id: "shared" }); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx, [ + { kind: "Org", id: "shared" }, + { kind: "Person", id: "shared" }, + { kind: "Person", id: "z" }, + ]); + expect(transitionHops(rows, "fold")).toEqual([ + "Org:shared <- -", + "Org:shared <- Person:shared", + ]); + }); + + it("notes a detach transition when a member of a class is soft-deleted", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + const asserted = await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + await store.nodes.Person.delete(asNodeId("a")); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx); + const detachRows = rows.filter((row) => row.cause === "detach"); + expect(detachRows.length).toBeGreaterThanOrEqual(1); + for (const row of detachRows) { + expect(row.assertion_ids).toEqual([asserted.assertion.id]); + } + }); + + it("checkpoints and restores buffered identity-transition notes exactly like every other touch", async () => { + // `noteIdentityTransition` "buffers alongside `touched`, is included + // in `checkpoint()` / `restore()` exactly as `touched` is". The only + // shipped rollback seam over a capture session is + // `runRecordedTransactionSavepoint` (used today by the archival import's + // per-edge retry, which never touches identity) — so this drives it + // directly, through `createRecordedTransactionScope` (both exported for + // exactly this kind of capture-session test; see + // `recorded-capture-write-parity.test.ts`), to prove a note taken inside + // a savepoint that rolls back never reaches the flushed table, while one + // taken inside a savepoint that releases does. + const backend = createTestBackend(); + const statementExecution = statementExecutionVerdict(backend); + expect(statementExecution.supported).toBe(true); + if (!statementExecution.supported) return; + const schema = createSqlSchema(backend.tableNames); + const graphId = "identity_transition_log_checkpoint"; + const rolledBackDraft = { + cause: "fold", + classRef: { kind: "Person", id: "rolled-back" }, + assertionIds: [], + validAt: new Date().toISOString(), + } as const; + const releasedDraft = { + cause: "fold", + classRef: { kind: "Person", id: "released" }, + assertionIds: [], + validAt: new Date().toISOString(), + } as const; + + await backend.transaction(async (target) => { + const scope = createRecordedTransactionScope( + target, + batchPointReadVerdict(backend), + schema, + ); + await runRecordedTransactionSavepoint( + scope.backend, + statementExecution, + "typegraph_identity_transition_checkpoint_test", + async () => { + await withRecordedIdentityMutationTarget( + scope.backend, + (_rawTarget, _touch, noteTransition) => { + noteTransition(graphId, rolledBackDraft); + return Promise.resolve(); + }, + ); + return { + action: "rollback", + value: undefined, + cause: new Error("expected test rollback"), + }; + }, + ); + await withRecordedIdentityMutationTarget( + scope.backend, + (_rawTarget, _touch, noteTransition) => { + noteTransition(graphId, releasedDraft); + return Promise.resolve(); + }, + ); + await scope.flush(); + }); + + const rows = await readIdentityTransitions(backend, schema, graphId, { + classRefs: [rolledBackDraft.classRef, releasedDraft.classRef], + limit: 200, + }); + expect(rows.map((row) => row.class_id)).toEqual(["released"]); + }); + + it("reports pending identity notes only for the graph that took them", async () => { + // Every public path runs one store's writes per capture session, so two + // graphs' notes meet in one session only through the session API + // itself; this drives it directly to pin the per-graph answer. + const backend = createTestBackend(); + const schema = createSqlSchema(backend.tableNames); + const notedGraphId = "identity_transition_log_pending_noted"; + const otherGraphId = "identity_transition_log_pending_other"; + + await backend.transaction(async (target) => { + const scope = createRecordedTransactionScope( + target, + batchPointReadVerdict(backend), + schema, + ); + expect( + hasPendingIdentityTransitionNotes(scope.backend, notedGraphId), + ).toBe(false); + await withRecordedIdentityMutationTarget( + scope.backend, + (_rawTarget, _touch, noteTransition) => { + noteTransition(notedGraphId, { + cause: "fold", + classRef: { kind: "Person", id: "pending" }, + assertionIds: [], + validAt: new Date().toISOString(), + }); + return Promise.resolve(); + }, + ); + const pending = { + noted: hasPendingIdentityTransitionNotes(scope.backend, notedGraphId), + other: hasPendingIdentityTransitionNotes(scope.backend, otherGraphId), + }; + console.log("pending notes by graph:", pending); + expect(pending).toEqual({ noted: true, other: false }); + await scope.flush(); + }); + }); + + it("throws the sealed-session ConfigurationError for a note taken after flush", async () => { + // `noteIdentityTransition` "throws the same already-sealed + // `ConfigurationError` after `flush`" — `noteIdentityTransition` + // (`RecordedCaptureSession`) carries its own copy of that check (with + // `{ entity: "identity-transition", graphId }` details), but + // `withRecordedIdentityMutationTarget` calls `session.assertOpen()` + // BEFORE handing the caller its `noteTransition` callback, so a + // synchronous note-after-flush attempt through the shipped seam observes + // that generic sealed error first — the SAME error class and message the + // design promises, whichever of the two checks actually fires. + const backend = createTestBackend(); + const statementExecution = statementExecutionVerdict(backend); + expect(statementExecution.supported).toBe(true); + if (!statementExecution.supported) return; + const schema = createSqlSchema(backend.tableNames); + const graphId = "identity_transition_log_sealed"; + + await backend.transaction(async (target) => { + const scope = createRecordedTransactionScope( + target, + batchPointReadVerdict(backend), + schema, + ); + await scope.flush(); + let caught: unknown; + try { + await withRecordedIdentityMutationTarget( + scope.backend, + (_rawTarget, _touch, noteTransition) => { + noteTransition(graphId, { + cause: "fold", + classRef: { kind: "Person", id: "too-late" }, + assertionIds: [], + validAt: new Date().toISOString(), + }); + return Promise.resolve(); + }, + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).message).toMatch(/sealed/i); + }); + }); + + it("writes zero transition rows with history: false", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: false }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx, [{ kind: "Person", id: "a" }]); + expect(rows.length).toBe(0); + }); + + it("a rebuild writes no transition and advances no revision; a prune writes none and ALSO advances no revision", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const ctx = storeRuntime(store).identityContext(); + const before = await readTransitions(ctx); + const beforeRebuildRevision = recordedInstantRevision( + requireRecordedNow(await store.recordedNow()), + ); + await storeRuntime(store).rebuildIdentityClosure(); + const afterRebuild = await readTransitions(ctx); + expect(afterRebuild.length).toBe(before.length); + // A rebuild follows the SAME non-advancing contract as a prune: it + // recomputes derived state from unchanged truth and never advances the + // content revision — pinned here in the same direction pruning is pinned + // below, so a regression that starts advancing EITHER one fails loudly. + expect( + recordedInstantRevision(requireRecordedNow(await store.recordedNow())), + ).toBe(beforeRebuildRevision); + + // beforeRecorded prunes rows strictly BEFORE that revision, so the + // watermark must be advanced past the assert's own commit — one more + // write, then read the new high-water mark. + await store.nodes.Person.create({ name: "C" }, { id: "c" }); + const recordedNow = requireRecordedNow(await store.recordedNow()); + const revisionBeforePrune = recordedInstantRevision(recordedNow); + const pruneResult = await pruneIdentityTransitionsForContext(ctx, { + beforeRecorded: recordedNow, + }); + expect(pruneResult.pruned).toBe(before.length); + const afterPrune = await readTransitions(ctx); + expect(afterPrune.length).toBe(0); + + // The binding contract (it supersedes an earlier draft of the + // same design): "a prune does NOT advance the content revision (same + // as rebuildIdentityClosure)." Pinned in the SAME direction as the + // rebuild above — a prune that started advancing the revision would fail + // here, exactly as a rebuild that started would fail above. + const revisionAfterPrune = recordedInstantRevision( + requireRecordedNow(await store.recordedNow()), + ); + expect(revisionAfterPrune).toBe(revisionBeforePrune); + + // The OTHER direction of the pin: an ORDINARY write, unlike either + // maintenance operation, unambiguously DOES advance the revision — proof + // this assertion methodology can actually detect an advance, so the two + // "no advance" checks above are not vacuously trivial. + await store.nodes.Person.create({ name: "D" }, { id: "d" }); + const revisionAfterOrdinaryWrite = recordedInstantRevision( + requireRecordedNow(await store.recordedNow()), + ); + expect(revisionAfterOrdinaryWrite).toBeGreaterThan(revisionAfterPrune); + }); + + it("pruneIdentityTransitions(store, options) — the Store-based public shape — prunes through the same path", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + await store.nodes.Person.create({ name: "C" }, { id: "c" }); + const recordedNow = await store.recordedNow(); + if (recordedNow === undefined) { + throw new Error("expected a recorded instant"); + } + const pruneResult = await pruneIdentityTransitions(store, { + beforeRecorded: recordedNow, + }); + expect(pruneResult.pruned).toBeGreaterThanOrEqual(1); + const ctx = storeRuntime(store).identityContext(); + const afterPrune = await readTransitions(ctx); + expect(afterPrune.length).toBe(0); + }); + + it("the retention watermark writer ignores a watermark at or below the stored one", async () => { + // Prune raises its own request to the stored watermark under the graph + // lock, so only this writer's guard stands between a lower write from any + // other caller (archival restore, a racing session) and a lowered cut. + const backend = createTestBackend(); + const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + const ctx = storeRuntime(store).identityContext(); + const stored = () => + readTransitionRetentionDetails(backend, ctx.schema, ctx.graphId); + + await writeIdentityTransitionRetentionWatermark( + backend, + ctx.schema, + ctx.graphId, + 7, + "2026-01-01T00:00:00.000Z", + ); + await writeIdentityTransitionRetentionWatermark( + backend, + ctx.schema, + ctx.graphId, + 3, + "2026-02-01T00:00:00.000Z", + ); + await writeIdentityTransitionRetentionWatermark( + backend, + ctx.schema, + ctx.graphId, + 7, + "2026-03-01T00:00:00.000Z", + ); + expect(await stored()).toEqual({ + prunedBeforeRevision: 7, + prunedAt: "2026-01-01T00:00:00.000Z", + }); + + await writeIdentityTransitionRetentionWatermark( + backend, + ctx.schema, + ctx.graphId, + 9, + "2026-04-01T00:00:00.000Z", + ); + expect(await stored()).toEqual({ + prunedBeforeRevision: 9, + prunedAt: "2026-04-01T00:00:00.000Z", + }); + }); + + it("notes a window-end transition when a class member's validity window is narrowed", async () => { + // Fold-based class, not an explicit assertion: an assertion's own valid-time + // window would have to end BEFORE the node's proposed validTo, or + // requireNodeValidityEndCompatible refuses the write outright (correctly — + // that refusal is the guard this note site sits behind). Same-id folding + // carries no assertion window to conflict with. + const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + const foldGraph = defineGraph({ + id: "identity_transition_log_window_end", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + foldGraph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "shared" }); + await store.nodes.Org.create({ name: "A Org" }, { id: "shared" }); + const farFuture = new Date(Date.now() + 1_000_000_000).toISOString(); + await store.nodes.Person.update( + asNodeId("shared"), + {}, + { validTo: farFuture }, + ); + const ctx = storeRuntime(store).identityContext(); + const rows = await readTransitions(ctx, [ + { kind: "Person", id: "shared" }, + { kind: "Org", id: "shared" }, + ]); + const windowEndRows = rows.filter((row) => row.cause === "window-end"); + expect(windowEndRows.length).toBeGreaterThanOrEqual(1); + }); + + // Load-bearing: reopening a window moves the membership boundary back just + // as narrowing it did, so every update path that can carry `clearValidTo` or + // `validTo` notes it through the one identity window-end owner. Revert + // check: skip the owner for a cleared end (the old `validTo !== undefined` + // gate) and the reopen notes nothing; drop its call from the batch upsert + // path and the bulk narrowing notes nothing. + it("notes a window-end transition for every move of a class member's window end, including a cleared one", async () => { + const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + const foldGraph = defineGraph({ + id: "identity_transition_log_window_reopen", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + foldGraph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "shared" }); + await store.nodes.Org.create({ name: "A Org" }, { id: "shared" }); + const orgRef = { kind: "Org", id: "shared" } as const; + async function windowEndCount(): Promise { + const history = await store.identity.transitionsOf(orgRef); + return history.transitions.filter( + (transition) => transition.cause === "window-end", + ).length; + } + const farFuture = new Date(Date.now() + 1_000_000_000).toISOString(); + + await store.nodes.Person.update( + asNodeId("shared"), + {}, + { validTo: farFuture }, + ); + expect(await windowEndCount()).toBe(1); + + await store.nodes.Person.update( + asNodeId("shared"), + {}, + { clearValidTo: true }, + ); + expect(await windowEndCount()).toBe(2); + + // Already open: nothing moved, so no boundary is manufactured. + await store.nodes.Person.upsertById( + "shared", + { name: "A" }, + { clearValidTo: true }, + ); + expect(await windowEndCount()).toBe(2); + + await store.nodes.Person.bulkUpsertById([ + { id: "shared", props: { name: "A" }, validTo: farFuture }, + ]); + expect(await windowEndCount()).toBe(3); + + await store.nodes.Person.bulkUpsertById([ + { id: "shared", props: { name: "A" }, clearValidTo: true }, + ]); + expect(await windowEndCount()).toBe(4); + }); + + // Load-bearing: the note describes a row write, so it is taken only once + // that write has succeeded. Revert check: note the transition inside + // `requireNodeValidityEndCompatible` again (before the row write) and each + // refused update below leaves a `window-end` transition and a receipt with + // `identity.transitions: 1` for a transaction that wrote nothing. + it("leaves no window-end transition for an update refused and caught inside a transaction", async () => { + const Org = defineNode("Org", { schema: z.object({ name: z.string() }) }); + const foldGraph = defineGraph({ + id: "identity_transition_log_window_refused", + nodes: { Person: { type: Person }, Org: { type: Org } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + foldGraph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "shared" }); + await store.nodes.Org.create({ name: "A Org" }, { id: "shared" }); + const invertedEnd = new Date(Date.now() - 3_600_000).toISOString(); + const recordedBefore = await store.recordedNow(); + + const outcome = await store.transactionWithReceipt(async (tx) => { + const refusals = [ + () => + tx.nodes.Org.update(asNodeId("shared"), {}, { validTo: invertedEnd }), + () => + tx.nodes.Org.upsertById( + "shared", + { name: "A Org" }, + { validTo: invertedEnd }, + ), + () => + tx.nodes.Org.bulkUpsertById([ + { id: "shared", props: { name: "A Org" }, validTo: invertedEnd }, + ]), + ]; + for (const refused of refusals) { + await expect(refused()).rejects.toThrow(/validity window/i); + } + }); + + expect(outcome.receipt.writes.total).toBe(0); + expect(outcome.receipt.writes.identity.transitions).toBe(0); + const history = await store.identity.transitionsOf({ + kind: "Person", + id: "shared", + }); + expect( + history.transitions.filter( + (transition) => transition.cause === "window-end", + ), + ).toEqual([]); + expect(await store.recordedNow()).toEqual(recordedBefore); + }); + + // Load-bearing: `store.evolve()` runs the identity schema-commit preflight + // on the raw commit transaction, which no capture session is bound to, so a + // history store must bind one itself or every note the preflight takes is + // dropped. A lagging closure is the reachable shape that makes the + // preflight's rebuild change membership. Revert check: drop the + // `capture` from `Store#identitySchemaPreflight` and the evolve + // records no transition. + it("records the schema-transition notes an evolve's identity preflight takes on a history store", async () => { + const backend = createTestBackend(); + const [store] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + await store.identity.assertSame( + { kind: "Person", id: "a" }, + { kind: "Person", id: "b" }, + ); + const schema = createSqlSchema(backend.tableNames); + const executeStatement = requireDefined(backend.executeStatement); + await executeStatement( + asCompiledStatementSql(sql` + DELETE FROM ${schema.identityClosureTable} + WHERE graph_id = ${store.graphId} + `), + ); + + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Tag: { properties: { label: { type: "string" } } } }, + }), + ); + const rows = await readTransitions(storeRuntime(evolved).identityContext()); + expect(transitionHops(rows, "schema-transition")).toEqual([ + "Person:a <- -", + ]); + }); + + it("notes a kind-drop transition when Store.removeKinds() cascades a folded class", async () => { + const extensionGraph = defineGraph({ + id: "identity_transition_log_kind_drop", + nodes: { Person: { type: Person } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const [store] = await createAdapterStoreWithSchema( + extensionGraph, + createTestBackend(), + { history: true }, + ); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Tag: { properties: { label: { type: "string" } } } }, + }), + ); + const person = await evolved.nodes.Person.create({ name: "Alice" }); + const tag = await evolved.getNodeCollectionOrThrow("Tag").create({ + label: "author", + }); + const asserted = await evolved.identity.assertSame(person, tag); + + const removed = await evolved.removeKinds(["Tag"]); + const ctx = storeRuntime(removed).identityContext(); + const rows = await readIdentityTransitions( + ctx.backend, + ctx.schema, + ctx.graphId, + { + classRefs: [ + { kind: "Person", id: person.id }, + { kind: "Tag", id: tag.id }, + ], + limit: 200, + }, + ); + const kindDropRows = rows.filter((row) => row.cause === "kind-drop"); + expect(kindDropRows.length).toBeGreaterThanOrEqual(1); + for (const row of kindDropRows) { + expect(row.assertion_ids).toEqual([asserted.assertion.id]); + } + }); + + it("notes a schema-transition cause when first enablement folds a pre-existing same-id pair", async () => { + // `schema-transition` is `identitySchemaCommitPreflight`'s cause + // whenever the closure changed and NO node kind was dropped — + // distinguishing it from `kind-drop`, the other cause the same preflight + // can emit. First enablement on a database that already holds a same-id + // pair across kinds is the simplest reachable case: the enablement + // rebuild folds Person/shared and Author/shared into one class with + // nothing dropped. + // + // Previously skipped: `prepareStoreWithSchema` (src/store/store.ts) runs + // the FIRST schema commit — including a first enablement — against the + // RAW constructor-argument `backend`, before `StoreImplementation`'s own + // constructor wraps it with `createRecordedBackend` (history capture). + // `identitySchemaCommitPreflight` now binds a capture session directly + // to its OWN schema-commit transaction target when the caller threads + // `historyEnabled` (via `SchemaManagerOptions.historyEnabled`, + // `prepareStoreWithSchema`'s `ensureOptions`) — the same + // `createRecordedTransactionScope` pattern + // `Store#removeIdentityKindsInSchemaPreflight` already used for its own + // schema-commit transaction — so this no longer depends on a Store + // object existing yet. + const GRAPH_ID = "identity_transition_log_schema_transition"; + const Author = defineNode("Author", { + schema: z.object({ penName: z.string() }), + }); + const disabledGraph = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person }, Author: { type: Author } }, + edges: {}, + }); + const enabledGraph = defineGraph({ + id: GRAPH_ID, + nodes: { Person: { type: Person }, Author: { type: Author } }, + edges: {}, + identity: { sameIdAcrossKinds: "fold" }, + }); + const { backend } = createLocalSqliteBackend(); + try { + const [disabledStore] = await createStoreWithSchema( + disabledGraph, + backend, + { history: true }, + ); + await disabledStore.nodes.Person.create( + { name: "Alice" }, + { id: "shared" }, + ); + await disabledStore.nodes.Author.create( + { penName: "A." }, + { id: "shared" }, + ); + + const [enabledStore] = await createStoreWithSchema( + enabledGraph, + backend, + { history: true }, + ); + const ctx = storeRuntime(enabledStore).identityContext(); + const rows = await readTransitions(ctx, [ + { kind: "Person", id: "shared" }, + { kind: "Author", id: "shared" }, + ]); + const schemaTransitionRows = rows.filter( + (row) => row.cause === "schema-transition", + ); + expect(schemaTransitionRows.length).toBeGreaterThanOrEqual(1); + // The other droppedNodeKinds branch of the SAME site must not have + // fired instead. + expect(rows.some((row) => row.cause === "kind-drop")).toBe(false); + } finally { + await backend.close(); + } + }); + + it("notes a reconcile transition, carrying decision provenance, for a governed apply", async () => { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + await store.nodes.Person.create({ name: "A" }, { id: "a" }); + await store.nodes.Person.create({ name: "B" }, { id: "b" }); + const ctx = storeRuntime(store).identityContext(); + const assertionId = generateId(); + const now = new Date().toISOString(); + await applyIdentityChangesForContext( + ctx, + [], + [ + { + id: assertionId, + relation: "same", + a: { kind: "Person", id: "a" }, + b: { kind: "Person", id: "b" }, + validFrom: now, + }, + ], + { branchId: "branch-a", mergePlanDigest: "digest-abc" }, + ); + const rows = await readTransitions(ctx); + const reconcileRows = rows.filter((row) => row.cause === "reconcile"); + expect(reconcileRows.length).toBeGreaterThanOrEqual(1); + expect(reconcileRows[0]?.assertion_ids).toEqual([assertionId]); + expect(reconcileRows[0]?.decision).toEqual({ + branchId: "branch-a", + mergePlanDigest: "digest-abc", + }); + }); +}); + +describe("transition records over a large class", () => { + const MEMBER_COUNT = 400; + const members: readonly PlainNodeRef[] = Array.from( + { length: MEMBER_COUNT }, + (_, index) => ({ + kind: "Person", + id: `m${String(index).padStart(4, "0")}`, + }), + ); + const chain = members.slice(1).map((member, index) => ({ + id: `a${String(index)}`, + rel: "same" as const, + a_kind: "Person", + a_id: requireDefined(members[index]).id, + b_kind: "Person", + b_id: member.id, + })); + + /** An array that counts how many elements iteration has handed out. */ + function countingClass( + nodes: readonly PlainNodeRef[], + counter: { reads: number }, + ): readonly PlainNodeRef[] { + return new Proxy([...nodes], { + get(target, property, receiver) { + if (property === Symbol.iterator) { + return function* iterate() { + for (const node of target) { + counter.reads += 1; + yield node; + } + }; + } + return Reflect.get(target, property, receiver) as unknown; + }, + }); + } + + it("reads a class once however many of its members report it", () => { + const counter = { reads: 0 }; + const merged = countingClass(members, counter); + const oldClassOf = new Map(); + const newClassOf = new Map(); + for (const member of members) { + oldClassOf.set(identityReferenceKey(member), [member]); + newClassOf.set(identityReferenceKey(member), merged); + } + + const records = diffClosureTransitions(members, oldClassOf, newClassOf); + + expect(records).toHaveLength(1); + expect(counter.reads).toBeLessThanOrEqual(MEMBER_COUNT); + }); + + it("names, for a class split into singletons, only the assertions at each singleton", () => { + const oldClassOf = new Map(); + const newClassOf = new Map(); + for (const member of members) { + oldClassOf.set(identityReferenceKey(member), members); + newClassOf.set(identityReferenceKey(member), [member]); + } + + const records = diffClosureTransitions(members, oldClassOf, newClassOf); + const ids = transitionAssertionIds(records, chain); + + expect(records).toHaveLength(MEMBER_COUNT); + // Every chain link has two endpoints, so each id is named by two records + // and the log is linear in the batch, not MEMBER_COUNT ids per record. + expect(ids.flat()).toHaveLength(2 * chain.length); + expect(ids[0]).toEqual(["a0"]); + expect(ids[1]).toEqual(["a0", "a1"]); + }); +}); diff --git a/packages/typegraph/tests/identity-transition-retention-decode.test.ts b/packages/typegraph/tests/identity-transition-retention-decode.test.ts new file mode 100644 index 000000000..76345bda5 --- /dev/null +++ b/packages/typegraph/tests/identity-transition-retention-decode.test.ts @@ -0,0 +1,68 @@ +/** + * `readTransitionRetentionDetails` must decode `pruned_at` through + * the identity module's one owner `toCanonicalIdentityTimestamp` + * (row-codec.ts) — exactly the decoder `normalizeIdentityTransitionRow` in + * the SAME file already uses for `recorded_at` / `valid_at`. A second, + * stricter inline decoder (`asRowString`) throws on a JS `Date` (what + * node-postgres returns for a `timestamp(..., { withTimezone: true })` + * column) and passes non-ISO text through uncanonicalized (what a + * text-returning driver such as postgres-js returns). + * + * `identityReplay` ALWAYS calls `readTransitionRetentionDetails` + * (replay.ts), so on real PostgreSQL every replay would throw once a graph + * had been pruned even once. Neither SQLite nor PGlite can reproduce this: + * both return canonical ISO text for a timestamp column, which every decoder + * candidate accepts — so this drives the reader directly with the two raw + * PostgreSQL driver value shapes, bypassing the query layer entirely, the + * same probe technique the finding used to reproduce it. + * + * Revert check: reverting `toCanonicalIdentityTimestamp(row.pruned_at)` to + * `asRowString(row.pruned_at, "pruned_at")` in `transition-log.ts` makes the + * first case below throw `ConfigurationError` (Date is not a string) and + * leaves the second case returning `2026-09-08 22:08:27.154+00` verbatim — + * failing the canonical-format assertion. + */ +import { describe, expect, it } from "vitest"; + +import { type IdentityTarget } from "../src/identity/sql-target"; +import { readTransitionRetentionDetails } from "../src/identity/transition-log"; +import { createSqlSchema } from "../src/query/compiler/schema"; +import { isCanonicalIsoDate } from "../src/utils/date"; + +const schema = createSqlSchema(); +const graphId = "g1r3_01_retention_decode"; + +function fakeTargetReturningPrunedAt(prunedAt: unknown): IdentityTarget { + return { + execute: () => + Promise.resolve([{ pruned_before_revision: 42, pruned_at: prunedAt }]), + } as unknown as IdentityTarget; +} + +describe("readTransitionRetentionDetails pruned_at decode", () => { + it("decodes a node-postgres JS Date pruned_at without throwing", async () => { + const target = fakeTargetReturningPrunedAt( + new Date("2026-09-08T22:08:27.154Z"), + ); + const details = await readTransitionRetentionDetails( + target, + schema, + graphId, + ); + expect(details.prunedBeforeRevision).toBe(42); + expect(isCanonicalIsoDate(details.prunedAt)).toBe(true); + expect(details.prunedAt).toBe("2026-09-08T22:08:27.154Z"); + }); + + it("canonicalizes a postgres-js raw-text pruned_at", async () => { + const target = fakeTargetReturningPrunedAt("2026-09-08 22:08:27.154+00"); + const details = await readTransitionRetentionDetails( + target, + schema, + graphId, + ); + expect(details.prunedBeforeRevision).toBe(42); + expect(isCanonicalIsoDate(details.prunedAt)).toBe(true); + expect(details.prunedAt).toBe("2026-09-08T22:08:27.154Z"); + }); +}); diff --git a/packages/typegraph/tests/identity.test.ts b/packages/typegraph/tests/identity.test.ts index d899be096..553984ac9 100644 --- a/packages/typegraph/tests/identity.test.ts +++ b/packages/typegraph/tests/identity.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { @@ -14,7 +14,10 @@ import { rebuildIdentityClosure, type TransactionBackend, } from "../src"; -import { deriveBackend } from "../src/backend/derive-backend"; +import { + deriveBackend, + projectBackendWithout, +} from "../src/backend/derive-backend"; import { type RecordedInstant, recordedInstantRevision, @@ -32,6 +35,7 @@ import { asCompiledRowsSql, type CompiledRowsSql, } from "../src/query/sql-intent"; +import { migrateSchema, rollbackSchema } from "../src/schema"; import { requireDefined } from "../src/utils/presence"; import { createInitializedStore, @@ -43,6 +47,30 @@ import { revisionsAdvanced, } from "./test-utils"; +const FAKE_CLOCK_START = "2026-01-01T00:00:00.000Z"; +const FAKE_CLOCK_STEP_MS = 1000; + +/** Fakes only `Date`, so recorded and valid instants are chosen by the test. */ +function startFakeClock(): void { + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(new Date(FAKE_CLOCK_START)); +} + +/** + * An instant strictly after every write so far and strictly before the next + * one — the fake clock moves forward on both sides of it. + */ +function instantBetweenWrites(): string { + vi.setSystemTime(Date.now() + FAKE_CLOCK_STEP_MS); + const instant = new Date().toISOString(); + vi.setSystemTime(Date.now() + FAKE_CLOCK_STEP_MS); + return instant; +} + +afterEach(() => { + vi.useRealTimers(); +}); + const Person = defineNode("Person", { schema: z.object({ name: z.string() }), }); @@ -492,13 +520,16 @@ describe("Operational Identity", () => { identity: { sameIdAcrossKinds: "fold" }, }); + // Person:"alice" and Author:"alice" already violate the newly-added + // `disjointWith(Person, Author)` directly — a plain node-disjointness + // fact, true whether or not identity is involved. The ontology-tightening + // preflight runs BEFORE the identity closure rebuild, so it refuses here and the identity contradiction check + // underneath is never reached. await expect( createStoreWithSchema(contradictory, backend), ).rejects.toMatchObject({ - name: "ConfigurationError", - details: matchingObject({ - code: "IDENTITY_SCHEMA_CONTRADICTION", - }), + name: "MigrationError", + details: matchingObject({ reason: "ontology-tightening-violated" }), }); const activeSchema = await backend.getActiveSchema(graph.id); expect(activeSchema?.version).toBe(1); @@ -517,15 +548,185 @@ describe("Operational Identity", () => { identity: { sameIdAcrossKinds: "fold" }, }); + // Same reasoning as above: the ontology tightening this migration + // proposes is already false against the live `alice` rows, so it is + // refused before identity's own (ontology-driven) revalidation runs. await expect( createStoreWithSchema(contradictory, backend), ).rejects.toMatchObject({ + name: "MigrationError", + details: matchingObject({ reason: "ontology-tightening-violated" }), + }); + const activeSchema = await backend.getActiveSchema(graph.id); + expect(activeSchema?.version).toBe(1); + }); + + it("refuses a rollback that re-enables identity, leaving the active version and closure untouched", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + // v2 disables identity, and a same-id pair is written while nothing + // folds it — reactivating v1 would owe the closure a rebuild that only + // the graph definition, not a stored document, can drive. + await migrateSchema(backend, disabledMigrationGraph, 1); + const disabledStore = createStore(disabledMigrationGraph, backend); + await disabledStore.nodes.Person.create({ name: "Bob" }, { id: "bob" }); + await disabledStore.nodes.Author.create({ penName: "B." }, { id: "bob" }); + + await expect(rollbackSchema(backend, graph.id, 1)).rejects.toMatchObject({ name: "ConfigurationError", - details: matchingObject({ code: "IDENTITY_SCHEMA_CONTRADICTION" }), + details: matchingObject({ + code: "IDENTITY_ROLLBACK_REQUIRES_MIGRATION", + fromVersion: 2, + toVersion: 1, + }), + }); + const active = await backend.getActiveSchema(graph.id); + expect(active?.version).toBe(2); + + // The documented alternative commits v1's definition forward and folds + // the pair. + await migrateSchema(backend, graph, 2); + const [reenabled] = await createStoreWithSchema(graph, backend); + expect( + await reenabled.identity.areSame( + { kind: "Person", id: "bob" }, + { kind: "Author", id: "bob" }, + ), + ).toBe(true); + }); + // MUTATION CHECK: passing `identity: undefined` to the composed rollback + // preflight (`prepareRollbackPreflight`, src/schema/manager.ts) flips the + // pointer onto an identity-enabled version whose closure was never + // rebuilt, and this test fails. + + it("names the identity refusal, not a capability gap, on a backend without setActiveVersionWithPreflight", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + await migrateSchema(backend, disabledMigrationGraph, 1); + const restrictedBackend = projectBackendWithout(backend, [ + "setActiveVersionWithPreflight", + ]); + + // The refusal reads no data, so the missing atomic primitive is not + // why this rollback cannot run. + await expect( + rollbackSchema(restrictedBackend, graph.id, 1), + ).rejects.toMatchObject({ + name: "ConfigurationError", + details: matchingObject({ + code: "IDENTITY_ROLLBACK_REQUIRES_MIGRATION", + fromVersion: 2, + toVersion: 1, + }), + }); + const active = await backend.getActiveSchema(graph.id); + expect(active?.version).toBe(2); + }); + // MUTATION CHECK: throwing the atomic-preflight capability error whenever + // `setActiveVersionWithPreflight` is missing (src/schema/manager.ts + // `rollbackSchema`) refuses with IDENTITY_REQUIRES_ATOMIC_BACKEND instead. + + it("rolls back an identity-enabled graph when identity-relevant schema is unchanged", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + await store.nodes.Person.create({ name: "Alice" }, { id: "alice" }); + await store.nodes.Author.create({ penName: "A." }, { id: "alice" }); + // v2 only adds an edge kind: the ontology, the node kinds, and the + // identity profile the closure derives from are unchanged. + const worksAt = defineEdge("worksAt", { schema: z.object({}) }); + const withEdge = defineGraph({ + id: graph.id, + nodes: graph.nodes, + edges: { + ...graph.edges, + worksAt: { type: worksAt, from: [Person], to: [Company] }, + }, + ontology: [disjointWith(Person, Company)], + identity: { sameIdAcrossKinds: "fold" }, + }); + await migrateSchema(backend, withEdge, 1); + + await rollbackSchema(backend, graph.id, 1); + + const active = await backend.getActiveSchema(graph.id); + expect(active?.version).toBe(1); + const [rolledBack] = await createStoreWithSchema(graph, backend); + expect( + await rolledBack.identity.areSame( + { kind: "Person", id: "alice" }, + { kind: "Author", id: "alice" }, + ), + ).toBe(true); + }); + + it("refuses the same tightening driven through migrateSchema directly, before the identity closure rebuild", async () => { + const backend = createTestBackend(); + const [disabledStore] = await createStoreWithSchema( + disabledMigrationGraph, + backend, + ); + await disabledStore.nodes.Person.create({ name: "Alice" }, { id: "alice" }); + await disabledStore.nodes.Author.create({ penName: "A." }, { id: "alice" }); + const contradictory = defineGraph({ + id: graph.id, + nodes: graph.nodes, + edges: graph.edges, + ontology: [disjointWith(Person, Author)], + identity: { sameIdAcrossKinds: "fold" }, + }); + + // Same shape as the two `ensureSchema` cases above, but driven through + // `migrateSchema()` directly — the documented escape hatch composes its + // own preflight array (`src/schema/manager.ts`) rather than reusing + // `ensureSchema`'s, so a call-site reorder there is invisible to those + // two tests. This is the ordering guard `ensureSchema`'s pair cannot + // provide for this path. + await expect( + migrateSchema(backend, contradictory, 1), + ).rejects.toMatchObject({ + name: "MigrationError", + details: matchingObject({ reason: "ontology-tightening-violated" }), + }); + const activeSchema = await backend.getActiveSchema(graph.id); + expect(activeSchema?.version).toBe(1); + }); + // MUTATION CHECK: swapping `ontologyPreflight` + // and `identityPreflight` in `migrateSchema`'s composed preflight array + // (`src/schema/manager.ts`) makes this test fail — the identity closure + // rebuild runs first, throws `IDENTITY_SCHEMA_CONTRADICTION` (or succeeds + // outright), and `ontology-tightening-violated` never surfaces. + + it("refuses the same tightening driven through Store.evolve(), before the identity closure rebuild", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + await store.nodes.Person.create({ name: "Alice" }, { id: "alice" }); + await store.nodes.Author.create({ penName: "A." }, { id: "alice" }); + + // `graph` already runs `sameIdAcrossKinds: "fold"`, so the two `alice` + // rows above are already one identity class before this evolve. Adding + // `disjointWith(Person, Author)` is simultaneously a live-data + // `nodeDisjointness` violation (the tightening probe) and something the + // identity closure rebuild would also refuse — proving `Store.evolve`'s + // OWN composed callback (`src/store/store.ts`), not just `ensureSchema`'s, + // orders the ontology probe before the identity rebuild. + await expect( + store.evolve( + defineGraphExtension({ + ontology: [ + { metaEdge: "disjointWith", from: "Person", to: "Author" }, + ], + }), + ), + ).rejects.toMatchObject({ + name: "MigrationError", + details: matchingObject({ reason: "ontology-tightening-violated" }), }); const activeSchema = await backend.getActiveSchema(graph.id); expect(activeSchema?.version).toBe(1); }); + // MUTATION CHECK: swapping `ontologyPreflight` + // and `identityCandidate`'s step in `Store.evolve`'s composed preflight + // array (`src/store/store.ts`) makes this test fail the same way. it("cascades extension-kind identity truth during removeKinds", async () => { const extensionGraph = defineGraph({ @@ -694,6 +895,7 @@ describe("Operational Identity", () => { }); it("reconstructs explicit and folded membership at an as-of coordinate", async () => { + startFakeClock(); const store = await createInitializedStore(graph, createTestBackend()); const person = await store.nodes.Person.create( { name: "Alice" }, @@ -708,9 +910,7 @@ describe("Operational Identity", () => { { id: "other" }, ); const assertion = await store.identity.assertSame(author, other); - await new Promise((resolve) => setTimeout(resolve, 2)); - const beforeRetraction = new Date().toISOString(); - await new Promise((resolve) => setTimeout(resolve, 2)); + const beforeRetraction = instantBetweenWrites(); await store.identity.retractAssertion(assertion.assertion.id); expect( @@ -724,6 +924,7 @@ describe("Operational Identity", () => { }); it("conducts historical identity through a soft-deleted invisible bridge", async () => { + startFakeClock(); const store = await createInitializedStore(graph, createTestBackend()); const first = await store.nodes.Person.create( { name: "First" }, @@ -739,9 +940,7 @@ describe("Operational Identity", () => { ); await store.identity.assertSame(first, bridge); await store.identity.assertSame(bridge, far); - await new Promise((resolve) => setTimeout(resolve, 2)); - const beforeDelete = new Date().toISOString(); - await new Promise((resolve) => setTimeout(resolve, 2)); + const beforeDelete = instantBetweenWrites(); await store.nodes.Person.delete(bridge.id); expect(await store.asOf(beforeDelete).identity.membersOf(first)).toEqual([ @@ -833,6 +1032,7 @@ describe("Operational Identity", () => { }); it("expands one traversal through visible identity members without duplicating physical edges", async () => { + startFakeClock(); const store = await createInitializedStore(graph, createTestBackend()); const person = await store.nodes.Person.create( { name: "Alice" }, @@ -882,9 +1082,7 @@ describe("Operational Identity", () => { expect(expanded[0]?.edge.fromId).toBe(author.id); expect(expanded[0]?.friend.id).toBe(friend.id); - await new Promise((resolve) => setTimeout(resolve, 2)); - const beforeRetraction = new Date().toISOString(); - await new Promise((resolve) => setTimeout(resolve, 2)); + const beforeRetraction = instantBetweenWrites(); await store.identity.retractAssertion(assertion.assertion.id); expect( await store diff --git a/packages/typegraph/tests/import-acyclicity.test.ts b/packages/typegraph/tests/import-acyclicity.test.ts new file mode 100644 index 000000000..f91ca336a --- /dev/null +++ b/packages/typegraph/tests/import-acyclicity.test.ts @@ -0,0 +1,203 @@ +/** + * Import wiring for edge acyclicity: the validating-import path checks + * acyclicity per row, sequentially, for an acyclic edge kind — never + * batched, since the in-batch overlay that lets cardinality/endpoint checks + * see earlier rows in the same slice cannot account for a recursive + * reachability walk. A backend with no transactions refuses the whole + * import up front, before the first row, because acyclicity's fence is + * `lockOnly` and import's claim-row substitute for the per-graph lock does + * not exist for it. + */ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { createStore, defineEdge, defineGraph, defineNode } from "../src"; +import { generateSqliteDDL } from "../src/backend/drizzle/ddl"; +import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; +import type { GraphBackend } from "../src/backend/types"; +import { + type GraphData, + importGraph, + ImportOptionsSchema, +} from "../src/interchange"; +import { createTestBackend } from "./test-utils"; + +const Task = defineNode("Task", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); +const plainMany = defineEdge("plainMany", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "import_acyclicity_test", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + acyclic: true, + }, + plainMany: { + type: plainMany, + from: [Task], + to: [Task], + }, + }, +}); + +function payload(edges: GraphData["edges"]): GraphData { + return { + formatVersion: "2.0", + exportedAt: "2026-01-01T00:00:00.000Z", + source: { type: "external", description: "acyclicity import test" }, + nodes: [ + { kind: "Task", id: "a", properties: {} }, + { kind: "Task", id: "b", properties: {} }, + { kind: "Task", id: "c", properties: {} }, + ], + edges, + }; +} + +const importOptions = ImportOptionsSchema.parse({ + onConflict: "error", + refreshStatistics: false, +}); + +describe("import: edge acyclicity", () => { + it("records a per-row error for an in-batch closing edge and commits the rest", async () => { + const backend = createTestBackend(); + const store = createStore(graph, backend); + + const result = await importGraph( + store, + payload([ + { + kind: "dependsOn", + id: "e1", + from: { kind: "Task", id: "a" }, + to: { kind: "Task", id: "b" }, + properties: {}, + }, + { + kind: "dependsOn", + id: "e2", + from: { kind: "Task", id: "b" }, + to: { kind: "Task", id: "c" }, + properties: {}, + }, + { + kind: "dependsOn", + id: "e3", + from: { kind: "Task", id: "c" }, + to: { kind: "Task", id: "a" }, + properties: {}, + }, + ]), + importOptions, + ); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.error).toMatch(/cycle|acyclic/i); + + const remaining = await store.edges.dependsOn.find({}); + expect(remaining.map((edge) => edge.id).toSorted()).toEqual(["e1", "e2"]); + }); + + it("refuses the whole import up front on a backend with no transactions", async () => { + const sqlite = new Database(":memory:"); + try { + const db = drizzle(sqlite); + for (const statement of generateSqliteDDL()) sqlite.exec(statement); + const backend: GraphBackend = createSqliteBackend(db, { + executionProfile: { transactionMode: "none", isSync: true }, + }); + const store = createStore(graph, backend); + + await expect( + importGraph( + store, + payload([ + { + kind: "dependsOn", + id: "e1", + from: { kind: "Task", id: "a" }, + to: { kind: "Task", id: "b" }, + properties: {}, + }, + ]), + importOptions, + ), + ).rejects.toMatchObject({ + details: { + code: "CONSTRAINT_WRITE_FENCE_UNSUPPORTED", + constraint: "edgeAcyclicity", + }, + }); + + const remaining = await store.edges.dependsOn.find({}); + expect(remaining).toEqual([]); + } finally { + sqlite.close(); + } + }); + + it("refuses before the first chunk even when an earlier chunk has no acyclic edges", async () => { + // batchSize: 1 puts the harmless plainMany edge in its own chunk ahead of + // the acyclic one. The refusal this graph owes is a property of its + // SCHEMA (it declares an acyclic kind), checked once before chunk 1 — + // not a property of what a given chunk's rows happen to contain — so + // chunk 1 must never commit even though nothing in it would fail on its + // own. Losing the up-front check would still refuse on chunk 2 (the + // per-chunk fence is generic), but only after chunk 1 already committed. + const sqlite = new Database(":memory:"); + try { + const db = drizzle(sqlite); + for (const statement of generateSqliteDDL()) sqlite.exec(statement); + const backend: GraphBackend = createSqliteBackend(db, { + executionProfile: { transactionMode: "none", isSync: true }, + }); + const store = createStore(graph, backend); + const chunkedOptions = ImportOptionsSchema.parse({ + onConflict: "error", + refreshStatistics: false, + batchSize: 1, + }); + + await expect( + importGraph( + store, + payload([ + { + kind: "plainMany", + id: "p1", + from: { kind: "Task", id: "a" }, + to: { kind: "Task", id: "b" }, + properties: {}, + }, + { + kind: "dependsOn", + id: "e1", + from: { kind: "Task", id: "a" }, + to: { kind: "Task", id: "b" }, + properties: {}, + }, + ]), + chunkedOptions, + ), + ).rejects.toMatchObject({ + details: { + code: "CONSTRAINT_WRITE_FENCE_UNSUPPORTED", + constraint: "edgeAcyclicity", + }, + }); + + expect(await store.edges.plainMany.find({})).toEqual([]); + expect(await store.edges.dependsOn.find({})).toEqual([]); + } finally { + sqlite.close(); + } + }); +}); diff --git a/packages/typegraph/tests/interchange.test.ts b/packages/typegraph/tests/interchange.test.ts index 14b50dbe7..ef1daa4cd 100644 --- a/packages/typegraph/tests/interchange.test.ts +++ b/packages/typegraph/tests/interchange.test.ts @@ -396,7 +396,7 @@ describe("Interchange Round-Trip", () => { expect(exported.nodes).toHaveLength(0); expect(exported.edges).toHaveLength(0); - expect(exported.formatVersion).toBe("2.0"); + expect(exported.formatVersion).toBe("3.0"); expect(exported.source.type).toBe("typegraph-export"); const targetBackend = createTestBackend(); @@ -1711,18 +1711,18 @@ describe("Interchange import property fidelity", () => { // ============================================================ describe("Interchange format version compatibility", () => { - it("exports always carry formatVersion 2.0", async () => { + it("exports always carry formatVersion 3.0", async () => { const store = createStore(testGraph, createTestBackend()); await store.nodes.Person.create({ name: "Alice" }); const exported = await exportGraph(store); - expect(exported.formatVersion).toBe("2.0"); + expect(exported.formatVersion).toBe("3.0"); const [headerChunk] = await collectChunks(exportGraphStream(store)); if (headerChunk?.type !== "header") { throw new Error("Expected the export stream to start with a header."); } - expect(headerChunk.header.formatVersion).toBe("2.0"); + expect(headerChunk.header.formatVersion).toBe("3.0"); }); it("accepts a 1.0 document (no identity section) via schema and import", async () => { @@ -1764,6 +1764,30 @@ describe("Interchange format version compatibility", () => { expect(GraphDataSchema.safeParse(document).success).toBe(true); }); + + it("accepts a 2.0 document (no transitions/retention) via schema and import", async () => { + // A pre-existing 2.0 export is structurally a valid 3.0 document; the + // documented GraphDataSchema.parse path must keep accepting it. + const document = { + formatVersion: "2.0", + exportedAt: CANONICAL_TIMESTAMP, + source: { type: "external" as const }, + nodes: [{ kind: "Person", id: "p1", properties: { name: "Alice" } }], + edges: [], + }; + + const parsed = GraphDataSchema.parse(document); + expect(parsed.formatVersion).toBe("2.0"); + + const store = createStore(testGraph, createTestBackend()); + const result = await importGraph( + store, + parsed, + importOptions({ onConflict: "error" }), + ); + expect(result.success).toBe(true); + expect(result.nodes.created).toBe(1); + }); }); // ============================================================ diff --git a/packages/typegraph/tests/interchange/composition-existence-import.test.ts b/packages/typegraph/tests/interchange/composition-existence-import.test.ts new file mode 100644 index 000000000..d75930bb2 --- /dev/null +++ b/packages/typegraph/tests/interchange/composition-existence-import.test.ts @@ -0,0 +1,810 @@ +/** + * Validating import and a required-existence composition part. + * + * Import writes every node row before any edge row (`processNodes` then + * `processEdges`), so "does this part's composition edge arrive in the same + * batch" can only be decided once the payload's whole edge set is known. + * `pendingRequiredParts` (a frame-scoped accumulator, mirroring + * `pendingMatchIdentityOwners`'s shape) tracks every required-existence part + * this import CREATES; `assertImportedRequiredPartsAttached` judges each of + * them after both passes against the committed rows (a part attached in the + * same batch, or already attached on the target, is accepted) and records + * one per-row error — not a thrown abort — for anything left without a live + * whole, removing that row's node in the same transaction. + */ +import { describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../../src"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import { ConfigurationError } from "../../src/errors"; +import { + FORMAT_VERSION, + type GraphData, + type GraphInterchangeChunk, + importGraph, + importGraphStream, +} from "../../src/interchange"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { requireDefined } from "../../src/utils/presence"; + +const CeiSegment = defineNode("CeiSegment", { schema: z.object({}) }); +const CeiEpisode = defineNode("CeiEpisode", { schema: z.object({}) }); +const CeiTag = defineNode("CeiTag", { schema: z.object({}) }); + +const ceiSegmentOf = defineEdge("ceiSegmentOf", { schema: z.object({}) }); +const ceiTaggedBy = defineEdge("ceiTaggedBy", { schema: z.object({}) }); + +function buildGraph() { + return defineGraph({ + id: "composition-existence-import", + nodes: { + CeiSegment: { type: CeiSegment }, + CeiEpisode: { type: CeiEpisode }, + }, + edges: { + ceiSegmentOf: { + type: ceiSegmentOf, + from: [CeiSegment], + to: [CeiEpisode], + cardinality: "one", + }, + }, + ontology: [ + partOf(CeiSegment, CeiEpisode, { + via: ceiSegmentOf, + existence: "required", + }), + ], + }); +} + +/** + * The SAME required-existence pair, plus an ordinary (non-composition) + * edge kind on the part — `CeiTag`, connected via `ceiTaggedBy`. A part + * refused for lacking a whole may still carry live, non-composition edges + * this same import created; the purge must not choke on those. + */ +function buildGraphWithTag() { + return defineGraph({ + id: "composition-existence-import-tagged", + nodes: { + CeiSegment: { type: CeiSegment }, + CeiEpisode: { type: CeiEpisode }, + CeiTag: { type: CeiTag }, + }, + edges: { + ceiSegmentOf: { + type: ceiSegmentOf, + from: [CeiSegment], + to: [CeiEpisode], + cardinality: "one", + }, + ceiTaggedBy: { + type: ceiTaggedBy, + from: [CeiSegment], + to: [CeiTag], + }, + }, + ontology: [ + partOf(CeiSegment, CeiEpisode, { + via: ceiSegmentOf, + existence: "required", + }), + ], + }); +} + +const CeiOther = defineNode("CeiOther", { schema: z.object({}) }); + +function buildIdentityEnabledGraph() { + return defineGraph({ + id: "composition-existence-import-identity", + nodes: { + CeiSegment: { type: CeiSegment }, + CeiEpisode: { type: CeiEpisode }, + CeiOther: { type: CeiOther }, + }, + edges: { + ceiSegmentOf: { + type: ceiSegmentOf, + from: [CeiSegment], + to: [CeiEpisode], + cardinality: "one", + }, + }, + ontology: [ + partOf(CeiSegment, CeiEpisode, { + via: ceiSegmentOf, + existence: "required", + }), + ], + identity: { sameIdAcrossKinds: "fold" }, + }); +} + +/** + * A composition edge to an episode that does not exist. Written under + * `validateReferences: false`, it attaches nothing, so its segment is written + * and then refused once the edges are in — the purge these tests drive. A + * segment with no composition edge at all is refused before its row is + * written and never reaches the purge. + */ +function danglingSegmentOf( + id: string, + segmentId: string, +): GraphData["edges"][number] { + return { + kind: "ceiSegmentOf", + id, + from: { kind: "CeiSegment", id: segmentId }, + to: { kind: "CeiEpisode", id: "episode-missing" }, + properties: {}, + }; +} + +function payload(data: Pick): GraphData { + return { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { + type: "external", + description: "composition-existence-import test", + }, + ...data, + }; +} + +describe("validating import: required composition existence", () => { + it("accepts a required part whose composition edge is in the same batch", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + const episode = await store.nodes.CeiEpisode.create({}); + + const result = await importGraph( + store, + payload({ + nodes: [{ kind: "CeiSegment", id: "seg-same-batch", properties: {} }], + edges: [ + { + kind: "ceiSegmentOf", + id: "e-same-batch", + from: { kind: "CeiSegment", id: "seg-same-batch" }, + to: { kind: "CeiEpisode", id: episode.id }, + properties: {}, + }, + ], + }), + { onConflict: "error", batchSize: 100 }, + ); + + expect(result.errors).toHaveLength(0); + expect(result.nodes.created).toBe(1); + const segment = await store.nodes.CeiSegment.getById( + "seg-same-batch" as never, + ); + expect(segment).toBeDefined(); + } finally { + await backend.close(); + } + }); + + it("accepts a required part already attached on the target before this import", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + const episode = await store.nodes.CeiEpisode.create({}); + await store.nodes.CeiSegment.create( + {}, + { partOf: { whole: { kind: "CeiEpisode", id: episode.id } } }, + ); + const [existingEdge] = await store.edges.ceiSegmentOf.find({}); + + // Re-import the SAME part with no edge in this batch at all — it is + // already attached on the target, so it must be accepted, not refused. + const result = await importGraph( + store, + payload({ + nodes: [ + { + kind: "CeiSegment", + id: requireDefined(existingEdge).fromId, + properties: {}, + }, + ], + edges: [], + }), + { onConflict: "skip", batchSize: 100 }, + ); + + expect(result.errors).toHaveLength(0); + } finally { + await backend.close(); + } + }); + + it("refuses a required part with neither an in-batch edge nor an existing whole — per-row error, no orphan node row survives, rest of the import commits", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + + const result = await importGraph( + store, + payload({ + nodes: [ + { kind: "CeiSegment", id: "seg-orphan", properties: {} }, + { kind: "CeiEpisode", id: "episode-ok", properties: {} }, + ], + edges: [], + }), + { onConflict: "error", batchSize: 100 }, + ); + + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.entityType).toBe("node"); + expect(result.errors[0]?.id).toBe("seg-orphan"); + expect(result.errors[0]?.error).toMatch(/requires a whole/u); + // The rest of the import commits: the unrelated episode row survives. + expect( + await store.nodes.CeiEpisode.getById("episode-ok" as never), + ).toBeDefined(); + // No orphan node row survives for the refused part. + expect( + await store.nodes.CeiSegment.getById("seg-orphan" as never), + ).toBeUndefined(); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: drop the `unattachableRequiredPartRefusal` check from + // `processNodeSlice` AND skip the `assertImportedRequiredPartsAttached` + // call in `runImportWritePlanAttempt` (src/interchange/import.ts) — + // `seg-orphan` then commits as a live, unattached required part, and the + // last assertion above (`toBeUndefined()`) fails. Either one alone still + // refuses it: the first before the row is written, the second after. + + it("refuses an unattached required part that also carries a live, non-composition edge without aborting the whole import", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraphWithTag(), backend); + + // `seg-1`'s only `ceiSegmentOf` edge names an episode that does not + // exist (refused for lacking a whole, once its row and edges are + // written) and it DOES carry a live `ceiTaggedBy` edge to `tag-1` — + // an ordinary edge this same import creates alongside it. + const result = await importGraph( + store, + payload({ + nodes: [ + { kind: "CeiSegment", id: "seg-1", properties: {} }, + { kind: "CeiTag", id: "tag-1", properties: {} }, + ], + edges: [ + danglingSegmentOf("e-part-1", "seg-1"), + { + kind: "ceiTaggedBy", + id: "e-tag-1", + from: { kind: "CeiSegment", id: "seg-1" }, + to: { kind: "CeiTag", id: "tag-1" }, + properties: {}, + }, + ], + }), + { onConflict: "error", batchSize: 100, validateReferences: false }, + ); + + // One per-row error for the refused part — not a thrown transaction + // abort. Before the fix, `session.purgeNode`'s default `restrict` + // policy saw the still-live `ceiTaggedBy` edge and threw + // `RestrictedDeleteError` PAST this function's per-row error channel, + // aborting the whole import. + expect(result.errors).toHaveLength(3); + expect(result.errors[0]?.entityType).toBe("node"); + expect(result.errors[0]?.id).toBe("seg-1"); + expect(result.errors[0]?.error).toMatch(/requires a whole/u); + // Each edge the purge removed with the part is reported too, and comes + // off the created count: the result tracks what was committed. + expect( + result.errors + .slice(1) + .map((error) => [error.entityType, error.id, error.error]), + ).toEqual( + expect.arrayContaining([ + ["edge", "e-tag-1", expect.stringMatching(/was removed with/u)], + ["edge", "e-part-1", expect.stringMatching(/was removed with/u)], + ]), + ); + expect(result.edges.created).toBe(0); + expect(result.nodes.created).toBe(1); + + // No orphan CeiSegment row survives for the refused part... + expect( + await store.nodes.CeiSegment.getById("seg-1" as never), + ).toBeUndefined(); + // ...its ordinary edge is gone too (purge is a real hard-delete, not + // a bare row removal that leaves the edge dangling)... + expect(await store.edges.ceiTaggedBy.find({})).toHaveLength(0); + // ...and — this IS the regression's signature — the unrelated + // CeiTag/tag-1 row this same import created still commits. + expect(await store.nodes.CeiTag.getById("tag-1" as never)).toBeDefined(); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: in `assertImportedRequiredPartsAttached` + // (src/interchange/import.ts), drop the `{ enforceDeleteBehavior: false }` + // argument from the `session.purgeNode` call (revert to the default + // policy). `purgeNode` then throws `RestrictedDeleteError` for the live + // `ceiTaggedBy` edge; the surrounding `try`/`catch` still catches it (so + // the import itself does not abort), but the caught error is + // `RestrictedDeleteError`'s message, not "requires a whole" — the + // `result.errors[0].error` assertion above fails — and the segment row is + // never purged at all (the restrict check runs before any deletion), so + // the `CeiSegment.getById("seg-1")` assertion fails too. + // + // MUTATION CHECK: in `purgeUnattachedRequiredPart`, drop the + // `removedEdges` loop — `result.edges.created` then reads 1 for an edge + // that no longer exists, and the second error is never reported. + + it("a purged required part leaves no identity membership behind on an identity-enabled store", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildIdentityEnabledGraph(), + backend, + ); + // A materialized 2-member identity class needs a LIVE peer of another + // kind sharing the same id (`hasMaterializedIdentityClass`'s + // docblock: a singleton writes no closure row at all) — otherwise the + // fold this test is about leaves nothing on disk to dangle. + await store.nodes.CeiOther.create({}, { id: "shared-id" }); + + // `foldImportedIdentityNodes` folds this node into identity — pairing + // it with `CeiOther/shared-id` into one 2-member class — BEFORE + // `assertImportedRequiredPartsAttached` gets a chance to refuse it + // (the fold needs the complete node batch; the refusal needs every + // edge processed too — neither can move ahead of the other). The + // refusal must undo the fold it inherited, not just the row. + const result = await importGraph( + store, + payload({ + nodes: [{ kind: "CeiSegment", id: "shared-id", properties: {} }], + edges: [danglingSegmentOf("e-part", "shared-id")], + }), + { onConflict: "error", batchSize: 100, validateReferences: false }, + ); + + expect(result.errors.map((error) => error.id)).toEqual([ + "shared-id", + "e-part", + ]); + expect( + await store.nodes.CeiSegment.getById("shared-id" as never), + ).toBeUndefined(); + expect( + await store.nodes.CeiOther.getById("shared-id" as never), + ).toBeDefined(); + + // A dangling identity membership for the purged row would disagree + // with the closure `validateIdentity()` recomputes from live rows — + // it must resolve clean, not throw IDENTITY_SCHEMA_CONTRADICTION. + await expect( + storeRuntime(store).validateIdentity(), + ).resolves.toBeUndefined(); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: remove the + // `runtime.detachDeletedImportedIdentityNode(frame.target, ...)` call from + // `assertImportedRequiredPartsAttached` (src/interchange/import.ts). The + // `validateIdentity()` assertion above then rejects instead of resolving. + + // Once the purge removed the row, its identity detach is owed: a failure + // there leaves a closure naming a deleted node, so it fails the import + // instead of being reported as that row's error with the row still counted. + it("fails the import when a purged part's identity detach fails", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildIdentityEnabledGraph(), + backend, + ); + await store.nodes.CeiOther.create({}, { id: "shared-id" }); + const detachFailure = new Error("identity detach failed"); + vi.spyOn( + storeRuntime(store), + "detachDeletedImportedIdentityNode", + ).mockRejectedValue(detachFailure); + + await expect( + importGraph( + store, + payload({ + nodes: [{ kind: "CeiSegment", id: "shared-id", properties: {} }], + edges: [danglingSegmentOf("e-part", "shared-id")], + }), + { onConflict: "error", batchSize: 100, validateReferences: false }, + ), + ).rejects.toBe(detachFailure); + expect( + await store.nodes.CeiSegment.getById("shared-id" as never), + ).toBeUndefined(); + expect(await store.edges.ceiSegmentOf.count()).toBe(0); + } finally { + vi.restoreAllMocks(); + await backend.close(); + } + }); +}); + +const CeiRoot = defineNode("CeiRoot", { schema: z.object({}) }); +const CeiMid = defineNode("CeiMid", { schema: z.object({}) }); +const CeiLeaf = defineNode("CeiLeaf", { schema: z.object({}) }); +const ceiMidOf = defineEdge("ceiMidOf", { schema: z.object({}) }); +const ceiLeafOf = defineEdge("ceiLeafOf", { schema: z.object({}) }); + +/** Two nested required pairs: CeiLeaf under CeiMid under CeiRoot. */ +function buildNestedGraph(population: "one" | "oneActive") { + return defineGraph({ + id: `composition-existence-import-nested-${population}`, + nodes: { + CeiRoot: { type: CeiRoot }, + CeiMid: { type: CeiMid }, + CeiLeaf: { type: CeiLeaf }, + }, + edges: { + ceiMidOf: { + type: ceiMidOf, + from: [CeiMid], + to: [CeiRoot], + cardinality: population, + }, + ceiLeafOf: { + type: ceiLeafOf, + from: [CeiLeaf], + to: [CeiMid], + cardinality: population, + }, + }, + ontology: [ + partOf(CeiMid, CeiRoot, { via: ceiMidOf, existence: "required" }), + partOf(CeiLeaf, CeiMid, { via: ceiLeafOf, existence: "required" }), + ], + }); +} + +const ENDED_FROM = "2020-01-01T00:00:00.000Z"; +const ENDED_TO = "2021-01-01T00:00:00.000Z"; + +describe("validating import: an accepted composition edge is not evidence of attachment", () => { + it("refuses a required part whose only composition edge is an ended oneActive window", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildNestedGraph("oneActive"), + backend, + ); + const root = await store.nodes.CeiRoot.create({}); + + // MUTATION CHECK: in `assertImportedRequiredPartsAttached` + // (src/interchange/import.ts), skip a pending part as soon as any + // composition edge naming it was created this import (the old + // clear-on-create rule) — the ended edge then counts as an attachment + // and `mid-1` commits with no whole. + const result = await importGraph( + store, + payload({ + nodes: [{ kind: "CeiMid", id: "mid-1", properties: {} }], + edges: [ + { + kind: "ceiMidOf", + id: "e-mid-1", + from: { kind: "CeiMid", id: "mid-1" }, + to: { kind: "CeiRoot", id: root.id }, + properties: {}, + validFrom: ENDED_FROM, + validTo: ENDED_TO, + }, + ], + }), + { onConflict: "error" }, + ); + + expect( + result.errors.map((error) => [error.entityType, error.id]), + ).toEqual([ + ["node", "mid-1"], + ["edge", "e-mid-1"], + ]); + expect(result.nodes.created).toBe(0); + expect(result.edges.created).toBe(0); + expect( + await store.nodes.CeiMid.getById("mid-1" as never), + ).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + } finally { + await backend.close(); + } + }); + + it("reports a purged part with why the payload left it without a whole", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildNestedGraph("oneActive"), + backend, + ); + const root = await store.nodes.CeiRoot.create({}); + const result = await importGraph( + store, + payload({ + nodes: [{ kind: "CeiMid", id: "mid-1", properties: {} }], + edges: [ + { + kind: "ceiMidOf", + id: "e-mid-1", + from: { kind: "CeiMid", id: "mid-1" }, + to: { kind: "CeiRoot", id: root.id }, + properties: {}, + validFrom: ENDED_FROM, + validTo: ENDED_TO, + }, + ], + }), + { onConflict: "error" }, + ); + + const partError = requireDefined( + result.errors.find((error) => error.entityType === "node"), + ).error; + // The payload did carry a composition edge for the part; it ended. + expect(partError).not.toMatch(/no `partOf` was given/u); + expect(partError).toMatch( + /CeiMid "mid-1" was removed: .*held no live whole/u, + ); + } finally { + await backend.close(); + } + }); + + it("refuses a required part attached to a whole that does not exist (validateReferences: false)", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildNestedGraph("one"), + backend, + ); + + // MUTATION CHECK: in `readCompositionPartStandings` + // (src/store/operations/composition-create.ts), report the attachment's + // whole without checking it is a live row — `mid-1` is then accepted + // hanging from a node that was never written. + const result = await importGraph( + store, + payload({ + nodes: [{ kind: "CeiMid", id: "mid-1", properties: {} }], + edges: [ + { + kind: "ceiMidOf", + id: "e-mid-1", + from: { kind: "CeiMid", id: "mid-1" }, + to: { kind: "CeiRoot", id: "ghost" }, + properties: {}, + }, + ], + }), + { onConflict: "error", validateReferences: false }, + ); + + expect( + result.errors.map((error) => [error.entityType, error.id]), + ).toEqual([ + ["node", "mid-1"], + ["edge", "e-mid-1"], + ]); + expect( + await store.nodes.CeiMid.getById("mid-1" as never), + ).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + } finally { + await backend.close(); + } + }); + + it("purges to a fixed point: a required part attached only to a purged part is refused too", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildNestedGraph("oneActive"), + backend, + ); + const root = await store.nodes.CeiRoot.create({}); + + // `mid-1` is named by a composition edge, so it reaches the standing + // read, but that edge is an ended window: no live whole, so it is + // purged. The first pass already accepted `leaf-1` (its whole `mid-1` + // was still a live row); only re-queueing the parts a purged whole held + // finds that `leaf-1` lost its whole. + // + // MUTATION CHECK: in `assertImportedRequiredPartsAttached`, stop after + // the first pass (`candidates = []` instead of re-queueing the parts a + // purged whole held) — `leaf-1` survives with no edge and no whole. + const result = await importGraph( + store, + payload({ + nodes: [ + { kind: "CeiLeaf", id: "leaf-1", properties: {} }, + { kind: "CeiMid", id: "mid-1", properties: {} }, + ], + edges: [ + { + kind: "ceiMidOf", + id: "e-mid-1", + from: { kind: "CeiMid", id: "mid-1" }, + to: { kind: "CeiRoot", id: root.id }, + properties: {}, + validFrom: ENDED_FROM, + validTo: ENDED_TO, + }, + { + kind: "ceiLeafOf", + id: "e-leaf-1", + from: { kind: "CeiLeaf", id: "leaf-1" }, + to: { kind: "CeiMid", id: "mid-1" }, + properties: {}, + }, + ], + }), + { onConflict: "error" }, + ); + + expect(await store.nodes.CeiMid.find({})).toEqual([]); + expect(await store.nodes.CeiLeaf.find({})).toEqual([]); + expect(await store.edges.ceiMidOf.find({})).toEqual([]); + expect(await store.edges.ceiLeafOf.find({})).toEqual([]); + expect(result.nodes.created).toBe(0); + expect(result.edges.created).toBe(0); + expect( + result.errors + .map((error) => `${error.entityType}:${error.id}`) + .toSorted(), + ).toEqual(["edge:e-leaf-1", "edge:e-mid-1", "node:leaf-1", "node:mid-1"]); + expect(await store.verifyConstraintFences()).toEqual([]); + } finally { + await backend.close(); + } + }); + + it("onConflict update refuses a validTo that would end a live required attachment", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildNestedGraph("oneActive"), + backend, + ); + const root = await store.nodes.CeiRoot.create({}); + const mid = await store.nodes.CeiMid.create( + {}, + { partOf: { whole: root, validFrom: ENDED_FROM } }, + ); + const attachments = await store.edges.ceiMidOf.find({}); + const attachment = requireDefined(attachments[0], "the realizing edge"); + const document = (batchSize: number) => + importGraph( + store, + payload({ + nodes: [], + edges: [ + { + kind: "ceiMidOf", + id: attachment.id, + from: { kind: "CeiMid", id: mid.id }, + to: { kind: "CeiRoot", id: root.id }, + properties: {}, + validFrom: ENDED_FROM, + validTo: ENDED_TO, + }, + // A repeat of the same id takes the per-row path after the + // slice's flush, so both update paths are exercised. + ...(batchSize === 1 ? + [] + : [ + { + kind: "ceiMidOf", + id: attachment.id, + from: { kind: "CeiMid", id: mid.id }, + to: { kind: "CeiRoot", id: root.id }, + properties: {}, + validFrom: ENDED_FROM, + validTo: ENDED_TO, + }, + ]), + ], + }), + { onConflict: "update", batchSize }, + ); + + // MUTATION CHECK: drop the `assertCompositionExistencePreserved` call + // from `updateImportedEdge` (src/interchange/import.ts) — the window is + // then ended and `mid` is live with no whole. + for (const batchSize of [1, 100]) { + const result = await document(batchSize); + expect(result.edges.updated).toBe(0); + expect(result.errors).not.toHaveLength(0); + for (const error of result.errors) { + expect(error.error).toMatch(/^COMPOSITION_DETACH_REFUSED: /u); + } + const [edge] = await store.edges.ceiMidOf.find({}); + expect(edge?.meta.validTo).toBeUndefined(); + expect(await store.verifyConstraintFences()).toEqual([]); + } + } finally { + await backend.close(); + } + }); +}); + +describe("streamed import: required composition existence", () => { + it("refuses a streamed import into a graph with required parts before reading any chunk, writing no row", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + const data = payload({ + nodes: [ + { kind: "CeiEpisode", id: "episode", properties: {} }, + { kind: "CeiSegment", id: "segment", properties: {} }, + ], + edges: [ + { + kind: "ceiSegmentOf", + id: "segment-of", + from: { kind: "CeiSegment", id: "segment" }, + to: { kind: "CeiEpisode", id: "episode" }, + properties: {}, + }, + ], + }); + let chunksRead = 0; + async function* chunks(): AsyncGenerator { + await Promise.resolve(); + chunksRead += 1; + yield { + type: "header", + header: { + formatVersion: data.formatVersion, + exportedAt: data.exportedAt, + source: data.source, + }, + }; + chunksRead += 1; + yield { type: "nodes", nodes: data.nodes }; + chunksRead += 1; + yield { type: "edges", edges: data.edges }; + } + + const error = await importGraphStream(store, chunks(), { + onConflict: "error", + batchSize: 100, + }).catch((error_: unknown) => error_); + + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "IMPORT_STREAM_REQUIRED_COMPOSITION_UNSUPPORTED", + }); + expect((error as ConfigurationError).suggestion).toMatch(/importGraph/u); + expect(chunksRead).toBe(0); + expect(await store.nodes.CeiEpisode.find()).toEqual([]); + expect(await store.nodes.CeiSegment.find()).toEqual([]); + } finally { + await backend.close(); + } + }); +}); diff --git a/packages/typegraph/tests/interchange/composition-history-roundtrip.test.ts b/packages/typegraph/tests/interchange/composition-history-roundtrip.test.ts new file mode 100644 index 000000000..ce74a6fa9 --- /dev/null +++ b/packages/typegraph/tests/interchange/composition-history-roundtrip.test.ts @@ -0,0 +1,210 @@ +/** + * Interchange of composition HISTORY: an ended `oneActive` composition row is + * the record of a move, not a current attachment. + * + * Its meaning lives in its `validTo`, so the export carries temporal fields + * for any graph with an active-only cardinality axis (a `oneActive` + * composition pair included) and refuses to drop them, and the import judges + * each ended row the way the store does: outside the composition relation's + * claim and its acyclicity probe. A composition window that starts or ends + * later than now is refused per row, as on every store write. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../../src"; +import { + exportGraph, + FORMAT_VERSION, + type GraphData, + importGraph, +} from "../../src/interchange"; +import { compareStrings } from "../../src/utils/compare"; +import { createTestBackend } from "../test-utils"; + +const ChShow = defineNode("ChShow", { schema: z.object({}) }); +const ChClip = defineNode("ChClip", { schema: z.object({}) }); +const ChSection = defineNode("ChSection", { schema: z.object({}) }); +const chClipOf = defineEdge("chClipOf", { schema: z.object({}) }); +const chSectionOf = defineEdge("chSectionOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "composition_history_roundtrip", + nodes: { + ChShow: { type: ChShow }, + ChClip: { type: ChClip }, + ChSection: { type: ChSection }, + }, + edges: { + chClipOf: { + type: chClipOf, + from: [ChClip], + to: [ChShow], + cardinality: "oneActive", + }, + chSectionOf: { + type: chSectionOf, + from: [ChSection], + to: [ChSection], + cardinality: "oneActive", + }, + }, + ontology: [ + partOf(ChClip, ChShow, { via: chClipOf, existence: "required" }), + partOf(ChSection, ChSection, { via: chSectionOf, partSide: "from" }), + ], +}); + +function attachments( + edges: readonly Readonly<{ fromId: string; toId: string }>[], +): readonly string[] { + return edges + .map((edge) => `${edge.fromId}->${edge.toId}`) + .toSorted(compareStrings); +} + +describe("composition history through export and import", () => { + it("exports every composition window by default, so each reparented part re-imports under its current whole", async () => { + const [source] = await createStoreWithSchema(graph, createTestBackend()); + const before = await source.nodes.ChShow.create({}, { id: "show-before" }); + const after = await source.nodes.ChShow.create({}, { id: "show-after" }); + for (let index = 0; index < 8; index += 1) { + const clip = await source.nodes.ChClip.create( + {}, + { id: `clip-${String(index)}`, partOf: { whole: before } }, + ); + await source.nodes.ChClip.reparent(clip.id, { whole: after }); + } + + // MUTATION CHECK: return `undefined` from `requiredTemporalFieldsRefusal` + // for a graph without identity (src/interchange/export.ts) — the default + // export then drops every `validTo`, each ended row re-imports as a + // current attachment, and whichever of a part's two rows sorts first by + // id takes its claim. + const data = await exportGraph(source); + expect(data.edges).toHaveLength(16); + expect( + data.edges.filter((edge) => edge.validTo !== undefined), + ).toHaveLength(8); + + const [target] = await createStoreWithSchema(graph, createTestBackend()); + const result = await importGraph(target, data, { onConflict: "error" }); + expect(result.errors).toEqual([]); + const current = await target.edges.chClipOf.find({}); + expect(current.every((edge) => edge.toId === after.id)).toBe(true); + expect( + await target.edges.chClipOf.find({}, { temporalMode: "includeEnded" }), + ).toHaveLength(16); + expect(await target.verifyConstraintFences()).toEqual([]); + }); + + it("refuses an export that disables temporal fields on a graph with an active-only cardinality", async () => { + const [source] = await createStoreWithSchema(graph, createTestBackend()); + + await expect( + exportGraph(source, { includeTemporal: false }), + ).rejects.toMatchObject({ + name: "ConfigurationError", + details: { + code: "ACTIVE_CARDINALITY_EXPORT_REQUIRES_TEMPORAL_FIELDS", + edgeKinds: ["chClipOf", "chSectionOf"], + }, + }); + }); + + it("re-imports a reflexive composition history in either row order", async () => { + const [source] = await createStoreWithSchema(graph, createTestBackend()); + const part = await source.nodes.ChSection.create({}); + const formerWhole = await source.nodes.ChSection.create({}); + const currentWhole = await source.nodes.ChSection.create({}); + await source.edges.chSectionOf.create(part, formerWhole, {}); + await source.nodes.ChSection.reparent(part.id, { whole: currentWhole }); + // The former whole now sits under the part: legal, because the row that + // put the part under it is history. + await source.edges.chSectionOf.create(formerWhole, part, {}); + expect(await source.verifyConstraintFences()).toEqual([]); + const data = await exportGraph(source); + + // MUTATION CHECK: hand import's per-row acyclicity probe + // (`processEdge`, src/interchange/import.ts) `validTo: undefined` — the + // history row is then probed as a live member and refused as closing a + // cycle in whichever order lands it after `formerWhole -> part`. + for (const edges of [data.edges, [...data.edges].toReversed()]) { + const [target] = await createStoreWithSchema(graph, createTestBackend()); + const result = await importGraph( + target, + { ...data, edges }, + { onConflict: "error" }, + ); + expect(result.errors).toEqual([]); + expect(attachments(await target.edges.chSectionOf.find({}))).toEqual( + attachments(await source.edges.chSectionOf.find({})), + ); + expect( + await target.edges.chSectionOf.find( + {}, + { temporalMode: "includeEnded" }, + ), + ).toHaveLength(3); + expect(await target.verifyConstraintFences()).toEqual([]); + } + }); + + it("reports a composition edge whose window ends or starts later than now as a per-row error", async () => { + const [target] = await createStoreWithSchema(graph, createTestBackend()); + const whole = await target.nodes.ChSection.create({}, { id: "whole" }); + const later = "2999-01-01T00:00:00.000Z"; + const document: GraphData = { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external", description: "future composition windows" }, + nodes: [ + { kind: "ChSection", id: "part-ends", properties: {} }, + { kind: "ChSection", id: "part-starts", properties: {} }, + ], + edges: [ + { + kind: "chSectionOf", + id: "ends-later", + from: { kind: "ChSection", id: "part-ends" }, + to: { kind: "ChSection", id: whole.id }, + properties: {}, + validTo: later, + }, + { + kind: "chSectionOf", + id: "starts-later", + from: { kind: "ChSection", id: "part-starts" }, + to: { kind: "ChSection", id: whole.id }, + properties: {}, + validFrom: later, + }, + ], + }; + + // MUTATION CHECK: drop `importedCompositionWindowError` from the edge + // validity checks in src/interchange/import.ts — both rows then import, + // one a membership that silently ends later, the other one that starts + // later, and `errors` is empty. + const result = await importGraph(target, document, { onConflict: "error" }); + expect( + result.errors + .map((error) => [error.id, error.error.split(":")[0]]) + .toSorted((left, right) => + compareStrings(String(left[0]), String(right[0])), + ), + ).toEqual([ + ["ends-later", "COMPOSITION_VALIDITY_FUTURE_END"], + ["starts-later", "COMPOSITION_VALIDITY_FUTURE_START"], + ]); + expect( + await target.edges.chSectionOf.find({}, { temporalMode: "includeEnded" }), + ).toEqual([]); + }); +}); diff --git a/packages/typegraph/tests/interchange/composition-import.test.ts b/packages/typegraph/tests/interchange/composition-import.test.ts new file mode 100644 index 000000000..4046a506a --- /dev/null +++ b/packages/typegraph/tests/interchange/composition-import.test.ts @@ -0,0 +1,197 @@ +/** + * Validating import and the composition claim. + * + * `importEdgeInsertWork` (`src/interchange/import.ts`) is the ONE owner of an + * imported edge's insert unit, and it now calls `edgeInsertClaims` — the same + * function every store write calls — instead of `edgeCardinalityClaims` + * directly. This pins that a batch binding one part to two wholes surfaces + * the SAME `CompositionError` a store write does, per row, without aborting + * the rest of the slice: import's existing catch-per-row recovery (each row + * commits inside its own savepoint) is untouched by adding a second claim to + * the row's insert unit. + */ +import Database from "better-sqlite3"; +import { drizzle } from "drizzle-orm/better-sqlite3"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + CompositionError, + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../../src"; +import { generateSqliteDDL } from "../../src/backend/drizzle/ddl"; +import { createSqliteBackend } from "../../src/backend/drizzle/sqlite"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import type { GraphBackend } from "../../src/backend/types"; +import { + FORMAT_VERSION, + type GraphData, + importGraph, +} from "../../src/interchange"; + +const CiChapter = defineNode("CiChapter", { schema: z.object({}) }); +const CiBook = defineNode("CiBook", { schema: z.object({}) }); +const CiAnthology = defineNode("CiAnthology", { schema: z.object({}) }); + +const ciChapterOf = defineEdge("ciChapterOf", { schema: z.object({}) }); +const ciIncludedIn = defineEdge("ciIncludedIn", { schema: z.object({}) }); + +function buildGraph() { + return defineGraph({ + id: "composition-import", + nodes: { + CiChapter: { type: CiChapter }, + CiBook: { type: CiBook }, + CiAnthology: { type: CiAnthology }, + }, + edges: { + ciChapterOf: { + type: ciChapterOf, + from: [CiChapter], + to: [CiBook], + cardinality: "one", + }, + ciIncludedIn: { + type: ciIncludedIn, + from: [CiChapter], + to: [CiAnthology], + cardinality: "one", + }, + }, + ontology: [ + partOf(CiChapter, CiBook, { via: ciChapterOf }), + partOf(CiChapter, CiAnthology, { via: ciIncludedIn }), + ], + }); +} + +function payload(edges: GraphData["edges"]): GraphData { + return { + formatVersion: FORMAT_VERSION, + exportedAt: new Date().toISOString(), + source: { type: "external", description: "composition-import test" }, + nodes: [], + edges, + }; +} + +describe("validating import: composition claim", () => { + it("refuses the second edge binding one part to a second whole, committing the first (catch-per-row)", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + const chapter = await store.nodes.CiChapter.create({}); + const book = await store.nodes.CiBook.create({}); + const anthology = await store.nodes.CiAnthology.create({}); + + const result = await importGraph( + store, + payload([ + { + kind: "ciChapterOf", + id: "e-chapter-of", + from: { kind: "CiChapter", id: chapter.id }, + to: { kind: "CiBook", id: book.id }, + properties: {}, + }, + { + kind: "ciIncludedIn", + id: "e-included-in", + from: { kind: "CiChapter", id: chapter.id }, + to: { kind: "CiAnthology", id: anthology.id }, + properties: {}, + }, + ]), + { onConflict: "error", batchSize: 100 }, + ); + + // The first row commits... + expect(result.edges.created).toBe(1); + expect(await store.edges.ciChapterOf.findFrom(chapter)).toHaveLength(1); + // ...the second is refused per-row, not as a whole-batch abort. + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.id).toBe("e-included-in"); + expect(result.errors[0]?.error).toMatch(/already has a whole/u); + expect(await store.edges.ciIncludedIn.findFrom(chapter)).toHaveLength(0); + } finally { + await backend.close(); + } + }); + // MUTATION CHECK: revert `importEdgeInsertWork` + // (src/interchange/import.ts) to call `edgeCardinalityClaims` directly + // instead of `edgeInsertClaims`. Both rows then satisfy their own, + // independent cardinality axis (one edge per kind per chapter) and + // `result.errors` is empty — the second `expect` above fails. + + it("refuses through the store the same way a validating import row does (same typed error)", async () => { + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema(buildGraph(), backend); + const chapter = await store.nodes.CiChapter.create({}); + const book = await store.nodes.CiBook.create({}); + const anthology = await store.nodes.CiAnthology.create({}); + await store.edges.ciChapterOf.create(chapter, book, {}); + + await expect( + store.edges.ciIncludedIn.create(chapter, anthology, {}), + ).rejects.toBeInstanceOf(CompositionError); + } finally { + await backend.close(); + } + }); + // Companion to the import case above — proves a store write and a + // validating-import row refuse with the SAME typed error rather than two + // independent spellings of "the part already has a whole". + + it("refuses the whole import up front on a backend with no transactions, naming edgeComposition (E7)", async () => { + const sqlite = new Database(":memory:"); + try { + const db = drizzle(sqlite); + for (const statement of generateSqliteDDL()) sqlite.exec(statement); + const backend: GraphBackend = createSqliteBackend(db, { + executionProfile: { transactionMode: "none", isSync: true }, + }); + const store = createStore(buildGraph(), backend); + + // `graphOwesClaims` (src/store/constraints.ts) is asked directly here + // — this graph declares no OTHER constrained edge kind, so a fold that + // never reaches the composition arm would find nothing to refuse on + // and let the import proceed unfenced. + await expect( + importGraph( + store, + payload([ + { + kind: "ciChapterOf", + id: "e-chapter-of", + from: { kind: "CiChapter", id: "c1" }, + to: { kind: "CiBook", id: "b1" }, + properties: {}, + }, + ]), + { onConflict: "error", batchSize: 100 }, + ), + ).rejects.toMatchObject({ + details: { + code: "CONSTRAINT_WRITE_FENCE_UNSUPPORTED", + constraint: "edgeComposition", + }, + }); + } finally { + sqlite.close(); + } + }); + // MUTATION CHECK: revert `graphOwesClaims`'s edge loop + // (src/store/constraints.ts) to its pre-fix inline + // `edgeCardinalityAxisReferences(registration).length > 0 -> + // "edgeCardinality"` spelling. This graph's edge kinds are composition-only + // (no plain `cardinality`/`targetCardinality` outside what composition + // itself requires), so the reported `constraint` becomes `"edgeCardinality"` + // instead of `"edgeComposition"` and the `toMatchObject` assertion above + // fails (verified, reverted). +}); diff --git a/packages/typegraph/tests/lock-fence-plan-inventory.test.ts b/packages/typegraph/tests/lock-fence-plan-inventory.test.ts index 7f06ce120..3b02e9176 100644 --- a/packages/typegraph/tests/lock-fence-plan-inventory.test.ts +++ b/packages/typegraph/tests/lock-fence-plan-inventory.test.ts @@ -4,7 +4,7 @@ * Three ratchets, all comment-stripped AST scans over `src/**` (modelled on * `tests/recursive-traversal-inventory.test.ts`): * - * 1. `resolveWriteFencePlan` has exactly **23** call sites — the 8 lock + * 1. `resolveWriteFencePlan` has exactly **25** call sites — the 8 lock * sites (J1-J8), the 2 construction gates (J9a recorded-clock * ownership, J9b identity), the adopted-transaction writer-slot proof * (J9c), the 3 consumers of the PostgreSQL schema fence (J14 @@ -14,7 +14,12 @@ * trusted-import table lock (J18), and SQLite's own schema-commit/ * writer fence resolution (J19, the `row`-mechanism counterpart to * J14/J15), plus adopted merge writer-slot acquisition (J20) and - * composed merge snapshot enforcement (J21), adopted schema fence evidence (J22), and the two node-row lock omissions under a writer-serializing plan (J23 SQLite's closed node-delete program, J24 hard delete's lock read, J25 import's skipped-node endpoint read) — enumerated in both + * composed merge snapshot enforcement (J21), adopted schema fence + * evidence (J22), the store-owned write unit's replay decision (J23), + * and the node-row lock omissions under a writer-serializing plan (J24 + * SQLite's closed node-delete program, J25 hard delete's lock read, J26 + * import's skipped-node endpoint read, J27 a composition cascade's + * member locks) — enumerated in both * directions, keyed on `(file, trimmed * line)` so line drift cannot rot the pin. * @@ -106,7 +111,7 @@ type InventoryEntry = Readonly<{ reason: string; }>; -/** The 20 `resolveWriteFencePlan` call sites (Contract J, I8). */ +/** The 25 `resolveWriteFencePlan` call sites (Contract J, I8). */ const CALL_SITES: readonly InventoryEntry[] = [ { file: "backend/drizzle/postgres.ts", @@ -185,6 +190,13 @@ const CALL_SITES: readonly InventoryEntry[] = [ reason: "The recorded-clock-allocation construction gate refuses an unfenced backend before any statement runs.", }, + { + file: "store/operations/write-transaction.ts", + line: "const plan = resolveWriteFencePlan(backend);", + site: "J23", + reason: + "A store-owned transaction replays on a stale-snapshot refusal only when its graph-write fence is a keyed acquisition, whose deciding form advances the fence row; a serialized engine takes none and never replays.", + }, { file: "store/operations/write-transaction.ts", line: 'resolveWriteFencePlan(target).kind !== "engine-serialized"', @@ -251,24 +263,31 @@ const CALL_SITES: readonly InventoryEntry[] = [ { file: "backend/drizzle/sqlite.ts", line: "writersSerialize: serializesWriters(resolveWriteFencePlan(fenceTarget)),", - site: "J23", + site: "J24", reason: "The SQLite operation layer resolves the plan to learn whether its writers serialize; under engine-serialized (every bundled SQLite driver) the closed node-delete program omits its leading row-lock slots, which would lock nothing. PostgreSQL reads the same fact off J16's plan.", }, { file: "store/operations/node-operations.ts", line: "if (!serializesWriters(resolveWriteFencePlan(target))) {", - site: "J24", + site: "J25", reason: "A hard delete takes the node's delete lock before its cascade reads connected edges only where writers contend; a writer-serializing plan (engine- or caller-serialized) has no concurrent edge write to wait for, so it pays no lock statement.", }, { file: "interchange/import.ts", line: "endpointLocksContend: !serializesWriters(resolveWriteFencePlan(target)),", - site: "J25", + site: "J26", reason: "An import attempt resolves the plan once to learn whether other writers can contend for endpoint rows; under a writer-serializing plan a live node the import skipped needs no locked re-read, as before endpoint locks existed.", }, + { + file: "store/operations/node-operations.ts", + line: "if (serializesWriters(resolveWriteFencePlan(target))) {", + site: "J27", + reason: + "A composition cascade reads its members' liveness through each member's delete lock, before it reads the members' connected edges, only where writers contend, the same rule as J25's hard delete; a writer-serializing plan has no concurrent write to wait for and reads the members unlocked.", + }, ]; /** The 5 files every lock site (J1-J8) lives in — zero dialect literals here. */ @@ -430,13 +449,13 @@ function scanSourceTree( ); } -describe("T17 — resolveWriteFencePlan has exactly 23 call sites", () => { +describe("T17 — resolveWriteFencePlan has exactly 25 call sites", () => { const found = scanSourceTree(scanForResolveCalls); const diff = diffAgainstInventory(found, CALL_SITES); - it("has exactly the 23 declared call sites, both directions", () => { - expect(found).toHaveLength(23); - expect(CALL_SITES).toHaveLength(23); + it("has exactly the 25 declared call sites, both directions", () => { + expect(found).toHaveLength(25); + expect(CALL_SITES).toHaveLength(25); const undeclaredReport = diff.undeclared.map( (site) => `${site.file}:${String(site.lineNumber)} ${site.line}`, ); diff --git a/packages/typegraph/tests/logical-plan.test.ts b/packages/typegraph/tests/logical-plan.test.ts index d1e92672f..00dd64298 100644 --- a/packages/typegraph/tests/logical-plan.test.ts +++ b/packages/typegraph/tests/logical-plan.test.ts @@ -66,7 +66,7 @@ function createBaseAst(extra: Partial = {}): QueryAst { return { start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"], }, traversals: [], diff --git a/packages/typegraph/tests/matches-predicate.test.ts b/packages/typegraph/tests/matches-predicate.test.ts index fbf669d48..887ab3e88 100644 --- a/packages/typegraph/tests/matches-predicate.test.ts +++ b/packages/typegraph/tests/matches-predicate.test.ts @@ -569,13 +569,13 @@ describe(".matches() with polymorphic alias", () => { body: "casual take", }); - // includeSubClasses expands "Content" to ["Content", "Article", "BlogPost"]. + // expansion: "subclasses" expands "Content" to ["Content", "Article", "BlogPost"]. // The polymorphic accessor types lose individual field typing; the // runtime introspector still recognizes title as searchable across // all three kinds and emits a node_kind IN (...) filter. const results = await store .query() - .from("Content", "d", { includeSubClasses: true }) + .from("Content", "d", { expansion: "subclasses" }) .whereNode("d", (d) => (d as unknown as { $fulltext: FulltextAccessor }).$fulltext.matches( "renewable", @@ -589,12 +589,12 @@ describe(".matches() with polymorphic alias", () => { expect(ids).toEqual([article.id, blog.id].toSorted()); }); - it("rejects polymorphic matches when any subclass lacks searchable() fields", async () => { - // Parent + two subclasses where only one declares `searchable()`. - // `hasSearchableField` uses `.every()`, so the runtime guard throws - // rather than silently producing partial results across the mixed - // set. This pins the behavior for the kbgraph-style shape where a - // parent kind has heterogeneous children. + it("rejects polymorphic matches when the parent kind lacks searchable() fields", async () => { + // Parent + two subclasses where only one subclass declares + // `searchable()`. The parent alias reads the parent's declaration, which + // has no searchable content, so the runtime guard throws and names the + // parent kind rather than silently producing partial results across the + // mixed set. const Media = defineNode("Media", { schema: z.object({ title: z.string() }), }); @@ -627,7 +627,7 @@ describe(".matches() with polymorphic alias", () => { expect(() => mixedStore .query() - .from("Media", "m", { includeSubClasses: true }) + .from("Media", "m", { expansion: "subclasses" }) .whereNode("m", (m) => (m as unknown as { $fulltext: FulltextAccessor }).$fulltext.matches( "searchable", @@ -636,7 +636,7 @@ describe(".matches() with polymorphic alias", () => { ) .select((ctx) => ({ id: ctx.m.id })) .execute(), - ).toThrow(/searchable\(\)/i); + ).toThrow(/kind "Media" has no fields declared with searchable\(\)/); }); it("hybrid SQL appends user orderBy as RRF tiebreaker", () => { @@ -783,7 +783,7 @@ describe(".matches() with polymorphic alias", () => { const results = await store .query() - .from("Content", "d", { includeSubClasses: true }) + .from("Content", "d", { expansion: "subclasses" }) .whereNode("d", (d) => (d as unknown as { $fulltext: FulltextAccessor }).$fulltext.matches( "quantum", @@ -832,7 +832,7 @@ describe(".matches() with polymorphic alias", () => { buildKindRegistry(VectorPolyGraph), ); const ast = polyQuery - .from("PolyVector", "v", { includeSubClasses: true }) + .from("PolyVector", "v", { expansion: "subclasses" }) .whereNode("v", (v) => (v as unknown as PropsAccessor).embedding.similarTo( queryEmbedding, diff --git a/packages/typegraph/tests/meta-edge-removal-migration.test.ts b/packages/typegraph/tests/meta-edge-removal-migration.test.ts new file mode 100644 index 000000000..59f942675 --- /dev/null +++ b/packages/typegraph/tests/meta-edge-removal-migration.test.ts @@ -0,0 +1,607 @@ +/** + * Removal of the public custom `metaEdge()` factory, the public + * `InferenceType` union, `MetaEdgeProperties.{transitive,symmetric,reflexive, + * inverse,inference}`, and the deprecated `sameAs`/`differentFrom` factories. + * + * Every test in this file is load-bearing — each MUTATION CHECK comment + * below states in full the code change that must flip the test red: + * + * - "still loads": a document persisted by pre-removal code (an old-shape + * `metaEdges` catalog entry with the five now-internal fields, and a + * `sameAs` relation) must still parse and build a `KindRegistry`. + * - "sameAs -> equivalentTo": the classifier's verdict on a store opened + * after the code migrates a `sameAs(A, B)` declaration to `equivalentTo(A, + * B)` is warning-only (never breaking) — it auto-migrates on open. + * - "differentFrom dropped": the classifier's verdict on a store opened + * after the code simply deletes a `differentFrom(A, B)` declaration is + * safe — it auto-migrates on open. + * - "custom meta-edge dropped": a persisted relation naming a custom + * meta-edge is the last copy of a relation the graph can no longer + * declare, so its removal is breaking, names the relation, and commits + * only through an explicit `migrateSchema()`. `store.evolve()`, + * `store.planEvolution()` and `store.removeKinds()`, which commit without + * `ensureSchema`'s severity gate, refuse it before writing anything. + * + * The `describe` blocks that call `computeSchemaDiff` and + * `deserializeSchema` directly test the classification math in isolation. + * The two `ensureSchema` blocks drive the same migrations end to end against + * a real backend, the way a booting `Store` actually encounters a + * pre-removal document: committed schema rows, not diff literals, and the + * status the schema manager actually returns. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + defineGraph, + defineNode, + equivalentTo, + type GraphBackend, +} from "../src"; +import { MigrationError } from "../src/errors"; +import { defineGraphExtension } from "../src/graph-extension"; +import { deserializeSchema } from "../src/schema/deserializer"; +import { + ensureSchema, + migrateSchema, + parseSerializedSchema, +} from "../src/schema/manager"; +import { + computeSchemaDiff, + getMigrationActions, +} from "../src/schema/migration"; +import { computeSchemaHash, serializeSchema } from "../src/schema/serializer"; +import { + type SerializedNodeDef, + type SerializedOntology, + type SerializedSchema, + serializedSchemaZod, +} from "../src/schema/types"; +import { createStoreWithSchema } from "../src/store/store"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +// ============================================================ +// Fixtures +// ============================================================ + +function nodeDef(kind: string): SerializedNodeDef { + return { + kind, + properties: { type: "object", properties: {} }, + uniqueConstraints: [], + onDelete: "restrict", + description: undefined, + }; +} + +const EMPTY_CLOSURES = { + subClassAncestors: {}, + subClassDescendants: {}, + broaderClosure: {}, + narrowerClosure: {}, + equivalenceSets: {}, + disjointPairs: [], + partOfClosure: {}, + hasPartClosure: {}, + iriToKind: {}, + edgeInverses: {}, + edgeImplicationsClosure: {}, + edgeImplyingClosure: {}, +}; + +function createSchema(ontology: SerializedOntology): SerializedSchema { + return { + graphId: "meta-edge-removal-migration", + version: 1, + generatedAt: "2024-01-01T00:00:00Z", + nodes: { A: nodeDef("A"), B: nodeDef("B") }, + edges: {}, + ontology, + defaults: { onNodeDelete: "restrict", temporalMode: "current" }, + }; +} + +describe("a pre-removal persisted document still loads", () => { + it("parses a `sameAs` relation and an old-shape metaEdges catalog entry, and builds a registry that folds sameAs like equivalentTo", () => { + // Exactly the shape pre-removal code would have serialized: the + // `metaEdges` catalog entry still carries `transitive`/`symmetric`/ + // `reflexive`/`inverse`/`inference` (removed from `SerializedMetaEdge` + // and from the zod validator's required fields by this change) — a + // literal, not `SerializedSchema`-typed, because current code can no + // longer describe this shape; that IS the point of the fixture. + const legacyDocument = { + graphId: "legacy-sameas", + version: 1, + generatedAt: "2023-01-01T00:00:00Z", + nodes: { A: nodeDef("A"), B: nodeDef("B") }, + edges: {}, + ontology: { + metaEdges: { + sameAs: { + name: "sameAs", + transitive: true, + symmetric: true, + reflexive: false, + inverse: undefined, + inference: "substitution", + description: "Deprecated type-level equivalence alias", + }, + }, + relations: [{ metaEdge: "sameAs", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }, + defaults: { onNodeDelete: "restrict", temporalMode: "current" }, + }; + + // MUTATION CHECK: changing the per-meta-edge zod object's `.loose()` + // (src/schema/types.ts) to `.strict()` makes this `safeParse` fail on + // the legacy `sameAs` entry's now-unrecognized `transitive`/`symmetric`/ + // `reflexive`/`inverse`/`inference` fields — restored after the check. + const parsed = serializedSchemaZod.safeParse(legacyDocument); + expect(parsed.success).toBe(true); + + const registry = deserializeSchema( + legacyDocument as unknown as SerializedSchema, + ).buildRegistry(); + expect(registry.areEquivalent("A", "B")).toBe(true); + }); +}); + +describe("migrating sameAs(A, B) to equivalentTo(A, B) auto-migrates", () => { + it("classifies the change as warning-only, never breaking", () => { + // `metaEdges` populated the way the real serializer derives it (1:1 + // from `relations`) — a synthetic catalog-only fixture would not + // exercise the dropped catalog diff arm the mutation check below relies + // on (see `SerializedOntology.metaEdges`'s docblock). + const before = createSchema({ + metaEdges: { sameAs: { name: "sameAs", description: undefined } }, + relations: [{ metaEdge: "sameAs", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }); + const after = createSchema({ + metaEdges: { + equivalentTo: { name: "equivalentTo", description: undefined }, + }, + relations: [{ metaEdge: "equivalentTo", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }); + + const diff = computeSchemaDiff(before, after); + + // MUTATION CHECK: restoring the dropped meta-edge-CATALOG diff arm in + // `classifyOntologyChanges` (`src/schema/ontology-change.ts`) — which + // unconditionally marked any meta-edge name disappearing from the + // catalog `breaking` — flips `hasBreakingChanges` to `true` here, since + // `sameAs` leaves the catalog in this exact migration. Restored after + // the check. + expect(diff.hasBreakingChanges).toBe(false); + expect(diff.isBackwardsCompatible).toBe(true); + expect(diff.ontology).toHaveLength(2); + expect(diff.ontology).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: "removed", + entity: "relation", + severity: "warning", + }), + expect.objectContaining({ + type: "added", + entity: "relation", + severity: "warning", + }), + ]), + ); + }); +}); + +describe("dropping a differentFrom(A, B) declaration auto-migrates", () => { + it("classifies the removal as safe", () => { + const before = createSchema({ + metaEdges: { + differentFrom: { name: "differentFrom", description: undefined }, + }, + relations: [{ metaEdge: "differentFrom", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }); + const after = createSchema({ + metaEdges: {}, + relations: [], + closures: EMPTY_CLOSURES, + }); + + const diff = computeSchemaDiff(before, after); + + expect(diff.hasBreakingChanges).toBe(false); + expect(diff.isBackwardsCompatible).toBe(true); + expect(diff.ontology).toHaveLength(1); + expect(diff.ontology[0]).toMatchObject({ + type: "removed", + entity: "relation", + severity: "safe", + }); + }); +}); + +const CUSTOM_RELATIONS_ONTOLOGY: SerializedOntology = { + metaEdges: { + governs: { name: "governs", description: "Team governs a project" }, + audits: { name: "audits", description: undefined }, + }, + relations: [ + { metaEdge: "governs", from: "A", to: "B" }, + { metaEdge: "audits", from: "B", to: "A" }, + ], + closures: EMPTY_CLOSURES, +}; + +describe("dropping a relation that names a custom meta-edge is breaking", () => { + it("classifies each removal as breaking and lists every relation in one migration action", () => { + const before = createSchema(CUSTOM_RELATIONS_ONTOLOGY); + const after = createSchema({ + metaEdges: {}, + relations: [], + closures: EMPTY_CLOSURES, + }); + + const diff = computeSchemaDiff(before, after); + + expect(diff.hasBreakingChanges).toBe(true); + expect(diff.isBackwardsCompatible).toBe(false); + const removedCustomRelations = diff.ontology.map((change) => ({ + type: change.type, + severity: change.severity, + removedCustomRelation: change.removedCustomRelation, + })); + expect(removedCustomRelations).toEqual( + expect.arrayContaining([ + { + type: "removed", + severity: "breaking", + removedCustomRelation: "governs(A, B)", + }, + { + type: "removed", + severity: "breaking", + removedCustomRelation: "audits(B, A)", + }, + ]), + ); + const governs = diff.ontology.find( + (change) => change.removedCustomRelation === "governs(A, B)", + ); + expect(governs?.details).toContain("governs(A, B)"); + expect(governs?.details).toContain("defineGraph({ annotations })"); + + const moveActions = getMigrationActions(diff).filter((action) => + action.startsWith("MOVE custom ontology relations"), + ); + expect(moveActions).toHaveLength(1); + expect(moveActions[0]).toContain("governs(A, B)"); + expect(moveActions[0]).toContain("audits(B, A)"); + expect(moveActions[0]).toContain("defineGraph({ annotations })"); + }); +}); + +describe("dropping a custom-meta-edge relation alongside a kind it names", () => { + it("still names the relation in the migration action, though the removed-kind rule keeps it safe", () => { + const before: SerializedSchema = { + ...createSchema({ + metaEdges: { governs: { name: "governs", description: undefined } }, + relations: [{ metaEdge: "governs", from: "A", to: "X" }], + closures: EMPTY_CLOSURES, + }), + nodes: { A: nodeDef("A"), B: nodeDef("B"), X: nodeDef("X") }, + }; + const after = createSchema({ + metaEdges: {}, + relations: [], + closures: EMPTY_CLOSURES, + }); + + const diff = computeSchemaDiff(before, after); + + expect(diff.ontology).toEqual([ + expect.objectContaining({ + type: "removed", + severity: "safe", + removedCustomRelation: "governs(A, X)", + }), + ]); + expect(getMigrationActions(diff)).toContainEqual( + expect.stringContaining("MOVE custom ontology relations governs(A, X)"), + ); + }); +}); + +describe("ensureSchema against a persisted pre-removal document", () => { + const A = defineNode("A", { schema: z.object({}) }); + const B = defineNode("B", { schema: z.object({}) }); + + const baseGraph = defineGraph({ + id: "meta-edge-removal-ensure-schema", + nodes: { A: { type: A }, B: { type: B } }, + edges: {}, + }); + + // Simulates "an earlier process, running pre-removal code, persisted this + // document": initializes the backend normally (so the A/B tables exist), + // then commits a v2 schema row whose `ontology` is hand-edited to the + // pre-removal shape — a shape today's `serializeSchema` can no longer + // produce, which is exactly the point of the fixture. + async function persistLegacyOntologyDocument( + backend: GraphBackend, + legacyOntology: SerializedOntology, + ): Promise { + const [, initial] = await createStoreWithSchema(baseGraph, backend); + expect(initial.status).toBe("initialized"); + + const legacySchema: SerializedSchema = { + ...serializeSchema(baseGraph, 2), + ontology: legacyOntology, + }; + const legacyHash = await computeSchemaHash(legacySchema); + await backend.commitSchemaVersion({ + graphId: baseGraph.id, + expected: { kind: "active", version: 1 }, + version: 2, + schemaHash: legacyHash, + schemaDoc: legacySchema, + }); + } + + async function activeRelations( + backend: GraphBackend, + ): Promise> { + const row = requireDefined(await backend.getActiveSchema(baseGraph.id)); + const document = parseSerializedSchema(row.schema_doc); + return { + version: row.version, + metaEdges: document.ontology.relations.map( + (relation) => relation.metaEdge, + ), + }; + } + + it("reports a persisted custom-meta-edge relation as breaking and does not commit its removal", async () => { + const backend = createTestBackend(); + await persistLegacyOntologyDocument(backend, CUSTOM_RELATIONS_ONTOLOGY); + + const result = await ensureSchema(backend, baseGraph, { + autoMigrate: true, + throwOnBreaking: false, + }); + + expect(result.status).toBe("breaking"); + const actions = result.status === "breaking" ? result.actions : []; + expect(actions).toEqual([expect.stringContaining("governs(A, B)")]); + expect(actions[0]).toContain("audits(B, A)"); + // The stored document still parses with its unknown meta-edge names. + expect(await activeRelations(backend)).toEqual({ + version: 2, + metaEdges: ["governs", "audits"], + }); + await expect(ensureSchema(backend, baseGraph)).rejects.toThrow( + MigrationError, + ); + }); + + it("commits the removal through an explicit migrateSchema()", async () => { + const backend = createTestBackend(); + await persistLegacyOntologyDocument(backend, CUSTOM_RELATIONS_ONTOLOGY); + + const committedVersion = await migrateSchema(backend, baseGraph, 2); + + expect(committedVersion).toBe(3); + expect(await activeRelations(backend)).toEqual({ + version: 3, + metaEdges: [], + }); + const reopened = await ensureSchema(backend, baseGraph); + expect(reopened.status).toBe("unchanged"); + }); + + it("migrates a persisted sameAs(A, B) relation to equivalentTo(A, B) on open", async () => { + const backend = createTestBackend(); + await persistLegacyOntologyDocument(backend, { + metaEdges: { + sameAs: { + name: "sameAs", + description: "Deprecated type-level equivalence alias", + }, + }, + relations: [{ metaEdge: "sameAs", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }); + + const migratedGraph = defineGraph({ + id: baseGraph.id, + nodes: { A: { type: A }, B: { type: B } }, + edges: {}, + ontology: [equivalentTo(A, B)], + }); + + // MUTATION CHECK: restoring the dropped meta-edge-CATALOG diff arm in + // `classifyOntologyChanges` (`src/schema/ontology-change.ts`) flips this + // to `status: "breaking"` (and, with `throwOnBreaking` defaulted true, + // a thrown `MigrationError`) — `sameAs` leaves the catalog in this exact + // migration. Restored after the check. + const result = await ensureSchema(backend, migratedGraph); + expect(result.status).toBe("migrated"); + }); + + it("migrates a persisted differentFrom(A, B) relation by dropping it on open", async () => { + const backend = createTestBackend(); + await persistLegacyOntologyDocument(backend, { + metaEdges: { + differentFrom: { + name: "differentFrom", + description: "Deprecated decorative type-level non-identity relation", + }, + }, + relations: [{ metaEdge: "differentFrom", from: "A", to: "B" }], + closures: EMPTY_CLOSURES, + }); + + const migratedGraph = defineGraph({ + id: baseGraph.id, + nodes: { A: { type: A }, B: { type: B } }, + edges: {}, + }); + + const result = await ensureSchema(backend, migratedGraph); + expect(result.status).toBe("migrated"); + }); +}); + +async function refusal(promise: Promise): Promise { + const outcome = await promise.then( + (value: unknown) => ({ resolved: value }), + (error: unknown) => error, + ); + console.log("custom relation refusal:", outcome); + expect(outcome).toBeInstanceOf(MigrationError); + return outcome as MigrationError; +} + +describe("store verbs that commit without ensureSchema refuse to drop a persisted custom-meta-edge relation", () => { + const A = defineNode("A", { schema: z.object({}) }); + const B = defineNode("B", { schema: z.object({}) }); + + const baseGraph = defineGraph({ + id: "meta-edge-removal-store-verbs", + nodes: { A: { type: A }, B: { type: B } }, + edges: {}, + }); + + const documentExtension = defineGraphExtension({ + nodes: { Doc: { properties: { title: { type: "string" } } } }, + }); + + // An earlier process committed `ontology` on top of whatever the active + // document holds (its extension included), as pre-removal code would have. + async function persistCustomRelations( + backend: GraphBackend, + ontology: SerializedOntology, + ): Promise { + const row = requireDefined(await backend.getActiveSchema(baseGraph.id)); + const version = row.version + 1; + const document: SerializedSchema = { + ...parseSerializedSchema(row.schema_doc), + version, + ontology, + }; + await backend.commitSchemaVersion({ + graphId: baseGraph.id, + expected: { kind: "active", version: row.version }, + version, + schemaHash: await computeSchemaHash(document), + schemaDoc: document, + }); + return version; + } + + async function activeRelations( + backend: GraphBackend, + ): Promise> { + const row = requireDefined(await backend.getActiveSchema(baseGraph.id)); + return { + version: row.version, + relations: parseSerializedSchema(row.schema_doc).ontology.relations.map( + (relation) => `${relation.metaEdge}(${relation.from}, ${relation.to})`, + ), + }; + } + + it("store.evolve() on a store createStoreWithSchema opened despite the breaking status refuses and commits nothing", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(baseGraph, backend); + await persistCustomRelations(backend, CUSTOM_RELATIONS_ONTOLOGY); + const [store, opened] = await createStoreWithSchema(baseGraph, backend, { + throwOnBreaking: false, + }); + expect(opened.status).toBe("breaking"); + + const error = await refusal(store.evolve(documentExtension)); + + expect(error.details).toEqual({ + graphId: baseGraph.id, + fromVersion: 2, + toVersion: 3, + reason: "custom-relation-removal", + relations: ["governs(A, B)", "audits(B, A)"], + }); + expect(error.message).toContain( + "MOVE custom ontology relations governs(A, B), audits(B, A)", + ); + expect(await activeRelations(backend)).toEqual({ + version: 2, + relations: ["governs(A, B)", "audits(B, A)"], + }); + expect(store.getNodeCollection("Doc")).toBeUndefined(); + }); + + it("store.planEvolution() on a plainly constructed store refuses before any apply", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(baseGraph, backend); + await persistCustomRelations(backend, CUSTOM_RELATIONS_ONTOLOGY); + const store = createStore(baseGraph, backend); + + const error = await refusal(store.planEvolution(documentExtension)); + + expect(error.details).toMatchObject({ + reason: "custom-relation-removal", + relations: ["governs(A, B)", "audits(B, A)"], + }); + expect(await activeRelations(backend)).toMatchObject({ version: 2 }); + }); + + it("store.removeKinds() refuses, naming a relation on a surviving kind and one on the removed kind", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + await store.evolve(documentExtension); + const version = await persistCustomRelations(backend, { + metaEdges: { + governs: { name: "governs", description: undefined }, + cites: { name: "cites", description: undefined }, + }, + relations: [ + { metaEdge: "governs", from: "A", to: "B" }, + { metaEdge: "cites", from: "A", to: "Doc" }, + ], + closures: EMPTY_CLOSURES, + }); + + const error = await refusal( + createStore(baseGraph, backend).removeKinds(["Doc"]), + ); + + expect(error.details).toMatchObject({ + reason: "custom-relation-removal", + fromVersion: version, + relations: ["governs(A, B)", "cites(A, Doc)"], + }); + expect(await activeRelations(backend)).toEqual({ + version, + relations: ["governs(A, B)", "cites(A, Doc)"], + }); + }); + + it("store.evolve() commits once migrateSchema() has dropped the relations", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(baseGraph, backend); + await persistCustomRelations(backend, CUSTOM_RELATIONS_ONTOLOGY); + await migrateSchema(backend, baseGraph, 2); + + const evolved = await createStore(baseGraph, backend).evolve( + documentExtension, + ); + + expect(evolved.getNodeCollection("Doc")).toBeDefined(); + expect(await activeRelations(backend)).toEqual({ + version: 4, + relations: [], + }); + }); +}); diff --git a/packages/typegraph/tests/migration-diff.test.ts b/packages/typegraph/tests/migration-diff.test.ts index 79e56853d..9add223ed 100644 --- a/packages/typegraph/tests/migration-diff.test.ts +++ b/packages/typegraph/tests/migration-diff.test.ts @@ -770,6 +770,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -798,6 +799,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -827,6 +829,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -841,6 +844,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -869,6 +873,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -883,6 +888,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person", "Organization"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -909,6 +915,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Company"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -923,6 +930,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Company"], properties: { type: "object", properties: {} }, cardinality: "one", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -976,6 +984,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Company"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -993,6 +1002,7 @@ describe("computeSchemaDiff", () => { properties: { role: { type: "string" } }, }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1019,6 +1029,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, annotations: { @@ -1036,6 +1047,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, annotations: { @@ -1068,6 +1080,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1082,6 +1095,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person", "Organization"], // toKinds changed properties: { type: "object", properties: {} }, cardinality: "one", // cardinality changed + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1103,23 +1117,18 @@ describe("computeSchemaDiff", () => { // ============================================================ describe("ontology changes", () => { - it("detects added meta-edge as safe change", () => { + // `SerializedOntology.metaEdges` is derived 1:1 from `relations` by the + // serializer (see its docblock in `src/schema/types.ts`) — the classifier + // has exactly one owner for ontology-change severity, the relation-level + // diff below, so these two cases exercise that diff directly rather than + // a synthetic catalog-only change a real serializer never produces. + it("detects an added relation with an inert meta-edge as a safe change", () => { const before = createSchema({ version: 1 }); const after = createSchema({ version: 2, ontology: { ...emptyOntology(), - metaEdges: { - subClassOf: { - name: "subClassOf", - transitive: true, - symmetric: false, - reflexive: false, - inverse: undefined, - inference: "none", - description: undefined, - }, - }, + relations: [{ metaEdge: "broader", from: "Employee", to: "Person" }], }, }); @@ -1130,28 +1139,18 @@ describe("computeSchemaDiff", () => { expect(diff.ontology).toHaveLength(1); expect(diff.ontology[0]).toMatchObject({ type: "added", - entity: "metaEdge", - name: "subClassOf", + entity: "relation", severity: "safe", }); + expect(requireDefined(diff.ontology[0]).details).toContain("broader"); }); - it("detects removed meta-edge as breaking change", () => { + it("detects a removed inverseOf relation as a breaking change", () => { const before = createSchema({ version: 1, ontology: { ...emptyOntology(), - metaEdges: { - subClassOf: { - name: "subClassOf", - transitive: true, - symmetric: false, - reflexive: false, - inverse: undefined, - inference: "none", - description: undefined, - }, - }, + relations: [{ metaEdge: "inverseOf", from: "likes", to: "likedBy" }], }, }); const after = createSchema({ version: 2 }); @@ -1163,13 +1162,12 @@ describe("computeSchemaDiff", () => { expect(diff.ontology).toHaveLength(1); expect(diff.ontology[0]).toMatchObject({ type: "removed", - entity: "metaEdge", - name: "subClassOf", + entity: "relation", severity: "breaking", }); }); - it("detects added relation as safe change", () => { + it("detects added relation as data-validated warning", () => { const before = createSchema({ version: 1 }); const after = createSchema({ version: 2, @@ -1189,13 +1187,78 @@ describe("computeSchemaDiff", () => { expect(diff.ontology[0]).toMatchObject({ type: "added", entity: "relation", - severity: "safe", + severity: "warning", }); expect(requireDefined(diff.ontology[0]).details).toContain("subClassOf"); expect(requireDefined(diff.ontology[0]).details).toContain("Employee"); expect(requireDefined(diff.ontology[0]).details).toContain("Person"); }); + it("classifies an added equivalentTo relation exactly like an added subClassOf relation", () => { + // `equivalentTo` is mutual subsumption, but `diffOntology` is + // meta-edge-agnostic — it keys relations as `${metaEdge}:${from}:${to}` + // and assigns `severity: "safe"` to every addition regardless of which + // meta-edge it is. Pin that this stays true after the fold: an + // `equivalentTo` addition must produce the identical shape (module its + // own metaEdge/from/to text) a `subClassOf` addition does. + const before = createSchema({ version: 1 }); + const afterSubClassOf = createSchema({ + version: 2, + ontology: { + ...emptyOntology(), + relations: [ + { metaEdge: "subClassOf", from: "Company", to: "Corporation" }, + ], + }, + }); + const afterEquivalentTo = createSchema({ + version: 2, + ontology: { + ...emptyOntology(), + relations: [ + { metaEdge: "equivalentTo", from: "Company", to: "Corporation" }, + ], + }, + }); + + const subClassOfDiff = computeSchemaDiff(before, afterSubClassOf); + const equivalentToDiff = computeSchemaDiff(before, afterEquivalentTo); + + const shapeOf = ( + diff: ReturnType, + ): Readonly<{ + hasChanges: boolean; + hasBreakingChanges: boolean; + isBackwardsCompatible: boolean; + entityCount: number; + type: string; + entity: string; + severity: string; + }> => ({ + hasChanges: diff.hasChanges, + hasBreakingChanges: diff.hasBreakingChanges, + isBackwardsCompatible: isBackwardsCompatible(diff), + entityCount: diff.ontology.length, + type: requireDefined(diff.ontology[0]).type, + entity: requireDefined(diff.ontology[0]).entity, + severity: requireDefined(diff.ontology[0]).severity, + }); + + expect(shapeOf(equivalentToDiff)).toEqual(shapeOf(subClassOfDiff)); + // Both are tightenings: they widen a subsumption component, so the + // classifier marks them `warning` and attaches the data probes a + // commit must run (see ontology-change.ts). + expect(shapeOf(equivalentToDiff)).toEqual({ + hasChanges: true, + hasBreakingChanges: false, + isBackwardsCompatible: true, + entityCount: 1, + type: "added", + entity: "relation", + severity: "warning", + }); + }); + it("detects removed relation as warning", () => { const before = createSchema({ version: 1, @@ -1219,6 +1282,28 @@ describe("computeSchemaDiff", () => { severity: "warning", }); }); + + it("classifies an added inverseOf relation as breaking", () => { + const before = createSchema({ version: 1 }); + const after = createSchema({ + version: 2, + ontology: { + ...emptyOntology(), + relations: [{ metaEdge: "inverseOf", from: "likes", to: "likedBy" }], + }, + }); + + const diff = computeSchemaDiff(before, after); + + expect(diff.ontology).toHaveLength(1); + expect(diff.ontology[0]).toMatchObject({ + type: "added", + entity: "relation", + severity: "breaking", + }); + expect(diff.hasBreakingChanges).toBe(true); + expect(diff.isBackwardsCompatible).toBe(false); + }); }); describe("Operational Identity capability", () => { @@ -1331,6 +1416,7 @@ describe("computeSchemaDiff", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1349,17 +1435,7 @@ describe("computeSchemaDiff", () => { version: 2, ontology: { ...emptyOntology(), - metaEdges: { - subClassOf: { - name: "subClassOf", - transitive: true, - symmetric: false, - reflexive: false, - inverse: undefined, - inference: "none", - description: undefined, - }, - }, + relations: [{ metaEdge: "broader", from: "Employee", to: "Person" }], }, }); @@ -1369,6 +1445,33 @@ describe("computeSchemaDiff", () => { expect(diff.summary).toContain("1 added"); }); + // LOAD-BEARING CHECK: counting only `added`/`removed` ontology changes + // again makes this summary "No changes" beside `hasChanges: true`. + it("summarizes a diff whose only change is a modified ontology entry", () => { + const follows = { + kind: "follows", + fromKinds: ["Person"], + toKinds: ["Person"], + properties: { type: "object", properties: {} }, + cardinality: "many", + targetCardinality: "many", + endpointExistence: "notDeleted", + description: undefined, + } as const; + const before = createSchema({ version: 1, edges: { follows } }); + const after = createSchema({ + version: 2, + edges: { follows: { ...follows, acyclic: true } }, + }); + + const diff = computeSchemaDiff(before, after); + + console.log("modified-only ontology diff:", diff.summary, diff.ontology); + expect(diff.hasChanges).toBe(true); + expect(diff.ontology.map((change) => change.type)).toEqual(["modified"]); + expect(diff.summary).toBe("Ontology: 0 added, 0 removed, 1 modified"); + }); + it("generates combined summary for multiple change types", () => { const before = createSchema({ version: 1, @@ -1400,6 +1503,7 @@ describe("computeSchemaDiff", () => { toKinds: ["NewNode"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1730,6 +1834,7 @@ describe("getMigrationActions", () => { toKinds: ["Person"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, @@ -1816,6 +1921,7 @@ describe("getMigrationActions", () => { toKinds: ["Company"], properties: { type: "object", properties: {} }, cardinality: "many", + targetCardinality: "many", endpointExistence: "notDeleted", description: undefined, }, diff --git a/packages/typegraph/tests/node-delete-expect.test.ts b/packages/typegraph/tests/node-delete-expect.test.ts new file mode 100644 index 000000000..db992990f --- /dev/null +++ b/packages/typegraph/tests/node-delete-expect.test.ts @@ -0,0 +1,238 @@ +/** + * `nodes..delete(id, { expect })` at the collection boundary: what an + * untyped caller's options are refused for, what the types refuse, and what + * operation hooks see of a refused guarded delete. The plan comparison itself + * is exercised on every backend in + * `tests/backends/integration/composition-delete-plan.ts`. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type NodeDeletePlan, + NodeDeletePlanMismatchError, + type OperationHookContext, + partOf, + type Store, + ValidationError, +} from "../src"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend, matchingArray } from "./test-utils"; + +const DeShow = defineNode("DeShow", { schema: z.object({}) }); +const DeClip = defineNode("DeClip", { schema: z.object({}) }); +const deClipOf = defineEdge("deClipOf", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "node_delete_expect", + nodes: { DeShow: { type: DeShow }, DeClip: { type: DeClip } }, + edges: { + deClipOf: { + type: deClipOf, + from: [DeClip], + to: [DeShow], + cardinality: "one", + }, + }, + ontology: [partOf(DeClip, DeShow, { via: deClipOf })], +}); + +type HookLog = Readonly<{ + ended: OperationHookContext[]; + failed: Readonly<{ operation: string; error: Error }>[]; +}>; + +async function showWithClip() { + const backend = createTestBackend(); + await createStoreWithSchema(graph, backend); + const log: HookLog = { ended: [], failed: [] }; + const store = createStore(graph, backend, { + hooks: { + onOperationEnd: (ctx) => log.ended.push(ctx), + onError: (ctx, error) => + log.failed.push({ operation: ctx.operationId, error }), + }, + }); + const show = await store.nodes.DeShow.create({}); + const clip = await store.nodes.DeClip.create({}, { partOf: { whole: show } }); + const plan = requireDefined(await store.nodes.DeShow.planDelete(show.id)); + log.ended.length = 0; + return { store, log, show, clip, plan }; +} + +async function rejectionOf( + operation: () => Promise, +): Promise { + try { + await operation(); + } catch (error) { + return error; + } + return undefined; +} + +function assertTypedSurface( + store: Store, + show: Awaited["nodes"]["DeShow"]["create"]>>, + plan: NodeDeletePlan, + maybePlan: NodeDeletePlan | undefined, +): void { + void store.nodes.DeShow.delete(show.id); + void store.nodes.DeShow.delete(show.id, { expect: plan }); + // @ts-expect-error planDelete's `undefined` answer is not an expectation + void store.nodes.DeShow.delete(show.id, { expect: maybePlan }); + // @ts-expect-error options without a plan state nothing + void store.nodes.DeShow.delete(show.id, {}); + // @ts-expect-error hardDelete takes no expect: the plan is the soft delete's + void store.nodes.DeShow.hardDelete(show.id, { expect: plan }); +} +void assertTypedSurface; + +describe("delete(id, { expect }) options", () => { + it("refuses options of the wrong shape with ValidationError naming the offending path, writing nothing", async () => { + const { store, show, clip, plan } = await showWithClip(); + const [part] = plan.cascadedParts; + const cases: readonly Readonly<{ options: unknown; path: string }>[] = [ + { options: "plan", path: "" }, + { options: {}, path: "expect" }, + { options: { expect: undefined }, path: "expect" }, + { options: { expect: plan, force: true }, path: "" }, + { + options: { expect: { cascadedParts: [] } }, + path: "expect.removedEdgeIds", + }, + { + options: { expect: { ...plan, cascadedParts: [{ kind: "DeClip" }] } }, + path: "expect.cascadedParts.0.id", + }, + { + options: { expect: { ...plan, cascadedParts: [part, part] } }, + path: "expect.cascadedParts.1", + }, + { + options: { + expect: { ...plan, removedEdgeIds: ["edge", "edge"] }, + }, + path: "expect.removedEdgeIds.1", + }, + ]; + + // MUTATION CHECK: return `undefined` from `readNodeDeleteOptions` + // (src/store/operations/node-delete-expectation.ts) for any options and + // every case deletes unguarded instead of refusing — verified and + // reverted. + for (const { options, path } of cases) { + const refusal = await rejectionOf(() => + store.nodes.DeShow.delete(show.id, options as never), + ); + expect(refusal).toBeInstanceOf(ValidationError); + const error = refusal as ValidationError; + expect(error.details).toMatchObject({ + entityType: "node", + kind: "DeShow", + id: show.id, + operation: "delete", + }); + expect({ + options, + paths: error.details.issues.map((issue) => issue.path), + }).toEqual({ options, paths: matchingArray([path]) }); + } + expect(await store.nodes.DeShow.getById(show.id)).toBeDefined(); + expect(await store.nodes.DeClip.getById(clip.id)).toBeDefined(); + }); + + it("refuses an options argument to hardDelete and bulkDelete instead of deleting unguarded", async () => { + const { store, show, clip, plan } = await showWithClip(); + + // MUTATION CHECK: return early from `refuseNodeDeleteArguments` + // (src/store/operations/node-delete-expectation.ts) and both calls + // delete the show without comparing anything — verified and reverted. + // The way an untyped JavaScript caller reaches these methods. + const collection = store.nodes.DeShow; + const refusals = [ + await rejectionOf( + () => + Reflect.apply(collection.hardDelete, collection, [ + show.id, + { expect: plan }, + ]) as Promise, + ), + await rejectionOf( + () => + Reflect.apply(collection.bulkDelete, collection, [ + [show.id], + { expect: plan }, + ]) as Promise, + ), + ]; + + expect( + refusals.map((refusal) => ({ + isValidationError: refusal instanceof ValidationError, + operation: (refusal as ValidationError).details.operation, + paths: (refusal as ValidationError).details.issues.map( + (issue) => issue.path, + ), + })), + ).toEqual([ + { isValidationError: true, operation: "hardDelete", paths: ["options"] }, + { isValidationError: true, operation: "delete", paths: ["options"] }, + ]); + expect(await store.nodes.DeShow.getById(show.id)).toBeDefined(); + expect(await store.nodes.DeClip.getById(clip.id)).toBeDefined(); + }); + + it("reports a refused guarded delete through onError, never onOperationEnd", async () => { + const { store, log, show, plan } = await showWithClip(); + await store.nodes.DeClip.create({}, { partOf: { whole: show } }); + log.ended.length = 0; + + const refusal = await rejectionOf(() => + store.nodes.DeShow.delete(show.id, { expect: plan }), + ); + + expect(refusal).toBeInstanceOf(NodeDeletePlanMismatchError); + expect(log.ended).toEqual([]); + expect(log.failed.map((entry) => entry.error)).toEqual([refusal]); + const error = refusal as NodeDeletePlanMismatchError; + expect(error.category).toBe("constraint"); + expect(error.message).toContain("1 part(s) added"); + expect(error.suggestion).toContain("planDelete(id)"); + }); + + it("fires onOperationEnd once, with the cascaded parts, for a guarded delete that matches", async () => { + const { store, log, show, clip, plan } = await showWithClip(); + + await store.nodes.DeShow.delete(show.id, { expect: plan }); + + expect(log.failed).toEqual([]); + expect( + log.ended.map(({ operation, kind, id, cascadedParts }) => ({ + operation, + kind, + id, + cascadedParts, + })), + ).toEqual([ + { + operation: "delete", + kind: "DeShow", + id: show.id, + cascadedParts: [ + { + kind: "DeClip", + id: clip.id, + whole: { kind: "DeShow", id: show.id }, + viaEdgeKind: "deClipOf", + }, + ], + }, + ]); + }); +}); diff --git a/packages/typegraph/tests/node-delete-policy-consumed-edges.test.ts b/packages/typegraph/tests/node-delete-policy-consumed-edges.test.ts new file mode 100644 index 000000000..7fefd341f --- /dev/null +++ b/packages/typegraph/tests/node-delete-policy-consumed-edges.test.ts @@ -0,0 +1,195 @@ +/** + * `NodeDeletePolicy.consumedEdgeIds` — the generic restrict/cascade narrowing + * seam a future composition cascade (`planCompositionCascade`, a later slice) + * plans against. + * + * No producer populates `consumedEdgeIds` with real composition-edge ids yet + * (that lands with the registry's composition-edge owner), so these tests + * drive the seam with hand-picked edge ids standing in for a future cascade's + * plan. They prove two things: the seam narrows exactly the edges it is told + * to, and today's behavior (an empty or absent `consumedEdgeIds`) is + * byte-identical to before this field existed. + * + * Driven through `store.transaction` + `transactionDeleteNodeWithPolicy` — + * the SAME production path merge apply uses — rather than the Store-scoped + * `StoreRuntime.deleteNodeWithPolicy` port: that Store-scoped variant builds + * its own unbuffered hook context and is correct only OUTSIDE a + * `store.transaction` callback (see its doc), so it is reserved for the one + * test (`tests/node-delete-policy-root-atomic-bypass-pglite.test.ts`) that + * specifically needs to call it directly against a root backend. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + RestrictedDeleteError, +} from "../src"; +import { transactionDeleteNodeWithPolicy } from "../src/store/runtime-port"; +import { createTestBackend } from "./test-utils"; + +const Target = defineNode("Target", { schema: z.object({}) }); +const RestrictNode = defineNode("RestrictNode", { schema: z.object({}) }); +const CascadeNode = defineNode("CascadeNode", { schema: z.object({}) }); +const link = defineEdge("link", { schema: z.object({}) }); + +function buildGraph(graphId: string) { + return defineGraph({ + id: graphId, + nodes: { + Target: { type: Target }, + RestrictNode: { type: RestrictNode, onDelete: "restrict" }, + CascadeNode: { type: CascadeNode, onDelete: "cascade" }, + }, + edges: { + link: { + type: link, + from: [RestrictNode, CascadeNode], + to: [Target], + }, + }, + }); +} + +describe("NodeDeletePolicy.consumedEdgeIds", () => { + it("excludes a fully-consumed edge from the restrict count, allowing the delete", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("consumed_restrict_narrows_to_zero"), + backend, + ); + const target = await store.nodes.Target.create({}); + const restrictNode = await store.nodes.RestrictNode.create({}); + const edge = await store.edges.link.create(restrictNode, target, {}); + + await store.transaction((tx) => + transactionDeleteNodeWithPolicy( + tx, + { kind: "RestrictNode", id: restrictNode.id }, + { + enforceDeleteBehavior: true, + consumedEdgeIds: new Set([edge.id]), + }, + ), + ); + + await expect( + store.nodes.RestrictNode.getById(restrictNode.id), + ).resolves.toBeUndefined(); + // The consumed edge is excluded from THIS delete's restrict/cascade + // consideration entirely — it is neither an obstacle nor something this + // delete removes. It survives untouched, for whichever caller planned + // its consumption to delete it itself. + await expect(store.edges.link.getById(edge.id)).resolves.toBeDefined(); + }); + + it("still counts an unconsumed edge against restrict even when a sibling edge is consumed", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("consumed_restrict_leaves_unconsumed_blocking"), + backend, + ); + const consumedTarget = await store.nodes.Target.create({}); + const unconsumedTarget = await store.nodes.Target.create({}); + const restrictNode = await store.nodes.RestrictNode.create({}); + const consumedEdge = await store.edges.link.create( + restrictNode, + consumedTarget, + {}, + ); + await store.edges.link.create(restrictNode, unconsumedTarget, {}); + + await expect( + store.transaction((tx) => + transactionDeleteNodeWithPolicy( + tx, + { kind: "RestrictNode", id: restrictNode.id }, + { + enforceDeleteBehavior: true, + consumedEdgeIds: new Set([consumedEdge.id]), + }, + ), + ), + ).rejects.toMatchObject({ + details: expect.objectContaining({ edgeCount: 1 }) as unknown, + }); + // Refused: the node survives, live. + await expect( + store.nodes.RestrictNode.getById(restrictNode.id), + ).resolves.toBeDefined(); + }); + + it("cascade removes only the unconsumed edge, leaving the consumed one for its own owner", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("consumed_cascade_removes_only_unconsumed"), + backend, + ); + const consumedTarget = await store.nodes.Target.create({}); + const unconsumedTarget = await store.nodes.Target.create({}); + const cascadeNode = await store.nodes.CascadeNode.create({}); + const consumedEdge = await store.edges.link.create( + cascadeNode, + consumedTarget, + {}, + ); + const unconsumedEdge = await store.edges.link.create( + cascadeNode, + unconsumedTarget, + {}, + ); + + await store.transaction((tx) => + transactionDeleteNodeWithPolicy( + tx, + { kind: "CascadeNode", id: cascadeNode.id }, + { + enforceDeleteBehavior: true, + consumedEdgeIds: new Set([consumedEdge.id]), + }, + ), + ); + + await expect( + store.nodes.CascadeNode.getById(cascadeNode.id), + ).resolves.toBeUndefined(); + await expect( + store.edges.link.getById(unconsumedEdge.id), + ).resolves.toBeUndefined(); + await expect( + store.edges.link.getById(consumedEdge.id), + ).resolves.toBeDefined(); + }); + + it("an absent consumedEdgeIds is byte-identical to today's plain restrict refusal", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema( + buildGraph("absent_consumed_edge_ids_matches_today"), + backend, + ); + const target = await store.nodes.Target.create({}); + const restrictNode = await store.nodes.RestrictNode.create({}); + await store.edges.link.create(restrictNode, target, {}); + + await expect( + store.transaction((tx) => + transactionDeleteNodeWithPolicy( + tx, + { kind: "RestrictNode", id: restrictNode.id }, + { enforceDeleteBehavior: true }, + ), + ), + ).rejects.toBeInstanceOf(RestrictedDeleteError); + await expect( + store.transaction((tx) => + transactionDeleteNodeWithPolicy(tx, { + kind: "RestrictNode", + id: restrictNode.id, + }), + ), + ).rejects.toBeInstanceOf(RestrictedDeleteError); + }); +}); diff --git a/packages/typegraph/tests/node-delete-policy-root-atomic-bypass-pglite.test.ts b/packages/typegraph/tests/node-delete-policy-root-atomic-bypass-pglite.test.ts new file mode 100644 index 000000000..0e7561ed7 --- /dev/null +++ b/packages/typegraph/tests/node-delete-policy-root-atomic-bypass-pglite.test.ts @@ -0,0 +1,209 @@ +/** + * `nodeDeleteRequiresPortablePath` — proof that a stated + * {@link NodeDeletePolicy}, or a public `delete(id, { expect })` plan, routes + * `executeNodeDelete` around the fused atomic delete command, on the ONE + * backend configuration where that command is actually reachable. + * + * `resolveAtomicNodeDeleteBatchExecutor` only ever resolves a `deleteNodes` + * executor against a backend whose execution capabilities advertise + * `atomicBatch: "root"` — which PGlite does at the root, but a + * `store.transaction` session never does (its transaction-scoped backend + * registers `replaceNodes` / `mutateNodes` / `mutateEdges` only). SQLite never + * advertises `atomicBatch` at all. So the routing decision this predicate + * makes is unreachable from `tests/node-delete-policy-consumed-edges.test.ts` + * (SQLite) AND from merge apply's transaction-scoped + * `transactionDeleteNodeWithPolicy` calls — the ONLY way to exercise it, in a + * test or otherwise, is `StoreRuntime.deleteNodeWithPolicy` called directly + * against a PGlite ROOT backend, exactly as this file does. The public + * `store.nodes..delete` runs against that same root backend, so the + * `expect` cases below reach the decision through the collection itself. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { withAtomicMutationProgramDispatchObserver } from "../src/backend/capabilities/atomic-mutation-program"; +import { createPostgresBackend } from "../src/backend/drizzle/postgres"; +import { createLocalPgliteBackend } from "../src/backend/postgres/pglite"; +import { defineEdge, defineGraph, defineNode } from "../src/core"; +import { NodeDeletePlanMismatchError } from "../src/errors"; +import { createStoreWithSchema } from "../src/store"; +import { STORE_RUNTIME } from "../src/store/runtime-port"; +import { requireDefined } from "../src/utils/presence"; + +// Deliberately atomic-eligible: `onDelete: "restrict"`, no uniques, no +// identity, no searchable/embedding fields (see +// `resolveAtomicNodeDeleteBatchExecutor`'s eligibility gates) — a plain +// `RestrictNode` delete with no policy WOULD take the fused path. +const Target = defineNode("Target", { schema: z.object({}) }); +const RestrictNode = defineNode("RestrictNode", { schema: z.object({}) }); +const link = defineEdge("link", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "node-delete-policy-root-atomic-bypass", + nodes: { + Target: { type: Target }, + RestrictNode: { type: RestrictNode, onDelete: "restrict" }, + }, + edges: { + link: { type: link, from: [RestrictNode], to: [Target] }, + }, +}); + +describe("NodeDeletePolicy bypasses the fused atomic delete on a root backend", () => { + it("honors enforceDeleteBehavior:false via the portable path even though the fused delete is eligible", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const target = await store.nodes.Target.create({}); + const restrictNode = await store.nodes.RestrictNode.create({}); + const edge = await store.edges.link.create(restrictNode, target, {}); + + const dispatched: string[] = []; + await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + () => + store[STORE_RUNTIME].deleteNodeWithPolicy( + backend, + { kind: "RestrictNode", id: restrictNode.id }, + { enforceDeleteBehavior: false }, + ), + ); + + // The fused `deleteNodes` command was never dispatched: the policy + // forced the portable path even though the fused command was eligible. + expect(dispatched).not.toContain("deleteNodes"); + await expect( + store.nodes.RestrictNode.getById(restrictNode.id), + ).resolves.toBeUndefined(); + // Enforcement fully skipped: the edge is neither an obstacle nor + // removed. + await expect(store.edges.link.getById(edge.id)).resolves.toBeDefined(); + } finally { + await local.backend.close(); + } + }); + + it("honors a non-empty consumedEdgeIds via the portable path even though the fused delete is eligible", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const target = await store.nodes.Target.create({}); + const restrictNode = await store.nodes.RestrictNode.create({}); + const edge = await store.edges.link.create(restrictNode, target, {}); + + const dispatched: string[] = []; + await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + () => + store[STORE_RUNTIME].deleteNodeWithPolicy( + backend, + { kind: "RestrictNode", id: restrictNode.id }, + { + enforceDeleteBehavior: true, + consumedEdgeIds: new Set([edge.id]), + }, + ), + ); + + expect(dispatched).not.toContain("deleteNodes"); + await expect( + store.nodes.RestrictNode.getById(restrictNode.id), + ).resolves.toBeUndefined(); + // Consumed, not this delete's to remove: survives untouched. + await expect(store.edges.link.getById(edge.id)).resolves.toBeDefined(); + } finally { + await local.backend.close(); + } + }); + + it("without a policy the SAME shape takes the fused path", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const restrictNode = await store.nodes.RestrictNode.create({}); + + const dispatched: string[] = []; + await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + () => + store[STORE_RUNTIME].deleteNodeWithPolicy(backend, { + kind: "RestrictNode", + id: restrictNode.id, + }), + ); + + // Confirms the eligibility premise: this exact node shape, with no + // policy at all, IS reachable through the fused command — so the two + // cases above are genuinely exercising a bypass, not an executor that + // was never eligible in the first place. + expect(dispatched).toContain("deleteNodes"); + } finally { + await local.backend.close(); + } + }); +}); + +describe("A delete expect plan bypasses the fused atomic delete on a root backend", () => { + it("compares the plan on the portable path, deleting on a match and refusing a mismatch, though the fused delete is eligible", async () => { + const local = await createLocalPgliteBackend({ vector: false }); + const backend = createPostgresBackend(local.db, { vector: false }); + try { + const [store] = await createStoreWithSchema(graph, backend); + const unguarded = await store.nodes.RestrictNode.create({}); + const matched = await store.nodes.RestrictNode.create({}); + const mismatched = await store.nodes.RestrictNode.create({}); + const matchedPlan = requireDefined( + await store.nodes.RestrictNode.planDelete(matched.id), + ); + expect(matchedPlan).toEqual({ cascadedParts: [], removedEdgeIds: [] }); + + const dispatched: string[] = []; + const refusal = await withAtomicMutationProgramDispatchObserver( + backend, + (variant) => dispatched.push(variant), + async () => { + await store.nodes.RestrictNode.delete(unguarded.id); + // The premise: the public delete of this shape IS fused. + expect(dispatched).toEqual(["deleteNodes"]); + await store.nodes.RestrictNode.delete(matched.id, { + expect: matchedPlan, + }); + try { + await store.nodes.RestrictNode.delete(mismatched.id, { + expect: { cascadedParts: [], removedEdgeIds: ["stale-edge"] }, + }); + } catch (error) { + return error; + } + return; + }, + ); + + // MUTATION CHECK: drop the `expect` arm of + // `nodeDeleteRequiresPortablePath` + // (src/store/operations/node-write-pipeline.ts) and both guarded + // deletes take the fused command, which compares nothing, so the + // mismatched node is deleted — verified and reverted. + expect(dispatched).toEqual(["deleteNodes"]); + expect(refusal).toBeInstanceOf(NodeDeletePlanMismatchError); + expect((refusal as NodeDeletePlanMismatchError).details).toMatchObject({ + situation: "changed", + droppedEdgeIds: ["stale-edge"], + }); + await expect( + store.nodes.RestrictNode.getById(matched.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.RestrictNode.getById(mismatched.id), + ).resolves.toBeDefined(); + } finally { + await local.backend.close(); + } + }); +}); diff --git a/packages/typegraph/tests/ontology-change.test.ts b/packages/typegraph/tests/ontology-change.test.ts new file mode 100644 index 000000000..51c687b05 --- /dev/null +++ b/packages/typegraph/tests/ontology-change.test.ts @@ -0,0 +1,951 @@ +/** + * Unit tests for `classifyOntologyChanges` (`src/schema/ontology-change.ts`). + * + * Every relation-classification test below is load-bearing: each case states the + * revert check, per AGENTS.md. + */ +import { describe, expect, it } from "vitest"; + +import { ConfigurationError } from "../src/errors"; +import { + classifyOntologyChanges, + type OntologyDataProbe, + type OntologySnapshot, +} from "../src/schema/ontology-change"; +import { + type SerializedClosures, + type SerializedEdgeDef, + type SerializedNodeDef, + type SerializedOntology, + type SerializedOntologyRelation, + type SerializedUniqueConstraint, +} from "../src/schema/types"; +import { requireDefined } from "../src/utils/presence"; + +// ============================================================ +// Fixtures +// ============================================================ + +const EMPTY_CLOSURES: SerializedClosures = { + subClassAncestors: {}, + subClassDescendants: {}, + broaderClosure: {}, + narrowerClosure: {}, + equivalenceSets: {}, + disjointPairs: [], + partOfClosure: {}, + hasPartClosure: {}, + iriToKind: {}, + edgeInverses: {}, + edgeImplicationsClosure: {}, + edgeImplyingClosure: {}, +}; + +function nodeDef( + kind: string, + uniqueConstraints: readonly SerializedUniqueConstraint[] = [], +): SerializedNodeDef { + return { + kind, + properties: { type: "object", properties: {} }, + uniqueConstraints, + onDelete: "restrict", + description: undefined, + }; +} + +function edgeDef( + kind: string, + fromKinds: readonly string[], + toKinds: readonly string[], +): SerializedEdgeDef { + return { + kind, + fromKinds, + toKinds, + properties: { type: "object", properties: {} }, + cardinality: "many", + targetCardinality: "many", + endpointExistence: "notDeleted", + description: undefined, + }; +} + +function ontology( + relations: readonly SerializedOntologyRelation[], +): SerializedOntology { + return { metaEdges: {}, relations, closures: EMPTY_CLOSURES }; +} + +function snapshot( + nodes: Record, + edges: Record, + relations: readonly SerializedOntologyRelation[], +): OntologySnapshot { + return { nodes, edges, ontology: ontology(relations) }; +} + +function relation( + metaEdge: string, + from: string, + to: string, + via?: string, +): SerializedOntologyRelation { + return { metaEdge, from, to, ...(via === undefined ? {} : { via }) }; +} + +function compositionRelation( + metaEdge: "partOf" | "hasPart", + from: string, + to: string, + via: string, + existence?: "optional" | "required", +): SerializedOntologyRelation { + return { + metaEdge, + from, + to, + via, + ...(existence === undefined ? {} : { existence }), + }; +} + +function relationChangeOf( + changes: ReturnType, + type: "added" | "removed", +) { + return requireDefined( + changes.find((change) => change.type === type), + `expected a ${type} relation change`, + ); +} + +function probeOfKind( + probes: readonly OntologyDataProbe[] | undefined, + kind: K, +): Extract { + const found = (probes ?? []).find( + (probe): probe is Extract => + probe.kind === kind, + ); + return requireDefined(found, `expected a "${kind}" probe`); +} + +function admissionLossOf(changes: ReturnType) { + return requireDefined( + changes.find((change) => change.entity === "edgeRegistration"), + "expected an edgeRegistration change", + ); +} + +/** `implies` only requires same-side assignability, so a same-shape pair is valid. */ +const EDGES_FOR_IMPLIES: Record = { + edgeA: edgeDef("edgeA", ["X"], ["Y"]), + edgeB: edgeDef("edgeB", ["X"], ["Y"]), +}; + +/** `inverseOf` requires an exact reversal: edgeB's (from, to) is edgeA's (to, from). */ +const EDGES_FOR_INVERSE_OF: Record = { + edgeA: edgeDef("edgeA", ["X"], ["Y"]), + edgeB: edgeDef("edgeB", ["Y"], ["X"]), +}; + +/** + * `partOf`/`hasPart` require a registered `via` edge whose whole-side + * cardinality is `"one"`/`"oneActive"`. `edgeA` (X -> Y) is the only pair + * declared against it, so it also satisfies composition-exactness for + * whichever orientation the relation under test infers. + */ +const EDGES_FOR_COMPOSITION: Record = { + edgeA: { + ...edgeDef("edgeA", ["X"], ["Y"]), + cardinality: "one", + targetCardinality: "one", + }, +}; + +// ============================================================ +// The severity matrix +// ============================================================ + +type MatrixRow = Readonly<{ + metaEdge: string; + direction: "added" | "removed"; + from: string; + to: string; + via?: string; + edges?: Record; + expectedSeverity: "safe" | "warning" | "breaking"; + expectedProbeKinds: readonly OntologyDataProbe["kind"][]; +}>; + +const MATRIX: readonly MatrixRow[] = [ + { + metaEdge: "disjointWith", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: ["nodeDisjointness"], + }, + { + metaEdge: "disjointWith", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "subClassOf", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: [ + "nodeUniqueness", + "nodeDisjointness", + "compositionExistence", + ], + }, + { + metaEdge: "subClassOf", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: ["edgeEndpointAssignability"], + }, + { + metaEdge: "equivalentTo", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: [ + "nodeUniqueness", + "nodeDisjointness", + "compositionExistence", + ], + }, + { + metaEdge: "equivalentTo", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: ["edgeEndpointAssignability"], + }, + { + metaEdge: "sameAs", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: [ + "nodeUniqueness", + "nodeDisjointness", + "compositionExistence", + ], + }, + { + metaEdge: "sameAs", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "warning", + expectedProbeKinds: ["edgeEndpointAssignability"], + }, + { + metaEdge: "inverseOf", + direction: "added", + from: "edgeA", + to: "edgeB", + edges: EDGES_FOR_INVERSE_OF, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "inverseOf", + direction: "removed", + from: "edgeA", + to: "edgeB", + edges: EDGES_FOR_INVERSE_OF, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "implies", + direction: "added", + from: "edgeA", + to: "edgeB", + edges: EDGES_FOR_IMPLIES, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "implies", + direction: "removed", + from: "edgeA", + to: "edgeB", + edges: EDGES_FOR_IMPLIES, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "broader", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "broader", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "narrower", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "narrower", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "partOf", + direction: "added", + from: "X", + to: "Y", + via: "edgeA", + edges: EDGES_FOR_COMPOSITION, + expectedSeverity: "warning", + expectedProbeKinds: ["composition"], + }, + { + metaEdge: "partOf", + direction: "removed", + from: "X", + to: "Y", + via: "edgeA", + edges: EDGES_FOR_COMPOSITION, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "hasPart", + direction: "added", + from: "X", + to: "Y", + via: "edgeA", + edges: EDGES_FOR_COMPOSITION, + expectedSeverity: "warning", + expectedProbeKinds: ["composition"], + }, + { + metaEdge: "hasPart", + direction: "removed", + from: "X", + to: "Y", + via: "edgeA", + edges: EDGES_FOR_COMPOSITION, + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, + { + metaEdge: "relatedTo", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "relatedTo", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "differentFrom", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "differentFrom", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "aCustomVocabularyRelation", + direction: "added", + from: "A", + to: "B", + expectedSeverity: "safe", + expectedProbeKinds: [], + }, + { + metaEdge: "aCustomVocabularyRelation", + direction: "removed", + from: "A", + to: "B", + expectedSeverity: "breaking", + expectedProbeKinds: [], + }, +]; + +describe("classifyOntologyChanges", () => { + describe("the severity table", () => { + it.each(MATRIX)("$metaEdge $direction -> $expectedSeverity", (row) => { + const edges = row.edges ?? {}; + const relationUnderTest = relation( + row.metaEdge, + row.from, + row.to, + row.via, + ); + const before = snapshot( + {}, + edges, + row.direction === "removed" ? [relationUnderTest] : [], + ); + const after = snapshot( + {}, + edges, + row.direction === "added" ? [relationUnderTest] : [], + ); + + const changes = classifyOntologyChanges(before, after); + const change = relationChangeOf(changes, row.direction); + + expect(change.severity).toBe(row.expectedSeverity); + expect((change.probes ?? []).map((probe) => probe.kind)).toEqual( + row.expectedProbeKinds, + ); + }); + + // MUTATION CHECK: flipping the + // `disjointWith`-added row's severity in + // `classifyKnownRelationSeverity` to "safe" with no probe makes this + // exact row of the matrix fail. + }); + + describe("symmetric relations", () => { + const kinds = { + Alpha: nodeDef("Alpha"), + Gamma: nodeDef("Gamma"), + }; + + it.each([ + "disjointWith", + "equivalentTo", + "sameAs", + "differentFrom", + "relatedTo", + ])( + "reads %s restated with its arguments swapped as no change", + (metaEdge) => { + const before = snapshot(kinds, {}, [ + relation(metaEdge, "Gamma", "Alpha"), + ]); + const after = snapshot(kinds, {}, [ + relation(metaEdge, "Alpha", "Gamma"), + ]); + + expect(classifyOntologyChanges(before, after)).toEqual([]); + }, + ); + + it("reads an inverseOf restated with its edge kinds swapped as no change", () => { + const edges = { + follows: edgeDef("follows", ["Alpha"], ["Gamma"]), + followedBy: edgeDef("followedBy", ["Gamma"], ["Alpha"]), + }; + const before = snapshot(kinds, edges, [ + relation("inverseOf", "follows", "followedBy"), + ]); + const after = snapshot(kinds, edges, [ + relation("inverseOf", "followedBy", "follows"), + ]); + + expect(classifyOntologyChanges(before, after)).toEqual([]); + }); + + it("still reads a swapped subClassOf as a removal and an addition", () => { + const before = snapshot(kinds, {}, [ + relation("subClassOf", "Gamma", "Alpha"), + ]); + const after = snapshot(kinds, {}, [ + relation("subClassOf", "Alpha", "Gamma"), + ]); + + expect( + classifyOntologyChanges(before, after).map((change) => change.type), + ).toEqual(["removed", "added"]); + }); + }); + // MUTATION CHECK (verified): keying `keyedRelations` by + // `ontologyRelationIdentityKey` alone (src/schema/ontology-change.ts) reads + // each swap as a removal beside a probed addition. + + describe("the removed-kind rule", () => { + it("classifies a removed relation as safe with no probe when this commit also removes one of its kinds", () => { + const before = snapshot( + { Company: nodeDef("Company"), Organization: nodeDef("Organization") }, + {}, + [relation("subClassOf", "Company", "Organization")], + ); + const after = snapshot({ Organization: nodeDef("Organization") }, {}, []); + + const changes = classifyOntologyChanges(before, after); + const change = relationChangeOf(changes, "removed"); + + expect(change.severity).toBe("safe"); + expect(change.probes).toBeUndefined(); + }); + + it("classifies the same removal as warning + edgeEndpointAssignability when both kinds survive", () => { + const before = snapshot( + { Company: nodeDef("Company"), Organization: nodeDef("Organization") }, + {}, + [relation("subClassOf", "Company", "Organization")], + ); + const after = snapshot( + { Company: nodeDef("Company"), Organization: nodeDef("Organization") }, + {}, + [], + ); + + const changes = classifyOntologyChanges(before, after); + const change = relationChangeOf(changes, "removed"); + + expect(change.severity).toBe("warning"); + expect((change.probes ?? []).map((probe) => probe.kind)).toEqual([ + "edgeEndpointAssignability", + ]); + }); + + // MUTATION CHECK: deleting the + // removed-kind guard in `classifyRelation` makes the first assertion + // above fail (the removal reads as an ordinary warning instead of safe). + }); + + describe("probe payloads", () => { + it("computes the propagated nodeDisjointness delta, not just the authored pair", () => { + const before = snapshot({}, {}, [ + relation("subClassOf", "Company", "Organization"), + ]); + const after = snapshot({}, {}, [ + relation("subClassOf", "Company", "Organization"), + relation("disjointWith", "Person", "Organization"), + ]); + + const changes = classifyOntologyChanges(before, after); + const added = changes.find( + (change) => + change.type === "added" && change.name.startsWith("disjointWith"), + ); + const probe = probeOfKind( + requireDefined(added, "expected the added disjointWith relation") + .probes, + "nodeDisjointness", + ); + + expect(probe.pairs).toEqual([ + ["Company", "Person"], + ["Organization", "Person"], + ]); + }); + + // MUTATION CHECK: computing the + // delta from the authored relations directly (skipping + // `registry.disjointKindPairs()`'s propagated closure) drops the + // ["Company", "Person"] pair and fails this assertion. + + it("merges kindWithSubClasses uniqueness components across an added subClassOf", () => { + const emailUnique: SerializedUniqueConstraint = { + name: "email_unique", + fields: ["email"], + where: undefined, + scope: "kindWithSubClasses", + collation: "binary", + }; + const nodes = { + Contractor: nodeDef("Contractor", [emailUnique]), + Worker: nodeDef("Worker", [emailUnique]), + }; + const before = snapshot(nodes, {}, []); + const after = snapshot(nodes, {}, [ + relation("subClassOf", "Contractor", "Worker"), + ]); + + const changes = classifyOntologyChanges(before, after); + const added = relationChangeOf(changes, "added"); + const probe = probeOfKind(added.probes, "nodeUniqueness"); + + expect(probe.groups).toEqual([ + { + constraintName: "email_unique", + coveredKinds: ["Contractor", "Worker"], + }, + ]); + }); + + it("computes the shrunk edge endpoint allowance for a removed subClassOf", () => { + const worksFor = edgeDef("worksFor", ["Person"], ["Organization"]); + const before = snapshot({}, { worksFor }, [ + relation("subClassOf", "Company", "Organization"), + ]); + const after = snapshot({}, { worksFor }, []); + + const changes = classifyOntologyChanges(before, after); + const removed = relationChangeOf(changes, "removed"); + const probe = probeOfKind(removed.probes, "edgeEndpointAssignability"); + + expect(probe.allowances).toEqual([ + { edgeKind: "worksFor", allowedPairs: [["Person", "Organization"]] }, + ]); + }); + + it("detects a same-size endpoint swap, not just a shrink in pair count", () => { + // `subClassOf(Company, Organization)` swapped for `subClassOf(Shop, + // Organization)` in one commit: the allowed-pair COUNT for `worksFor` + // is unchanged (one subclass pair either way), but `(Person, Company)` + // is no longer admitted. A count-based "did this shrink?" predicate + // (a proper-subset test, which additionally requires the after list to + // be strictly SHORTER) misses this entirely. + const worksFor = edgeDef("worksFor", ["Person"], ["Organization"]); + const before = snapshot({}, { worksFor }, [ + relation("subClassOf", "Company", "Organization"), + ]); + const after = snapshot({}, { worksFor }, [ + relation("subClassOf", "Shop", "Organization"), + ]); + + const changes = classifyOntologyChanges(before, after); + const removed = relationChangeOf(changes, "removed"); + const probe = probeOfKind(removed.probes, "edgeEndpointAssignability"); + + expect(probe.allowances).toEqual([ + { + edgeKind: "worksFor", + allowedPairs: [ + ["Person", "Organization"], + ["Person", "Shop"], + ], + }, + ]); + }); + // MUTATION CHECK (verified): gating `edgeEndpointAdmissionLosses` + // (`src/schema/ontology-change.ts`) on the after pair list being + // strictly SHORTER than the before list drops the swap (equal-length + // lists) and this assertion fails. + }); + + describe("an edge kind's endpoint admission loss no relation change carries", () => { + it("probes a surviving subclass that loses admission when a mid-chain kind is removed, exempting the removed kind's own rows", () => { + const likes = edgeDef("likes", ["Person"], ["Animal"]); + const before = snapshot( + { + Person: nodeDef("Person"), + Animal: nodeDef("Animal"), + Mammal: nodeDef("Mammal"), + Dog: nodeDef("Dog"), + }, + { likes }, + [ + relation("subClassOf", "Mammal", "Animal"), + relation("subClassOf", "Dog", "Mammal"), + ], + ); + const after = snapshot( + { + Person: nodeDef("Person"), + Animal: nodeDef("Animal"), + Dog: nodeDef("Dog"), + }, + { likes }, + [], + ); + + const changes = classifyOntologyChanges(before, after); + + expect( + changes + .filter((change) => change.entity === "relation") + .map((change) => [change.severity, change.probes]), + ).toEqual([ + ["safe", undefined], + ["safe", undefined], + ]); + const loss = admissionLossOf(changes); + expect(loss).toMatchObject({ + type: "modified", + name: "likes", + severity: "warning", + details: + 'Edge "likes" no longer admits endpoint pairs: (Person -> Dog)', + }); + expect( + probeOfKind(loss.probes, "edgeEndpointAssignability").allowances, + ).toEqual([ + { + edgeKind: "likes", + allowedPairs: [ + ["Person", "Animal"], + ["Person", "Mammal"], + ], + }, + ]); + }); + + it("owes nothing when the only pairs lost name the removed kind", () => { + const likes = edgeDef("likes", ["Person"], ["Animal"]); + const before = snapshot( + { + Person: nodeDef("Person"), + Animal: nodeDef("Animal"), + Mammal: nodeDef("Mammal"), + }, + { likes }, + [relation("subClassOf", "Mammal", "Animal")], + ); + const after = snapshot( + { Person: nodeDef("Person"), Animal: nodeDef("Animal") }, + { likes }, + [], + ); + + const changes = classifyOntologyChanges(before, after); + + expect(changes.flatMap((change) => change.probes ?? [])).toEqual([]); + expect(changes.map((change) => change.entity)).toEqual(["relation"]); + }); + + it("probes a directly narrowed `to` declaration with no relation change at all", () => { + const nodes = { A: nodeDef("A"), B: nodeDef("B"), C: nodeDef("C") }; + const before = snapshot( + nodes, + { e: edgeDef("e", ["A"], ["B", "C"]) }, + [], + ); + const after = snapshot(nodes, { e: edgeDef("e", ["A"], ["B"]) }, []); + + const loss = admissionLossOf(classifyOntologyChanges(before, after)); + + expect(loss.details).toBe( + 'Edge "e" no longer admits endpoint pairs: (A -> C)', + ); + expect( + probeOfKind(loss.probes, "edgeEndpointAssignability").allowances, + ).toEqual([{ edgeKind: "e", allowedPairs: [["A", "B"]] }]); + }); + + it("probes a narrowed source-dependent target map", () => { + const nodes = { A: nodeDef("A"), B: nodeDef("B"), C: nodeDef("C") }; + const before = snapshot( + nodes, + { e: edgeDef("e", ["A"], ["B", "C"]) }, + [], + ); + const after = snapshot( + nodes, + { + e: { + ...edgeDef("e", ["A"], ["B", "C"]), + targetKindsBySource: { A: ["B"] }, + }, + }, + [], + ); + + const loss = admissionLossOf(classifyOntologyChanges(before, after)); + + expect( + probeOfKind(loss.probes, "edgeEndpointAssignability").allowances, + ).toEqual([{ edgeKind: "e", allowedPairs: [["A", "B"]] }]); + }); + + it("owes nothing when a dropped declared endpoint is still admitted through subsumption", () => { + const nodes = { A: nodeDef("A"), B: nodeDef("B"), C: nodeDef("C") }; + const relations = [relation("subClassOf", "C", "B")]; + const before = snapshot( + nodes, + { e: edgeDef("e", ["A"], ["B", "C"]) }, + relations, + ); + const after = snapshot( + nodes, + { e: edgeDef("e", ["A"], ["B"]) }, + relations, + ); + + expect(classifyOntologyChanges(before, after)).toEqual([]); + }); + + it("reports the loss once, on the relation change, when one already carries the diff-wide probe", () => { + const nodes = { + Person: nodeDef("Person"), + Organization: nodeDef("Organization"), + Company: nodeDef("Company"), + Shop: nodeDef("Shop"), + }; + const before = snapshot( + nodes, + { worksFor: edgeDef("worksFor", ["Person"], ["Organization", "Shop"]) }, + [relation("subClassOf", "Company", "Organization")], + ); + const after = snapshot( + nodes, + { worksFor: edgeDef("worksFor", ["Person"], ["Organization"]) }, + [], + ); + + const changes = classifyOntologyChanges(before, after); + + expect(changes.map((change) => change.entity)).toEqual(["relation"]); + expect( + probeOfKind(changes[0]?.probes, "edgeEndpointAssignability").allowances, + ).toEqual([ + { edgeKind: "worksFor", allowedPairs: [["Person", "Organization"]] }, + ]); + }); + + it("does not build a registry for a widened or reordered endpoint declaration", () => { + const incoherentRelations = [ + relation("disjointWith", "A", "B"), + relation("subClassOf", "A", "B"), + ]; + const before = snapshot( + {}, + { e: edgeDef("e", ["A"], ["B", "C"]) }, + incoherentRelations, + ); + const after = snapshot( + {}, + { e: edgeDef("e", ["A"], ["C", "B", "D"]) }, + incoherentRelations, + ); + + expect(classifyOntologyChanges(before, after)).toEqual([]); + }); + }); + + describe("an incoherent ontology", () => { + it("propagates ConfigurationError rather than reporting an unresolved probe", () => { + // `disjointWith(A, B)` plus `subClassOf(A, B)` is a contradiction + // `validateOntologyRelations` already refuses at store open — the + // refuse-on-load precedent this module follows (there is no + // "unresolved" probe variant). + const incoherentRelations = [ + relation("disjointWith", "A", "B"), + relation("subClassOf", "A", "B"), + ]; + const before = snapshot({}, {}, incoherentRelations); + // At least one relation change so a registry build is actually + // attempted (an unrelated diff never touches the incoherent ontology). + const after = snapshot({}, {}, [ + ...incoherentRelations, + relation("broader", "X", "Y"), + ]); + + expect(() => classifyOntologyChanges(before, after)).toThrow( + ConfigurationError, + ); + }); + + it("never throws when the diff adds or removes no relation", () => { + const incoherentRelations = [ + relation("disjointWith", "A", "B"), + relation("subClassOf", "A", "B"), + ]; + const before = snapshot({}, {}, incoherentRelations); + const after = snapshot({}, {}, incoherentRelations); + + expect(() => classifyOntologyChanges(before, after)).not.toThrow(); + }); + }); + + describe("an in-place `existence` flip on an already-declared partOf/hasPart pair", () => { + it('classifies optional -> required as ONE "modified" warning entry carrying both composition probes, never remove+add', () => { + const before = snapshot({}, EDGES_FOR_COMPOSITION, [ + compositionRelation("partOf", "X", "Y", "edgeA"), + ]); + const after = snapshot({}, EDGES_FOR_COMPOSITION, [ + compositionRelation("partOf", "X", "Y", "edgeA", "required"), + ]); + + const changes = classifyOntologyChanges(before, after); + + // The bug this guards: folding `existence` into the identity key made + // this diff as remove (breaking) + add (warning), which both defeats + // the rule that a pure loosening must be safe and makes `ensureSchema` + // refuse a tightening as "breaking-change" before the tightening + // preflight ever runs. + expect(changes).toHaveLength(1); + const change = requireDefined(changes[0], "expected one change"); + expect(change.type).toBe("modified"); + expect(change.severity).toBe("warning"); + expect(change.details).toContain('existence: "required"'); + expect((change.probes ?? []).map((probe) => probe.kind)).toEqual([ + "composition", + "compositionExistence", + ]); + }); + // MUTATION CHECK: restore `relation.existence ?? ""` as a fifth element + // of the tuple `relationMapKey` (src/schema/ontology-change.ts) encodes. + // `changes` then has length 2 (one "removed", one "added") and the + // "removed" half's severity is "breaking" instead of this test's single + // "modified"/"warning" entry. + + it('classifies required -> optional as ONE "modified" safe entry with no probe (loosening is safe)', () => { + const before = snapshot({}, EDGES_FOR_COMPOSITION, [ + compositionRelation("partOf", "X", "Y", "edgeA", "required"), + ]); + const after = snapshot({}, EDGES_FOR_COMPOSITION, [ + compositionRelation("partOf", "X", "Y", "edgeA"), + ]); + + const changes = classifyOntologyChanges(before, after); + + expect(changes).toHaveLength(1); + const change = requireDefined(changes[0], "expected one change"); + expect(change.type).toBe("modified"); + expect(change.severity).toBe("safe"); + expect(change.probes).toBeUndefined(); + }); + // MUTATION CHECK: same restore as above. `changes` then has length 2, + // and the "removed" half (dropping the old `required` declaration) + // classifies as "breaking" instead of this test's single "safe" entry. + + it("classifies no change at all when existence is unchanged", () => { + const relations = [ + compositionRelation("partOf", "X", "Y", "edgeA", "required"), + ]; + const before = snapshot({}, EDGES_FOR_COMPOSITION, relations); + const after = snapshot({}, EDGES_FOR_COMPOSITION, relations); + + expect(classifyOntologyChanges(before, after)).toEqual([]); + }); + }); +}); diff --git a/packages/typegraph/tests/ontology-composition-declaration.test.ts b/packages/typegraph/tests/ontology-composition-declaration.test.ts new file mode 100644 index 000000000..87f2d580d --- /dev/null +++ b/packages/typegraph/tests/ontology-composition-declaration.test.ts @@ -0,0 +1,1230 @@ +/** + * Composition relation declaration tests. + * + * Covers the two families of composition checks: + * - shape checks, owned by `validateOntologyRelations` + * (`ONTOLOGY_COMPOSITION_VIA_REQUIRED` / `..._FORBIDDEN` / + * `..._PART_SIDE_FORBIDDEN`) — exercised directly against + * `validateOntologyRelations` with hand-built `NamedOntologyRelation`s, + * since `via` is a compile-time-required option on the `partOf`/`hasPart` + * factories and cannot be omitted through them. + * - registration-dependent checks, owned by `buildCompositionRelation` + * (orientation, cardinality, exactness, population) — exercised through + * `buildKindRegistry`, which is how every real caller reaches them. + * + * MUTATION CHECK: each + * `issues.push({ code: "ONTOLOGY_COMPOSITION_*", ... })` branch in + * `src/ontology/validation.ts` and `src/registry/composition-relation.ts` + * was deleted/commented in turn; exactly the one test naming that code + * flipped from throwing to passing, and no other test in this file changed + * outcome. Restored after each check. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + hasPart, + type OntologyRelation, + partOf, + subClassOf, +} from "../src"; +import { validateOntologyRelations } from "../src/ontology/validation"; +import { buildKindRegistry } from "../src/registry"; +import { + type CompositionIssueCode, + inferCompositionPartSide, + partitionCompositionEdgeKindsByDirection, +} from "../src/registry/composition-relation"; +import { type EdgeKindFacts } from "../src/registry/edge-kind-facts"; +import { createTestBackend, matchingObject } from "./test-utils"; + +const emptySchema = z.object({}); + +/** Asserts `fn` throws a `ConfigurationError` carrying exactly this composition code. */ +function expectCompositionCode(fn: () => unknown, code: string): void { + expect(fn).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code }), + }), + ); +} + +// ============================================================ +// Shape checks — validateOntologyRelations directly +// ============================================================ + +describe("composition shape checks (validateOntologyRelations)", () => { + it("ONTOLOGY_COMPOSITION_VIA_REQUIRED: partOf/hasPart with no via", () => { + const issues = validateOntologyRelations([ + { metaEdge: "partOf", from: "Part", to: "Whole" }, + ]); + expect(issues.map((issue) => issue.code)).toContain( + "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + ); + }); + + it("ONTOLOGY_COMPOSITION_VIA_REQUIRED: hasPart with no via", () => { + const issues = validateOntologyRelations([ + { metaEdge: "hasPart", from: "Whole", to: "Part" }, + ]); + expect(issues.map((issue) => issue.code)).toContain( + "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + ); + }); + + it("ONTOLOGY_COMPOSITION_VIA_FORBIDDEN: a non-composition meta-edge carrying via", () => { + const issues = validateOntologyRelations([ + { metaEdge: "subClassOf", from: "A", to: "B", via: "someEdge" }, + ]); + expect(issues.map((issue) => issue.code)).toContain( + "ONTOLOGY_COMPOSITION_VIA_FORBIDDEN", + ); + }); + + it("ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN: a non-composition meta-edge carrying partSide", () => { + const issues = validateOntologyRelations([ + { metaEdge: "broader", from: "A", to: "B", partSide: "from" }, + ]); + expect(issues.map((issue) => issue.code)).toContain( + "ONTOLOGY_COMPOSITION_PART_SIDE_FORBIDDEN", + ); + }); + + it("accepts a well-formed partOf relation with via and no partSide", () => { + const issues = validateOntologyRelations([ + { metaEdge: "partOf", from: "Part", to: "Whole", via: "realizes" }, + ]); + expect(issues).toEqual([]); + }); +}); + +// ============================================================ +// Registration-dependent checks — buildKindRegistry +// ============================================================ + +describe("composition registration checks (buildKindRegistry)", () => { + const Part = defineNode("Part", { schema: emptySchema }); + const Whole = defineNode("Whole", { schema: emptySchema }); + const Other = defineNode("Other", { schema: emptySchema }); + const realizes = defineEdge("realizes", { schema: emptySchema }); + + it("ONTOLOGY_COMPOSITION_VIA_UNKNOWN: via names an unregistered edge kind", () => { + const ghost = defineEdge("ghost", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-via-unknown", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: {}, + ontology: [partOf(Part, Whole, { via: ghost })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_VIA_UNKNOWN", + ); + }); + + it("ONTOLOGY_COMPOSITION_VIA_ENDPOINTS: via's endpoints admit neither orientation", () => { + const wrongEdge = defineEdge("wrongEdge", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-via-endpoints", + nodes: { + Part: { type: Part }, + Whole: { type: Whole }, + Other: { type: Other }, + }, + edges: { + wrongEdge: { + type: wrongEdge, + from: [Other], + to: [Other], + cardinality: "one", + }, + }, + ontology: [partOf(Part, Whole, { via: wrongEdge })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS", + ); + }); + + it("ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED: an ambiguous same-kind edge with no declared partSide", () => { + const Section = defineNode("Section", { schema: emptySchema }); + const containsSection = defineEdge("containsSection", { + schema: emptySchema, + }); + const graph = defineGraph({ + id: "composition-part-side-required", + nodes: { Section: { type: Section } }, + edges: { + containsSection: { + type: containsSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [partOf(Section, Section, { via: containsSection })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED", + ); + }); + + it("builds a reflexive composition pair when partSide disambiguates it", () => { + const Section = defineNode("Section", { schema: emptySchema }); + const containsSection = defineEdge("containsSection", { + schema: emptySchema, + }); + const graph = defineGraph({ + id: "composition-reflexive-ok", + nodes: { Section: { type: Section } }, + edges: { + containsSection: { + type: containsSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [ + partOf(Section, Section, { + via: containsSection, + partSide: "from", + }), + ], + }); + const registry = buildKindRegistry(graph); + expect(registry.isCompositionEdge("containsSection")).toBe(true); + expect(registry.compositionPartSide("containsSection")).toBe("from"); + }); + + it("ONTOLOGY_COMPOSITION_PART_SIDE_INVALID: a declared partSide contradicting the endpoints", () => { + const Engine = defineNode("Engine", { schema: emptySchema }); + const Car = defineNode("Car", { schema: emptySchema }); + const installedIn = defineEdge("installedIn", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-part-side-invalid", + nodes: { Engine: { type: Engine }, Car: { type: Car } }, + edges: { + installedIn: { + type: installedIn, + from: [Engine], + to: [Car], + cardinality: "one", + }, + }, + ontology: [partOf(Engine, Car, { via: installedIn, partSide: "to" })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID", + ); + }); + + it("accepts a redundant-but-consistent declared partSide", () => { + const Engine = defineNode("Engine", { schema: emptySchema }); + const Car = defineNode("Car", { schema: emptySchema }); + const installedIn = defineEdge("installedIn", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-part-side-redundant", + nodes: { Engine: { type: Engine }, Car: { type: Car } }, + edges: { + installedIn: { + type: installedIn, + from: [Engine], + to: [Car], + cardinality: "one", + }, + }, + ontology: [partOf(Engine, Car, { via: installedIn, partSide: "from" })], + }); + const registry = buildKindRegistry(graph); + expect(registry.compositionPairsBetween("Engine", "Car")).toEqual([ + matchingObject({ partSide: "from", population: "one" }), + ]); + }); + + it("ONTOLOGY_COMPOSITION_CARDINALITY: via declares no constraining cardinality", () => { + const weakLink = defineEdge("weakLink", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-cardinality", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: { + weakLink: { type: weakLink, from: [Part], to: [Whole] }, + }, + ontology: [partOf(Part, Whole, { via: weakLink })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_CARDINALITY", + ); + }); + + it("hasPart orientation: whole is `from`, fenced by targetCardinality", () => { + const Wheel = defineNode("Wheel", { schema: emptySchema }); + const Bicycle = defineNode("Bicycle", { schema: emptySchema }); + const hasWheel = defineEdge("hasWheel", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-haspart-orientation", + nodes: { Wheel: { type: Wheel }, Bicycle: { type: Bicycle } }, + edges: { + hasWheel: { + type: hasWheel, + from: [Bicycle], + to: [Wheel], + targetCardinality: "one", + }, + }, + ontology: [hasPart(Bicycle, Wheel, { via: hasWheel })], + }); + const registry = buildKindRegistry(graph); + expect(registry.compositionPartSide("hasWheel")).toBe("to"); + expect(registry.compositionPopulation("Wheel")).toBe("one"); + expect(registry.compositionPairsBetween("Wheel", "Bicycle")).toEqual([ + matchingObject({ viaEdgeKind: "hasWheel", partSide: "to" }), + ]); + }); + + it("ONTOLOGY_COMPOSITION_VIA_MIXED: the via edge admits a pair no relation declares", () => { + const PartA = defineNode("PartA", { schema: emptySchema }); + const PartB = defineNode("PartB", { schema: emptySchema }); + const WholeX = defineNode("WholeX", { schema: emptySchema }); + const ownsItem = defineEdge("ownsItem", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-via-mixed", + nodes: { + PartA: { type: PartA }, + PartB: { type: PartB }, + WholeX: { type: WholeX }, + }, + edges: { + ownsItem: { + type: ownsItem, + from: [WholeX], + to: [PartA, PartB], + targetCardinality: "one", + }, + }, + // Only PartA is declared a composition pair; PartB is a legal + // endpoint of `ownsItem` but not declared, so the edge is not + // exclusively a composition edge. + ontology: [hasPart(WholeX, PartA, { via: ownsItem })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_VIA_MIXED", + ); + }); + + it("ONTOLOGY_COMPOSITION_VIA_MIXED: the same via edge realizes two orientations", () => { + // `link`'s source-dependent target map admits (A -> B) and (C -> D). The + // first declaration is only forward-compatible (A -> B); the second is + // only reverse-compatible (whole C -> part D), so `link` ends up + // realizing composition in two different orientations. The dedicated + // orientation-conflict check in `buildCompositionRelation` + // (`existingPartSide !== partSide`) refuses this directly, rather than + // relying on composition-exactness (the general "every admitted pair is + // declared" check) to notice the coverage gap the corrupted pair would + // otherwise leave. + const A = defineNode("A", { schema: emptySchema }); + const B = defineNode("B", { schema: emptySchema }); + const C = defineNode("C", { schema: emptySchema }); + const D = defineNode("D", { schema: emptySchema }); + const link = defineEdge("link", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-via-mixed-orientation", + nodes: { A: { type: A }, B: { type: B }, C: { type: C }, D: { type: D } }, + edges: { + link: { + type: link, + from: [A, C], + to: { A: [B], C: [D] }, + cardinality: "one", + targetCardinality: "one", + }, + }, + ontology: [partOf(A, B, { via: link }), hasPart(C, D, { via: link })], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_VIA_MIXED", + ); + }); + + it("ONTOLOGY_COMPOSITION_VIA_MIXED: a reflexive pair and a cross-kind pair disagree on which side is the part, regardless of declaration order", () => { + // `link`'s source-dependent target map admits (Section -> Section) and + // (Book -> Chapter). The reflexive Section pair is declared `from` + // explicitly (both orientations are endpoint-compatible for a reflexive + // pair, so it must be). The Book/Chapter pair is only reverse-compatible + // (whole Book -> part Chapter), so it infers `to` — contradicting the + // edge-wide orientation the first declaration already recorded. Before + // this was refused directly, `partSideByEdgeKind` silently kept only the + // second declaration's side, and exactness was computed against that one + // side for every pair of the edge kind, corrupting the reflexive pair's + // own entry — and making the verdict depend on declaration order. + const Section = defineNode("Section", { schema: emptySchema }); + const Book = defineNode("Book", { schema: emptySchema }); + const Chapter = defineNode("Chapter", { schema: emptySchema }); + const link = defineEdge("link", { schema: emptySchema }); + const edges = { + link: { + type: link, + from: [Section, Book], + to: { Section: [Section], Book: [Chapter] }, + cardinality: "one", + targetCardinality: "one", + }, + } as const; + const nodes = { + Section: { type: Section }, + Book: { type: Book }, + Chapter: { type: Chapter }, + }; + + const sectionFirst = defineGraph({ + id: "composition-orientation-conflict-section-first", + nodes, + edges, + ontology: [ + partOf(Section, Section, { via: link, partSide: "from" }), + hasPart(Book, Chapter, { via: link }), + ], + }); + expectCompositionCode( + () => buildKindRegistry(sectionFirst), + "ONTOLOGY_COMPOSITION_VIA_MIXED", + ); + + const bookFirst = defineGraph({ + id: "composition-orientation-conflict-book-first", + nodes, + edges, + ontology: [ + hasPart(Book, Chapter, { via: link }), + partOf(Section, Section, { via: link, partSide: "from" }), + ], + }); + expectCompositionCode( + () => buildKindRegistry(bookFirst), + "ONTOLOGY_COMPOSITION_VIA_MIXED", + ); + }); + + it("ONTOLOGY_COMPOSITION_POPULATION_MIXED: one part kind under edges with different populations", () => { + const SharedPart = defineNode("SharedPart", { schema: emptySchema }); + const WholeOne = defineNode("WholeOne", { schema: emptySchema }); + const WholeTwo = defineNode("WholeTwo", { schema: emptySchema }); + const edgeOne = defineEdge("edgeOne", { schema: emptySchema }); + const edgeTwo = defineEdge("edgeTwo", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-population-mixed", + nodes: { + SharedPart: { type: SharedPart }, + WholeOne: { type: WholeOne }, + WholeTwo: { type: WholeTwo }, + }, + edges: { + edgeOne: { + type: edgeOne, + from: [SharedPart], + to: [WholeOne], + cardinality: "one", + }, + edgeTwo: { + type: edgeTwo, + from: [SharedPart], + to: [WholeTwo], + cardinality: "oneActive", + }, + }, + ontology: [ + partOf(SharedPart, WholeOne, { via: edgeOne }), + partOf(SharedPart, WholeTwo, { via: edgeTwo }), + ], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_POPULATION_MIXED", + ); + }); + + it("ONTOLOGY_COMPOSITION_EXISTENCE_MIXED: one part kind declaring different existence across pairs", () => { + const SharedPart = defineNode("SharedPart", { schema: emptySchema }); + const WholeOne = defineNode("WholeOne", { schema: emptySchema }); + const WholeTwo = defineNode("WholeTwo", { schema: emptySchema }); + const edgeOne = defineEdge("edgeOne", { schema: emptySchema }); + const edgeTwo = defineEdge("edgeTwo", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-existence-mixed", + nodes: { + SharedPart: { type: SharedPart }, + WholeOne: { type: WholeOne }, + WholeTwo: { type: WholeTwo }, + }, + edges: { + edgeOne: { + type: edgeOne, + from: [SharedPart], + to: [WholeOne], + cardinality: "one", + }, + edgeTwo: { + type: edgeTwo, + from: [SharedPart], + to: [WholeTwo], + cardinality: "one", + }, + }, + ontology: [ + partOf(SharedPart, WholeOne, { via: edgeOne, existence: "required" }), + partOf(SharedPart, WholeTwo, { via: edgeTwo }), + ], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + ); + }); + // MUTATION CHECK: remove the mixed-existence loop in + // `buildCompositionRelation` (src/registry/composition-relation.ts). This + // graph then builds cleanly and `registry.compositionExistence("SharedPart")` + // answers whichever pair happens to be iterated last, silently — the + // exact ambiguity `ONTOLOGY_COMPOSITION_EXISTENCE_MIXED` exists to refuse. + + it("ONTOLOGY_COMPOSITION_EXISTENCE_MIXED: a subclass of two part kinds whose pairs disagree on existence", () => { + // Neither declared part kind is mixed on its own; only the diamond's + // shared descendant inherits both pairs. + const Optional = defineNode("DiamondOptionalPart", { schema: emptySchema }); + const Required = defineNode("DiamondRequiredPart", { schema: emptySchema }); + const Shared = defineNode("DiamondSharedPart", { schema: emptySchema }); + const WholeOne = defineNode("DiamondWholeOne", { schema: emptySchema }); + const WholeTwo = defineNode("DiamondWholeTwo", { schema: emptySchema }); + const edgeOne = defineEdge("diamondEdgeOne", { schema: emptySchema }); + const edgeTwo = defineEdge("diamondEdgeTwo", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-existence-mixed-diamond", + nodes: { + DiamondOptionalPart: { type: Optional }, + DiamondRequiredPart: { type: Required }, + DiamondSharedPart: { type: Shared }, + DiamondWholeOne: { type: WholeOne }, + DiamondWholeTwo: { type: WholeTwo }, + }, + edges: { + diamondEdgeOne: { + type: edgeOne, + from: [Optional], + to: [WholeOne], + cardinality: "one", + }, + diamondEdgeTwo: { + type: edgeTwo, + from: [Required], + to: [WholeTwo], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(Shared, Optional), + subClassOf(Shared, Required), + partOf(Optional, WholeOne, { via: edgeOne }), + partOf(Required, WholeTwo, { via: edgeTwo, existence: "required" }), + ], + }); + expectCompositionCode( + () => buildKindRegistry(graph), + "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + ); + }); + // MUTATION CHECK (verified): stop adding each declared part kind's + // descendants to `candidateKinds` in `buildCompositionRelation` + // (src/registry/composition-relation.ts). The diamond's shared descendant + // is then never judged and the graph builds. + + it("ONTOLOGY_COMPOSITION_EXISTENCE_MIXED: a mirrored partOf/hasPart pair disagreeing on existence, in either declaration order", () => { + const mirrored = (ontology: readonly OntologyRelation[]) => + defineGraph({ + id: "composition-existence-mirror", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: { + realizes: { + type: realizes, + from: [Part], + to: [Whole], + cardinality: "one", + }, + }, + ontology, + }); + const optional = partOf(Part, Whole, { via: realizes }); + const required = hasPart(Whole, Part, { + via: realizes, + existence: "required", + }); + + for (const ontology of [ + [optional, required], + [required, optional], + ]) { + expectCompositionCode( + () => buildKindRegistry(mirrored(ontology)), + "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + ); + } + + // A mirror that restates the same existence is one pair. + const agreeing = buildKindRegistry( + mirrored([ + partOf(Part, Whole, { via: realizes, existence: "required" }), + required, + ]), + ); + expect(agreeing.compositionExistence("Part")).toBe("required"); + expect(agreeing.compositionRelation().pairs).toHaveLength(1); + }); + // MUTATION CHECK: in `buildCompositionRelation` + // (src/registry/composition-relation.ts), drop the `existenceByPairKey` + // comparison ahead of the `seenPairKeys` fold. The first declaration then + // wins: `[optional, required]` builds an OPTIONAL part and + // `[required, optional]` a required one, and neither order refuses. + + it("the same mirrored disagreement in a runtime extension document is refused by store.evolve", async () => { + const Base = defineNode("Base", { schema: emptySchema }); + const [store] = await createStoreWithSchema( + defineGraph({ + id: "composition-existence-mirror-extension", + nodes: { Base: { type: Base } }, + edges: {}, + }), + createTestBackend(), + ); + + // MUTATION CHECK: as above — `store.evolve` builds its registry through + // the same `buildCompositionRelation`, so the document is then accepted. + await expect( + store.evolve( + defineGraphExtension({ + nodes: { A: { properties: {} }, B: { properties: {} } }, + edges: { + e: { from: ["A"], to: ["B"], properties: {}, cardinality: "one" }, + }, + ontology: [ + { metaEdge: "partOf", from: "A", to: "B", via: "e" }, + { + metaEdge: "hasPart", + from: "B", + to: "A", + via: "e", + existence: "required", + }, + ], + }), + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_COMPOSITION_EXISTENCE_MIXED", + }), + }), + ); + }); + + it("ONTOLOGY_COMPOSITION_EXISTENCE_INVALID: an existence outside its enum is refused by the factory and by the registry build", () => { + // MUTATION CHECK: drop the `isCompositionExistence` branch from + // `compositionRelationOptions` (src/ontology/core-meta-edges.ts) — both + // factories then return a relation carrying the misspelling. + for (const factory of [ + () => + partOf(Part, Whole, { via: realizes, existence: "require" as never }), + () => + hasPart(Whole, Part, { via: realizes, existence: "require" as never }), + ]) { + expectCompositionCode(factory, "ONTOLOGY_COMPOSITION_EXISTENCE_INVALID"); + } + + // A relation object built without the factory reaches the registry. + // MUTATION CHECK: drop the `isCompositionExistence` issue from + // `buildCompositionRelation` — the graph builds, reads "require" as not + // required, and serializes a schema document that no longer loads. + const handBuilt = { + ...partOf(Part, Whole, { via: realizes }), + existence: "require", + }; + expectCompositionCode( + () => + buildKindRegistry( + defineGraph({ + id: "composition-existence-invalid", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: { + realizes: { + type: realizes, + from: [Part], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [handBuilt as never], + }), + ), + "ONTOLOGY_COMPOSITION_EXISTENCE_INVALID", + ); + }); + + it("ONTOLOGY_COMPOSITION_PART_SIDE_INVALID: a partSide outside its enum is refused by the factory and by the registry build, even where the edge admits both orientations", () => { + const Folder = defineNode("Folder", { schema: emptySchema }); + const inFolder = defineEdge("inFolder", { schema: emptySchema }); + + // MUTATION CHECK: drop the `isCompositionPartSide` branch from + // `compositionRelationOptions` (src/ontology/core-meta-edges.ts) — both + // factories then return a relation carrying "sideways". + for (const factory of [ + () => + partOf(Folder, Folder, { + via: inFolder, + partSide: "sideways" as never, + }), + () => + hasPart(Folder, Folder, { + via: inFolder, + partSide: "sideways" as never, + }), + ]) { + expectCompositionCode(factory, "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID"); + } + + // MUTATION CHECK: drop the `isCompositionPartSide` check from + // `inferCompositionPartSide` (src/registry/composition-relation.ts) — a + // reflexive pair admits both orientations, so the declared value is + // returned unchecked, the registry reads it as "to", and the schema is + // persisted with a value the stored-schema reader refuses. + const handBuilt = { + ...partOf(Folder, Folder, { via: inFolder, partSide: "from" }), + partSide: "sideways", + }; + expectCompositionCode( + () => + buildKindRegistry( + defineGraph({ + id: "composition-part-side-invalid", + nodes: { Folder: { type: Folder } }, + edges: { + inFolder: { + type: inFolder, + from: [Folder], + to: [Folder], + cardinality: "one", + }, + }, + ontology: [handBuilt as never], + }), + ), + "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID", + ); + }); + + it("ONTOLOGY_COMPOSITION_OPTION_UNKNOWN: partOf and hasPart refuse an option key they do not know", () => { + // MUTATION CHECK: drop the unknown-key branch from + // `compositionRelationOptions` — the misspelled `existance` is ignored + // and the pair is declared optional. + const misspelled = { via: realizes, existance: "required" } as never; + for (const factory of [ + () => partOf(Part, Whole, misspelled), + () => hasPart(Whole, Part, misspelled), + ]) { + expect(factory).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ + code: "ONTOLOGY_COMPOSITION_OPTION_UNKNOWN", + unknownKeys: ["existance"], + }), + }), + ); + } + }); + + it("existence on a non-composition meta-edge is refused (ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN)", () => { + const issues = validateOntologyRelations([ + { + metaEdge: "subClassOf", + from: "A", + to: "B", + existence: "required", + }, + ]); + expect(issues.map((issue) => issue.code)).toContain( + "ONTOLOGY_COMPOSITION_EXISTENCE_FORBIDDEN", + ); + }); +}); + +// ============================================================ +// A valid multi-relation declaration +// ============================================================ + +describe("a valid multi-relation composition declaration", () => { + const Podcast = defineNode("Podcast", { schema: emptySchema }); + const Episode = defineNode("Episode", { schema: emptySchema }); + const Segment = defineNode("Segment", { schema: emptySchema }); + const episodeOf = defineEdge("episodeOf", { schema: emptySchema }); + const segmentOf = defineEdge("segmentOf", { schema: emptySchema }); + + const graph = defineGraph({ + id: "composition-multi-relation", + nodes: { + Podcast: { type: Podcast }, + Episode: { type: Episode }, + Segment: { type: Segment }, + }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + segmentOf: { + type: segmentOf, + from: [Segment], + to: [Episode], + cardinality: "oneActive", + }, + }, + ontology: [ + partOf(Episode, Podcast, { via: episodeOf }), + partOf(Segment, Episode, { via: segmentOf }), + ], + }); + + const registry = buildKindRegistry(graph); + + it("reports the expected pairs, edge-kind union, and orientation map", () => { + const relation = registry.compositionRelation(); + expect(relation.pairs).toEqual([ + { + partKind: "Episode", + wholeKind: "Podcast", + viaEdgeKind: "episodeOf", + partSide: "from", + population: "one", + existence: "optional", + }, + { + partKind: "Segment", + wholeKind: "Episode", + viaEdgeKind: "segmentOf", + partSide: "from", + population: "oneActive", + existence: "optional", + }, + ]); + expect(relation.edgeKinds).toEqual(new Set(["episodeOf", "segmentOf"])); + expect(relation.partSideByEdgeKind).toEqual( + new Map([ + ["episodeOf", "from"], + ["segmentOf", "from"], + ]), + ); + }); + + it("reaches across heterogeneous edge kinds transitively", () => { + expect(registry.compositionEdgeKindsUnder("Podcast")).toEqual([ + "episodeOf", + "segmentOf", + ]); + expect(registry.compositionEdgeKindsOver("Segment")).toEqual([ + "episodeOf", + "segmentOf", + ]); + }); + + it("isCompositionWhole/isCompositionPart classify each kind by its role, both ways for an intermediate kind", () => { + // Podcast is a whole only (declares parts, is never itself a part). + expect(registry.isCompositionWhole("Podcast")).toBe(true); + expect(registry.isCompositionPart("Podcast")).toBe(false); + // Segment is a part only (never itself declares parts). + expect(registry.isCompositionWhole("Segment")).toBe(false); + expect(registry.isCompositionPart("Segment")).toBe(true); + // Episode is BOTH: a whole (of Segment) and a part (of Podcast). + expect(registry.isCompositionWhole("Episode")).toBe(true); + expect(registry.isCompositionPart("Episode")).toBe(true); + }); +}); + +// ============================================================ +// Subclass-aware composition readers +// ============================================================ +// +// `compositionPopulation` resolves a concrete kind against a declared pair's +// partKind through `isAssignableTo`; every other reader must agree, or a +// declared pair above a base kind is invisible to concrete subclasses that +// really do participate in it (edge endpoint validation already accepts them +// through `isAssignableToAny`). + +describe("composition readers resolve subclasses of a declared part kind", () => { + const Base = defineNode("Base", { schema: emptySchema }); + const Sub = defineNode("Sub", { schema: emptySchema }); + const Whole = defineNode("Whole", { schema: emptySchema }); + const partOfWhole = defineEdge("partOfWhole", { schema: emptySchema }); + + const graph = defineGraph({ + id: "composition-subclass-part-side", + nodes: { Base: { type: Base }, Sub: { type: Sub }, Whole: { type: Whole } }, + edges: { + partOfWhole: { + type: partOfWhole, + from: [Base], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(Sub, Base), + partOf(Base, Whole, { via: partOfWhole }), + ], + }); + const registry = buildKindRegistry(graph); + + it("compositionPopulation already resolves the subclass", () => { + expect(registry.compositionPopulation("Sub")).toBe("one"); + }); + + it("compositionPairsBetween resolves the subclass part kind", () => { + expect(registry.compositionPairsBetween("Sub", "Whole")).toEqual([ + matchingObject({ viaEdgeKind: "partOfWhole", population: "one" }), + ]); + }); + + it("compositionEdgeKindsOver resolves the subclass part kind", () => { + expect(registry.compositionEdgeKindsOver("Sub")).toEqual(["partOfWhole"]); + }); + + it("compositionWholeKindsOver resolves the subclass part kind", () => { + expect(registry.compositionWholeKindsOver("Sub")).toEqual(["Whole"]); + }); +}); + +describe("composition readers resolve subclasses of a declared whole kind", () => { + const Part = defineNode("Part", { schema: emptySchema }); + const BaseWhole = defineNode("BaseWhole", { schema: emptySchema }); + const SubWhole = defineNode("SubWhole", { schema: emptySchema }); + const partOfBaseWhole = defineEdge("partOfBaseWhole", { + schema: emptySchema, + }); + + const graph = defineGraph({ + id: "composition-subclass-whole-side", + nodes: { + Part: { type: Part }, + BaseWhole: { type: BaseWhole }, + SubWhole: { type: SubWhole }, + }, + edges: { + partOfBaseWhole: { + type: partOfBaseWhole, + from: [Part], + to: [BaseWhole], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(SubWhole, BaseWhole), + partOf(Part, BaseWhole, { via: partOfBaseWhole }), + ], + }); + const registry = buildKindRegistry(graph); + + it("compositionPairsBetween resolves the subclass whole kind", () => { + expect(registry.compositionPairsBetween("Part", "SubWhole")).toEqual([ + matchingObject({ viaEdgeKind: "partOfBaseWhole", population: "one" }), + ]); + }); + + it("compositionEdgeKindsUnder resolves the subclass whole kind", () => { + expect(registry.compositionEdgeKindsUnder("SubWhole")).toEqual([ + "partOfBaseWhole", + ]); + }); + + it("compositionPartKindsUnder resolves the subclass whole kind", () => { + expect(registry.compositionPartKindsUnder("SubWhole")).toEqual(["Part"]); + }); +}); + +describe("the mirrored partOf + hasPart idiom", () => { + // apps/docs/src/content/docs/ontology.md teaches declaring both directions + // of the same realizing edge: `partOf(Chapter, Book, { via })` and + // `hasPart(Book, Chapter, { via })`. Both normalize to the identical + // (partKind, wholeKind, viaEdgeKind) tuple and must collapse to one pair, + // not two, or every `pairs` consumer double-processes the composition. + const Chapter = defineNode("Chapter", { schema: emptySchema }); + const Book = defineNode("Book", { schema: emptySchema }); + const chapterOf = defineEdge("chapterOf", { schema: emptySchema }); + + const graph = defineGraph({ + id: "composition-mirror-idiom", + nodes: { Chapter: { type: Chapter }, Book: { type: Book } }, + edges: { + chapterOf: { + type: chapterOf, + from: [Chapter], + to: [Book], + cardinality: "one", + }, + }, + ontology: [ + partOf(Chapter, Book, { via: chapterOf }), + hasPart(Book, Chapter, { via: chapterOf }), + ], + }); + + it("yields exactly one CompositionPair", () => { + const registry = buildKindRegistry(graph); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: "Chapter", + wholeKind: "Book", + viaEdgeKind: "chapterOf", + partSide: "from", + population: "one", + existence: "optional", + }, + ]); + }); +}); + +describe("two realizing edges over one (part, whole) pair", () => { + // "Mixed orientations under one part kind are legal", which already + // implies several edges may realize one part kind; this is the same shape + // for a single (part, whole) pair. The duplicate-relation key must key on + // `via` (and `partSide`), not just `(metaEdge, from, to)`, or this refuses + // as DUPLICATE_ONTOLOGY_RELATION. + const Part = defineNode("Part", { schema: emptySchema }); + const Whole = defineNode("Whole", { schema: emptySchema }); + const edgeA = defineEdge("edgeA", { schema: emptySchema }); + const edgeB = defineEdge("edgeB", { schema: emptySchema }); + + const graph = defineGraph({ + id: "composition-two-vias-one-pair", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: { + edgeA: { type: edgeA, from: [Part], to: [Whole], cardinality: "one" }, + edgeB: { type: edgeB, from: [Part], to: [Whole], cardinality: "one" }, + }, + ontology: [ + partOf(Part, Whole, { via: edgeA }), + partOf(Part, Whole, { via: edgeB }), + ], + }); + + it("builds without DUPLICATE_ONTOLOGY_RELATION and records both pairs", () => { + const registry = buildKindRegistry(graph); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: "Part", + wholeKind: "Whole", + viaEdgeKind: "edgeA", + partSide: "from", + population: "one", + existence: "optional", + }, + { + partKind: "Part", + wholeKind: "Whole", + viaEdgeKind: "edgeB", + partSide: "from", + population: "one", + existence: "optional", + }, + ]); + expect(registry.compositionEdgeKindsOver("Part")).toEqual([ + "edgeA", + "edgeB", + ]); + }); +}); + +// ============================================================ +// partitionCompositionEdgeKindsByDirection — direct unit tests +// ============================================================ +// +// `QueryBuilder#navigateComposition` (parts()/wholes()) and +// `buildSubgraphCompositionReachableCte` (subgraph({ composition: true })) +// both call this one function instead of each re-spelling "resolve this +// edge kind's part side, then its direction" — see the composition-relation +// module doc comment on `partitionCompositionEdgeKindsByDirection`. + +describe("partitionCompositionEdgeKindsByDirection", () => { + const Podcast = defineNode("Podcast", { schema: emptySchema }); + const Episode = defineNode("Episode", { schema: emptySchema }); + const episodeOf = defineEdge("episodeOf", { schema: emptySchema }); + + const graph = defineGraph({ + id: "composition-partition-direction", + nodes: { Podcast: { type: Podcast }, Episode: { type: Episode } }, + edges: { + episodeOf: { + type: episodeOf, + from: [Episode], + to: [Podcast], + cardinality: "one", + }, + }, + ontology: [partOf(Episode, Podcast, { via: episodeOf })], + }); + const registry = buildKindRegistry(graph); + + it("partitions a part->whole edge kind reversed toward parts and forward toward wholes", () => { + expect( + partitionCompositionEdgeKindsByDirection( + registry, + ["episodeOf"], + "parts", + ), + ).toEqual({ outEdgeKinds: [], inEdgeKinds: ["episodeOf"] }); + expect( + partitionCompositionEdgeKindsByDirection( + registry, + ["episodeOf"], + "wholes", + ), + ).toEqual({ outEdgeKinds: ["episodeOf"], inEdgeKinds: [] }); + }); + + it("throws — never silently defaults — on an edge kind with no recorded part side", () => { + // MUTATION CHECK: before the fix, `QueryBuilder#navigateComposition` + // silently defaulted a missing part side to "from" + // (`registry.compositionPartSide(edgeKind) ?? "from"`) while + // `buildSubgraphCompositionReachableCte` threw via `requireDefined` — + // one predicate, two disagreeing owners. Extracting both call sites + // onto this shared function is what makes them agree: reverting this + // function's `requireDefined` back to `?? "from"` makes this assertion + // fail (no throw; returns `{ outEdgeKinds: [], inEdgeKinds: + // ["notComposition"] }` instead, walking the wrong direction) — + // verified and reverted. + expect(() => + partitionCompositionEdgeKindsByDirection( + registry, + ["notComposition"], + "parts", + ), + ).toThrow( + '"notComposition" is not a composition edge kind, but was returned by the registry\'s composition edge-kind reader.', + ); + }); +}); + +// ============================================================ +// inferCompositionPartSide — direct unit tests +// ============================================================ + +describe("inferCompositionPartSide", () => { + // No ontology relations declared, so `isAssignableTo` degrades to plain + // equality (no subClassOf closure) — exactly what these facts need. + const registry = buildKindRegistry( + defineGraph({ + id: "infer-composition-part-side-fixture", + nodes: {}, + edges: {}, + ontology: [], + }), + ); + + const forwardOnlyFacts: EdgeKindFacts = { + from: ["Part"], + to: ["Whole"], + pairs: [{ from: "Part", to: "Whole" }], + cardinality: "one", + targetCardinality: "many", + }; + + const ambiguousFacts: EdgeKindFacts = { + from: ["Section"], + to: ["Section"], + pairs: [{ from: "Section", to: "Section" }], + cardinality: "one", + targetCardinality: "one", + }; + + it("returns `from` when only the forward orientation is endpoint-compatible", () => { + expect( + inferCompositionPartSide( + { partKind: "Part", wholeKind: "Whole" }, + forwardOnlyFacts, + registry, + ), + ).toEqual({ partSide: "from" }); + }); + + it("returns `to` when only the reverse orientation is endpoint-compatible", () => { + expect( + inferCompositionPartSide( + { partKind: "Whole", wholeKind: "Part" }, + forwardOnlyFacts, + registry, + ), + ).toEqual({ partSide: "to" }); + }); + + it("rejects a pair neither orientation admits", () => { + const code: CompositionIssueCode = "ONTOLOGY_COMPOSITION_VIA_ENDPOINTS"; + expect( + inferCompositionPartSide( + { partKind: "Other", wholeKind: "Different" }, + forwardOnlyFacts, + registry, + ), + ).toEqual({ code }); + }); + + it("requires a declared partSide when both orientations are endpoint-compatible", () => { + const code: CompositionIssueCode = + "ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED"; + expect( + inferCompositionPartSide( + { partKind: "Section", wholeKind: "Section" }, + ambiguousFacts, + registry, + ), + ).toEqual({ code }); + }); + + it("accepts a declared partSide when both orientations are endpoint-compatible", () => { + expect( + inferCompositionPartSide( + { partKind: "Section", wholeKind: "Section", declared: "to" }, + ambiguousFacts, + registry, + ), + ).toEqual({ partSide: "to" }); + }); + + it("rejects a declared partSide contradicting the only valid orientation", () => { + const code: CompositionIssueCode = "ONTOLOGY_COMPOSITION_PART_SIDE_INVALID"; + expect( + inferCompositionPartSide( + { partKind: "Part", wholeKind: "Whole", declared: "to" }, + forwardOnlyFacts, + registry, + ), + ).toEqual({ code }); + }); + + it("accepts a declared partSide agreeing with the only valid orientation (redundant)", () => { + expect( + inferCompositionPartSide( + { partKind: "Part", wholeKind: "Whole", declared: "from" }, + forwardOnlyFacts, + registry, + ), + ).toEqual({ partSide: "from" }); + }); +}); diff --git a/packages/typegraph/tests/ontology-hardening.test.ts b/packages/typegraph/tests/ontology-hardening.test.ts index 29cd49456..a80fd2ff3 100644 --- a/packages/typegraph/tests/ontology-hardening.test.ts +++ b/packages/typegraph/tests/ontology-hardening.test.ts @@ -23,8 +23,12 @@ import { type SerializedSchema, serializeSchema, } from "../src/schema"; -import { disjointnessClaimAxis } from "../src/store/claims/axis"; -import { matchingArray, matchingObject } from "./test-utils"; +import { + disjointnessClaimAxis, + uniquenessClaimTarget, +} from "../src/store/claims/axis"; +import { createStoreWithSchema } from "../src/store/store"; +import { createTestBackend, matchingArray, matchingObject } from "./test-utils"; const emptySchema = z.object({}); const Person = defineNode("Person", { schema: emptySchema }); @@ -147,13 +151,21 @@ describe("ontology truth and hardening", () => { }); it("allows a kind to be part of a disjoint kind", () => { + const belongsToOrg = defineEdge("belongsToOrg", { schema: emptySchema }); const graph = defineGraph({ id: "part-of-disjoint-kind", nodes: { Person: { type: Person }, Organization: { type: Organization } }, - edges: {}, + edges: { + belongsToOrg: { + type: belongsToOrg, + from: [Person], + to: [Organization], + cardinality: "one", + }, + }, ontology: [ disjointWith(Person, Organization), - partOf(Person, Organization), + partOf(Person, Organization, { via: belongsToOrg }), ], }); @@ -258,7 +270,20 @@ describe("ontology truth and hardening", () => { ); }); - it("stops disjointness descent at external IRIs in validation and runtime alike", () => { + it("continues subclass descent through an external IRI that is also a subClassOf endpoint", () => { + // Regression for a defect found reviewing the equivalence fold: `subClassOf(Gamma, IRI)` + // reaches Alpha's representative through the IRI's equivalence class + // (`equivalentTo(Alpha, IRI)`) on the ANCESTOR side by construction — + // `isSubClassOf("Gamma", "Alpha")` was already true before this fix, via + // `expandCollapsedRelation`'s per-representative fellow walk. What was + // missing was the DESCENDANT side: `representativeOf` left the IRI + // unmapped, so `collapsedSubClass` kept it as a literal, disconnected + // node instead of resolving it to Alpha's representative, and Gamma + // silently dropped out of `subClassDescendants.get("Alpha")` — the two + // closures disagreed about the very same pair. An IRI is still never a + // KIND (it never appears as a member of either set), but it is no longer + // a dead end for a `subClassOf` edge that names it: the class it belongs + // to is exactly what a `subClassOf`-to-IRI edge subsumes. const externalIri = "http://example.org/X"; const ontology = [ { metaEdge: "equivalentTo", from: "Alpha", to: externalIri }, @@ -266,16 +291,125 @@ describe("ontology truth and hardening", () => { { metaEdge: "disjointWith", from: "Alpha", to: "Beta" }, ]; - // An IRI is an inert reference, so subclass descent stops there: Gamma - // inherits nothing from Alpha. Validation must agree rather than reason - // past what the registry actually materializes. expect(validateOntologyRelations(ontology)).toEqual([]); const closures = computeClosuresFromNamedOntology(ontology); - expect([...closures.disjointPairs]).toEqual(["Alpha|Beta"]); + expect(closures.subClassAncestors.get("Gamma")?.has("Alpha")).toBe(true); + expect(closures.subClassDescendants.get("Alpha")?.has("Gamma")).toBe(true); + expect([...closures.disjointPairs].toSorted()).toEqual([ + "Alpha|Beta", + "Beta|Gamma", + ]); + // Gamma is a SUBCLASS of Alpha through the IRI bridge, not equivalentTo + // it — the equivalence set itself stays exactly what was declared. expect(closures.equivalenceSets.get("Alpha")?.has("Gamma")).toBe(false); }); + it("propagates disjointness through an IRI-routed equivalence chain (fold parity)", () => { + // The fold of equivalentTo into mutual subsumption runs BEFORE the disjoint-side + // expansion runs, so `expandDisjointSide` no longer walks `equivalenceSets` + // separately — it reaches Beta through `subClassDescendants` alone, + // exactly as it reached a subclass before this change. This case is the + // one that would break if that fold were undone. + const externalIri = "http://example.org/equivalence-route"; + const ontology = [ + { metaEdge: "equivalentTo", from: "Alpha", to: externalIri }, + { metaEdge: "equivalentTo", from: externalIri, to: "Beta" }, + { metaEdge: "disjointWith", from: "Alpha", to: "Gamma" }, + ]; + + expect(validateOntologyRelations(ontology)).toEqual([]); + const closures = computeClosuresFromNamedOntology(ontology); + expect(closures.disjointPairs.has("Beta|Gamma")).toBe(true); + }); + + it("keeps an external IRI out of subsumption itself, not only out of disjointness propagation", () => { + // The disjointness tests above only pin what gets THROUGH an IRI-routed + // equivalence chain, not whether the IRI itself became a subsumption + // member. An IRI is an inert reference (ontology.md): it must never + // appear as an ancestor/descendant, never satisfy isSubClassOf, and never + // enter a kind's subclass component or uniqueness claim axis. + const externalIri = "https://schema.org/Person"; + + // Single-kind case: Person ≡ IRI alone must not turn the mapping into a + // cross-kind uniqueness claim. + const soloGraph = defineGraph({ + id: "iri-inert-solo", + nodes: { Person: { type: Person } }, + edges: {}, + ontology: [equivalentTo(Person, externalIri)], + }); + const soloRegistry = buildKindRegistry(soloGraph); + expect(soloRegistry.getSubClassComponent("Person")).toEqual(["Person"]); + expect( + uniquenessClaimTarget("Person", "kindWithSubClasses", soloRegistry), + ).toEqual({ axis: "Person", crossKind: false }); + + // Two-kind case: Person ≡ IRI ≡ Individual routes a real equivalence + // through the IRI, so Person and Individual must fold together — but the + // IRI itself must stay out of every subsumption-reading API. + const Individual = defineNode("Individual", { schema: emptySchema }); + const routedGraph = defineGraph({ + id: "iri-inert-routed", + nodes: { Person: { type: Person }, Individual: { type: Individual } }, + edges: {}, + ontology: [ + equivalentTo(Person, externalIri), + equivalentTo(Individual, externalIri), + ], + }); + const routedRegistry = buildKindRegistry(routedGraph); + + expect([...routedRegistry.getAncestors("Person")]).not.toContain( + externalIri, + ); + expect([...routedRegistry.getAncestors("Individual")]).not.toContain( + externalIri, + ); + expect(routedRegistry.expandSubClasses("Person")).not.toContain( + externalIri, + ); + expect(routedRegistry.isSubClassOf("Person", externalIri)).toBe(false); + expect(routedRegistry.isAssignableTo("Person", externalIri)).toBe(false); + expect(routedRegistry.getSubClassComponent("Person")).toEqual([ + "Individual", + "Person", + ]); + expect(routedRegistry.getSubClassComponent("Individual")).toBe( + routedRegistry.getSubClassComponent("Person"), + ); + expect( + uniquenessClaimTarget("Person", "kindWithSubClasses", routedRegistry), + ).toEqual({ axis: "Individual", crossKind: true }); + }); + + it("pins the literal disjointPairs set for the equivalentTo-is-subsumption probe graph", () => { + // The exact literal member list `computeDisjointPairs` produced before the + // fold of equivalence into subsumption. Company gains Corporation as a + // subclass descendant (mutual subsumption), so Corporation inherits + // Person's disjointness with Company byte-identically to how the + // pre-fold code reached it through a separate equivalence walk. + const Corporation = defineNode("Corporation", { schema: emptySchema }); + const graph = defineGraph({ + id: "equivalence-subsumption-disjoint-pairs-pin", + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Corporation: { type: Corporation }, + }, + edges: {}, + ontology: [ + equivalentTo(Company, Corporation), + disjointWith(Person, Company), + ], + }); + const registry = buildKindRegistry(graph); + expect([...registry.disjointPairs].toSorted()).toEqual([ + "Company|Person", + "Corporation|Person", + ]); + }); + it("keeps disjoint pairs injective when kind names contain pipes", () => { const A = defineNode("A", { schema: emptySchema }); const BPipeC = defineNode("B|C", { schema: emptySchema }); @@ -680,4 +814,182 @@ describe("ontology truth and hardening", () => { }); expect(() => mergeGraphExtension(host, external)).not.toThrow(); }); + + describe("equivalence classes must be node-kind-only (ONTOLOGY_EQUIVALENCE_INVALID_CLASS)", () => { + it("rejects a class that mixes a node kind and an edge kind", () => { + const worksAt = defineEdge("worksAtMixed"); + const graph = defineGraph({ + id: "equivalence-invalid-mixed", + nodes: { Person: { type: Person } }, + edges: { + worksAtMixed: { type: worksAt, from: [Person], to: [Person] }, + }, + // The widened `equivalentTo` signature accepts an edge kind on the + // left directly — no runtime-only spelling needed, and this is + // exactly what makes the runtime refusal load-bearing rather than a + // type error: the call compiles, and only the registry build catches + // that the two sides are a node kind and an edge kind. + ontology: [equivalentTo(worksAt, Person)], + }); + + expect(() => buildKindRegistry(graph)).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + issues: matchingArray([ + expect.objectContaining({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + details: matchingObject({ reason: "mixed-node-and-edge" }), + }), + ]), + }), + }), + ); + }); + + it("rejects two registered edge kinds folded into one class through a shared IRI", () => { + const worksAt = defineEdge("worksAtShared"); + const employedBy = defineEdge("employedByShared"); + const graph = defineGraph({ + id: "equivalence-invalid-multiple-edges", + nodes: { Person: { type: Person } }, + edges: { + worksAtShared: { type: worksAt, from: [Person], to: [Person] }, + employedByShared: { + type: employedBy, + from: [Person], + to: [Person], + }, + }, + ontology: [ + equivalentTo(worksAt, "https://schema.org/worksFor"), + equivalentTo(employedBy, "https://schema.org/worksFor"), + ], + }); + + expect(() => buildKindRegistry(graph)).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + issues: matchingArray([ + expect.objectContaining({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + details: matchingObject({ reason: "multiple-edge-kinds" }), + }), + ]), + }), + }), + ); + }); + + it("allows one edge kind ≡ one IRI and one node kind ≡ another IRI", () => { + const worksAt = defineEdge("worksAtLegal"); + const Company = defineNode("EquivalenceLegalCompany", { + schema: emptySchema, + }); + const graph = defineGraph({ + id: "equivalence-valid-edge-and-node-to-iri", + nodes: { + Person: { type: Person }, + EquivalenceLegalCompany: { type: Company }, + }, + edges: { + worksAtLegal: { type: worksAt, from: [Person], to: [Company] }, + }, + ontology: [ + equivalentTo(worksAt, "https://schema.org/worksFor"), + equivalentTo(Company, "https://schema.org/Organization"), + ], + }); + + const registry = buildKindRegistry(graph); + expect(registry.resolveIri("https://schema.org/worksFor")).toBe( + "worksAtLegal", + ); + expect(registry.resolveIri("https://schema.org/Organization")).toBe( + "EquivalenceLegalCompany", + ); + }); + + it("rejects the same invalid class on the persisted-schema deserializer path", () => { + const worksAt = defineEdge("worksAtPersisted"); + const employedBy = defineEdge("employedByPersisted"); + const graph = defineGraph({ + id: "equivalence-invalid-persisted", + nodes: { Person: { type: Person } }, + edges: { + worksAtPersisted: { type: worksAt, from: [Person], to: [Person] }, + employedByPersisted: { + type: employedBy, + from: [Person], + to: [Person], + }, + }, + ontology: [ + equivalentTo(worksAt, "https://schema.org/worksForPersisted"), + equivalentTo(employedBy, "https://schema.org/worksForPersisted"), + ], + }); + // The live registry build already refuses this graph, so persist the + // RELATIONS only (bypassing `buildKindRegistry`) to exercise the + // deserializer's own classifier, built from `schema.nodes`/`schema.edges` + // rather than delegated to a caller — the thing the schema threads. + const schema = serializeSchema(graph, 1); + + expect(() => deserializeSchema(schema).buildRegistry()).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + }), + }), + ); + }); + + it("rejects two edge kinds folded through a shared IRI only after evolve merges the registry", async () => { + const backend = createTestBackend(); + const baseGraph = defineGraph({ + id: "equivalence-invalid-evolve-base", + nodes: { Person: { type: Person } }, + edges: {}, + }); + const [store] = await createStoreWithSchema(baseGraph, backend); + + await expect( + store.evolve( + defineGraphExtension({ + edges: { + worksAtEvolve: { + from: ["Person"], + to: ["Person"], + properties: {}, + }, + employedByEvolve: { + from: ["Person"], + to: ["Person"], + properties: {}, + }, + }, + ontology: [ + { + metaEdge: "equivalentTo", + from: "worksAtEvolve", + to: "https://schema.org/worksForEvolve", + }, + { + metaEdge: "equivalentTo", + from: "employedByEvolve", + to: "https://schema.org/worksForEvolve", + }, + ], + }), + ), + ).rejects.toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_EQUIVALENCE_INVALID_CLASS", + }), + }), + ); + }); + }); }); diff --git a/packages/typegraph/tests/plan-inspector.test.ts b/packages/typegraph/tests/plan-inspector.test.ts index 1f7d24d61..b4c29d9b1 100644 --- a/packages/typegraph/tests/plan-inspector.test.ts +++ b/packages/typegraph/tests/plan-inspector.test.ts @@ -42,7 +42,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, @@ -81,7 +81,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, @@ -133,7 +133,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, @@ -175,7 +175,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, @@ -209,7 +209,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, @@ -253,7 +253,7 @@ describe("plan inspector", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"] as const, }, temporalMode: { mode: "current" as const }, diff --git a/packages/typegraph/tests/polymorphic-default.test.ts b/packages/typegraph/tests/polymorphic-default.test.ts new file mode 100644 index 000000000..c09890e80 --- /dev/null +++ b/packages/typegraph/tests/polymorphic-default.test.ts @@ -0,0 +1,479 @@ +/** + * Subclass expansion is the query default. + * + * `from`/`to`/`fromDynamic`/`toDynamic` default to `expansion: "subclasses"` + * (a supertype query is polymorphic unless narrowed). Seven internal call + * sites are pinned to `false` so they stay exact-kind under the new + * default: `store.search()`'s candidate subquery, and five collection + * paths (`find({where})`, `updateWhere` x2 (its own root, plus the `exists` + * leg's root), `compareAndSet`, and `updateWhere`'s `exists` RELATED-kind + * traversal). + * + * Two shapes of pin, told apart honestly rather than claimed + * uniformly load-bearing: + * + * - `find({ where })` and the `exists` leg's RELATED-kind `toDynamic` pin + * ARE genuinely load-bearing: dropping either changes a real result set, + * and the tests below fail without them (mutation-checked). + * - The other five pins — `search()`'s candidate subquery, `compareAndSet`'s + * root, `updateWhere`'s own root, and the `exists` leg's OWN root + * `fromDynamic` — are DEFENSE IN DEPTH with no observable effect today. + * Each candidate id they widen still passes through an outer exact-kind + * fence one layer down (`WHERE nodes.kind = ` in + * `buildUpdateNodeSet`, or `nodeKind: kind` on the `backend.fulltextSearch` + * call), so a subclass id the pin would have let through is filtered out + * there regardless. Kept as an explicit second layer against a future + * change to that outer fence, not because today's tests can observe them + * failing — dropping any of the five leaves every test in this file + * green. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createQueryBuilder, + createStore, + defineEdge, + defineGraph, + defineNode, + subClassOf, +} from "../src"; +import { searchable } from "../src/core/searchable"; +import { ConfigurationError } from "../src/errors"; +import { buildKindRegistry } from "../src/registry"; +import { createStoreWithSchema } from "../src/store/store"; +import { requireDefined } from "../src/utils/presence"; +import { createInitializedStore, createTestBackend } from "./test-utils"; + +const Media = defineNode("Media", { + schema: z.object({ title: z.string() }), +}); +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { Media: { type: Media }, Podcast: { type: Podcast } }, + edges: {}, + ontology: [subClassOf(Podcast, Media)], + }); +} + +describe('from()/to() default to expansion: "subclasses"', () => { + it("store.query().from('Media', 'm') returns Podcast rows by default", async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_default_on"), + backend, + ); + await store.nodes.Media.create({ title: "plain media" }); + await store.nodes.Podcast.create({ + title: "a podcast", + rssUrl: "https://x", + }); + + const rows = await store + .query() + .from("Media", "m") + .select((ctx) => ctx.m) + .execute(); + + expect(rows.map((row) => row.kind).toSorted()).toEqual([ + "Media", + "Podcast", + ]); + }); + + it('{ expansion: "exact" } restores exact-kind behavior on from()', async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_default_off_per_call"), + backend, + ); + await store.nodes.Media.create({ title: "plain media" }); + await store.nodes.Podcast.create({ + title: "a podcast", + rssUrl: "https://x", + }); + + const rows = await store + .query() + .from("Media", "m", { expansion: "exact" }) + .select((ctx) => ctx.m) + .execute(); + + expect(rows.map((row) => row.kind)).toEqual(["Media"]); + }); + + it('createStore(..., { queryDefaults: { expansion: "exact" } }) restores exact-kind everywhere', async () => { + const backend = createTestBackend(); + const graph = buildGraph("q3_store_default_off"); + const [store] = await createStoreWithSchema(graph, backend, { + queryDefaults: { expansion: "exact" }, + }); + await store.nodes.Media.create({ title: "plain media" }); + await store.nodes.Podcast.create({ + title: "a podcast", + rssUrl: "https://x", + }); + + const rows = await store + .query() + .from("Media", "m") + .select((ctx) => ctx.m) + .execute(); + + expect(rows.map((row) => row.kind)).toEqual(["Media"]); + }); +}); + +describe("default pin — store.search() candidate subquery stays exact-kind (defense in depth)", () => { + // NOT mutation-checked: `backend.fulltextSearch({ nodeKind: kind, ... })` + // already scopes the physical search to the exact kind regardless of what + // the candidate subquery's `from()` widens to, so dropping the + // `{ expansion: "exact" }` pin at src/store/search.ts leaves this + // test green. See the module docblock above. + it("a fulltext search with no expansion option does not return subclass rows", async () => { + const SearchableMedia = defineNode("SearchMedia", { + schema: z.object({ title: searchable({ language: "english" }) }), + }); + const SearchablePodcast = defineNode("SearchPodcast", { + schema: z.object({ + title: searchable({ language: "english" }), + rssUrl: z.string(), + }), + }); + const graph = defineGraph({ + id: "q3_search_pin", + nodes: { + SearchMedia: { type: SearchableMedia }, + SearchPodcast: { type: SearchablePodcast }, + }, + edges: {}, + ontology: [subClassOf(SearchablePodcast, SearchableMedia)], + }); + const backend = createTestBackend(); + const store = await createInitializedStore(graph, backend); + await store.nodes.SearchMedia.create({ title: "unique_pin_marker media" }); + await store.nodes.SearchPodcast.create({ + title: "unique_pin_marker podcast", + rssUrl: "https://x", + }); + + // A `where` predicate is required to exercise `buildKindCandidates` — + // the pinned line under test compiles ONLY when a `where` predicate is + // present (the unfiltered path never builds a query-builder candidate + // subquery at all). + const results = await store.search.fulltext("SearchMedia", { + query: "unique_pin_marker", + limit: 10, + where: (accessor) => accessor.title.contains("unique_pin_marker"), + }); + + expect(results.map((result) => result.node.kind)).toEqual(["SearchMedia"]); + }); + + it('expansion: "subclasses" on search() itself does return subclass rows once each', async () => { + const SearchableMedia2 = defineNode("SearchMedia2", { + schema: z.object({ title: searchable({ language: "english" }) }), + }); + const SearchablePodcast2 = defineNode("SearchPodcast2", { + schema: z.object({ + title: searchable({ language: "english" }), + rssUrl: z.string(), + }), + }); + const graph = defineGraph({ + id: "q3_search_expand", + nodes: { + SearchMedia2: { type: SearchableMedia2 }, + SearchPodcast2: { type: SearchablePodcast2 }, + }, + edges: {}, + ontology: [subClassOf(SearchablePodcast2, SearchableMedia2)], + }); + const backend = createTestBackend(); + const store = await createInitializedStore(graph, backend); + await store.nodes.SearchMedia2.create({ + title: "unique_pin_marker2 media", + }); + await store.nodes.SearchPodcast2.create({ + title: "unique_pin_marker2 podcast", + rssUrl: "https://x", + }); + + const results = await store.search.fulltext("SearchMedia2", { + query: "unique_pin_marker2", + limit: 10, + expansion: "subclasses", + }); + + expect(results.map((result) => result.node.kind).toSorted()).toEqual([ + "SearchMedia2", + "SearchPodcast2", + ]); + }); + + it("refuses an expansion axis the facade does not offer instead of searching exact-kind", async () => { + // `"narrower"` is a real member of the shared axis vocabulary that this + // facade does not offer, and the builder refuses the identical value — + // so coercing it to `"exact"` here would silently hand back a different + // row set than the option asked for. Reachable from JavaScript, and from + // a typed caller that casts. + const SearchableMedia3 = defineNode("SearchMedia3", { + schema: z.object({ title: searchable({ language: "english" }) }), + }); + const graph = defineGraph({ + id: "q3_search_refusal", + nodes: { SearchMedia3: { type: SearchableMedia3 } }, + edges: {}, + ontology: [], + }); + const backend = createTestBackend(); + const store = await createInitializedStore(graph, backend); + await store.nodes.SearchMedia3.create({ + title: "unique_pin_marker3 media", + }); + + for (const axis of ["narrower", "subClasses"]) { + let caught: unknown; + try { + await store.search.fulltext("SearchMedia3", { + query: "unique_pin_marker3", + limit: 10, + expansion: axis as never, + }); + } catch (error) { + caught = error; + } + expect(caught, `expansion: "${axis}" must be refused`).toBeInstanceOf( + ConfigurationError, + ); + expect((caught as ConfigurationError).details["code"]).toBe( + "QUERY_ALIAS_EXPANSION_INVALID", + ); + expect((caught as ConfigurationError).message).toContain(axis); + } + + // The builder's refusal of the same value, for the same vocabulary. + expect(() => + store + .query() + .from("SearchMedia3", "m", { expansion: "narrower2" as never }), + ).toThrow(ConfigurationError); + }); +}); + +describe("default pin — collection APIs stay exact-kind", () => { + // Load-bearing: `nodes.Media.find()`'s no-`where` branch goes straight to + // the exact-kind backend find path, and this pin is what keeps + // `find({ where })` returning the identical row set. Mutation-checked: + // dropping `{ expansion: "exact" }` at node-collection.ts's `find` + // makes `findWhere` also return the `Podcast` row, failing this test. + it("nodes.Media.find({ where }) and find() return the same row set", async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_find_pin"), + backend, + ); + await store.nodes.Media.create({ title: "alpha" }); + await store.nodes.Podcast.create({ title: "alpha", rssUrl: "https://x" }); + + const findAll = await store.nodes.Media.find(); + const findWhere = await store.nodes.Media.find({ + where: (accessor) => accessor.title.eq("alpha"), + }); + + expect(findAll.map((row) => row.id).toSorted()).toEqual( + findWhere.map((row) => row.id).toSorted(), + ); + expect(findAll).toHaveLength(1); + }); + + // NOT mutation-checked: `executeNodeSetUpdate`'s outer + // `WHERE nodes.kind = params.kind` re-filters the candidate ids to + // `Media` regardless of what the `updateWhere` root's `fromDynamic` pin + // widens to, so dropping it at node-collection.ts leaves this test green. + // Defense in depth (see the module docblock above). + it("nodes.Media.updateWhere({ all: true }) touches zero Podcast rows", async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_update_where_pin"), + backend, + ); + await store.nodes.Media.create({ title: "before" }); + const podcast = await store.nodes.Podcast.create({ + title: "before", + rssUrl: "https://x", + }); + + const result = await store.nodes.Media.updateWhere({ + all: true, + patch: { title: "after" }, + }); + + expect(result.affectedCount).toBe(1); + const stillPodcast = await store.nodes.Podcast.getById(podcast.id); + expect(requireDefined(stillPodcast).title).toBe("before"); + }); + + // NOT mutation-checked: same outer-fence reason as updateWhere above — + // `compareAndSet`'s root `fromDynamic` pin widens the CANDIDATE set, but + // the final UPDATE still filters `nodes.kind = "Media"`, which a Podcast + // row never matches. Defense in depth (see the module docblock above). + it("nodes.Media.compareAndSet(podcastId, ...) returns false — exact-kind", async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_compare_and_set_pin"), + backend, + ); + const podcast = await store.nodes.Podcast.create({ + title: "before", + rssUrl: "https://x", + }); + + const applied = await store.nodes.Media.compareAndSet(podcast.id as never, { + expected: { title: "before" }, + patch: { title: "after" }, + }); + + expect(applied).toBe(false); + }); +}); + +describe("default pin — updateWhere()'s exists-leg RELATED-kind toDynamic (load-bearing)", () => { + // Unlike the root-kind pins above, this one is NOT behind the outer + // `WHERE nodes.kind = params.kind` fence: the related node's kind gates + // whether the `exists` predicate is satisfied at all, and only the + // ROOT node's id is projected as a candidate. Dropping + // `{ expansion: "exact" }` on the `exists` leg's `toDynamic(relation.relatedKind, ...)` + // (node-collection.ts) genuinely changes the result: an `exists` check + // against `Tag` would then also be satisfied by a `SpecialTag`-only + // related row. Mutation-checked. + it("an exists check against a parent kind is not satisfied by a subclass-only related row", async () => { + const Item = defineNode("PDItem", { + schema: z.object({ title: z.string() }), + }); + const Tag = defineNode("PDTag", { schema: z.object({ name: z.string() }) }); + const SpecialTag = defineNode("PDSpecialTag", { + schema: z.object({ name: z.string(), extra: z.string() }), + }); + const tagged = defineEdge("pdTagged", { schema: z.object({}) }); + const graph = defineGraph({ + id: "q3_exists_related_kind_pin", + nodes: { + PDItem: { type: Item }, + PDTag: { type: Tag }, + PDSpecialTag: { type: SpecialTag }, + }, + edges: { + pdTagged: { type: tagged, from: [Item], to: [Tag, SpecialTag] }, + }, + ontology: [subClassOf(SpecialTag, Tag)], + }); + const backend = createTestBackend(); + const store = await createInitializedStore(graph, backend); + const item = await store.nodes.PDItem.create({ title: "before" }); + const specialTag = await store.nodes.PDSpecialTag.create({ + name: "s", + extra: "x", + }); + await store.edges.pdTagged.create(item, specialTag, {}); + + const result = await store.nodes.PDItem.updateWhere({ + exists: [ + { edgeKind: "pdTagged", direction: "out", relatedKind: "PDTag" }, + ], + patch: { title: "after" }, + }); + + expect(result.affectedCount).toBe(0); + const stillItem = await store.nodes.PDItem.getById(item.id); + expect(requireDefined(stillItem).title).toBe("before"); + }); +}); + +describe("store.subgraph() is unaffected by the query default", () => { + it("includeKinds is resolved literally regardless of subClassOf", async () => { + const backend = createTestBackend(); + const store = await createInitializedStore( + buildGraph("q3_subgraph_pin"), + backend, + ); + const media = await store.nodes.Media.create({ title: "root" }); + await store.nodes.Podcast.create({ title: "other", rssUrl: "https://x" }); + + const result = await store.subgraph(media, { edges: [] }); + + expect(result.root).toBeDefined(); + expect(result.nodes).toHaveLength(1); + expect(requireDefined(result.root).kind).toBe("Media"); + }); +}); + +function refusalCodeOf(construct: () => unknown): unknown { + try { + construct(); + return "accepted"; + } catch (error) { + return error instanceof ConfigurationError ? error.details["code"] : error; + } +} + +describe("a store-wide default expansion is refused where it is stated", () => { + // "narrower" is a real axis an alias may state, but never a default: an + // alias declared with no options is typed as its declared kind. + it.each(["bogus", "subClasses", "narrower", 1])( + "createStore refuses queryDefaults.expansion: %s before any query runs", + (expansion) => { + const graph = buildGraph("default_expansion_refused_store"); + + expect( + refusalCodeOf(() => + createStore(graph, createTestBackend(), { + queryDefaults: { expansion: expansion as never }, + }), + ), + ).toBe("QUERY_ALIAS_EXPANSION_INVALID"); + }, + ); + + it.each(["bogus", "subClasses", "narrower", 1])( + "createQueryBuilder refuses defaultExpansion: %s before any alias is declared", + (defaultExpansion) => { + const graph = buildGraph("default_expansion_refused_builder"); + + expect( + refusalCodeOf(() => + createQueryBuilder(graph.id, buildKindRegistry(graph), { + defaultExpansion: defaultExpansion as never, + }), + ), + ).toBe("QUERY_ALIAS_EXPANSION_INVALID"); + }, + ); + + it.each([undefined, "exact", "subclasses"] as const)( + "accepts %s on both surfaces", + (expansion) => { + const graph = buildGraph("default_expansion_accepted"); + + expect( + refusalCodeOf(() => + createStore(graph, createTestBackend(), { + queryDefaults: { expansion }, + }), + ), + ).toBe("accepted"); + expect( + refusalCodeOf(() => + createQueryBuilder(graph.id, buildKindRegistry(graph), { + defaultExpansion: expansion, + }), + ), + ).toBe("accepted"); + }, + ); +}); diff --git a/packages/typegraph/tests/postgres-fence-sql.test.ts b/packages/typegraph/tests/postgres-fence-sql.test.ts index 8d31e088c..7867b46f1 100644 --- a/packages/typegraph/tests/postgres-fence-sql.test.ts +++ b/packages/typegraph/tests/postgres-fence-sql.test.ts @@ -29,6 +29,28 @@ import { renderPostgres, sql } from "../src/query/sql-fragment"; import { requireDefined } from "../src/utils/presence"; const fenceStatements = resolveFenceStatements(postgresFenceSql); +// The style every bundled PostgreSQL lock site resolves: the advisory +// mechanism, with the fences relation named. +const fenceStatementsWithFenceRow = resolveFenceStatements(postgresFenceSql, { + mechanism: "advisory", + fencesTableName: "typegraph_fences", +}); +const acquireKeyedSnapshotFence = requireDefined( + fenceStatementsWithFenceRow.acquireKeyedSnapshotFence, + "an advisory fence naming a fences relation supplies acquireKeyedSnapshotFence", +); +const SNAPSHOT_FENCE_SQL = + "\n" + + ' WITH "fence_lock" AS MATERIALIZED (\n' + + ' SELECT pg_advisory_xact_lock(hashtext($1), hashtext($2)) AS "lock_token"\n' + + " )\n" + + " \n" + + ' INSERT INTO "typegraph_fences" (key, generation)\n' + + ' SELECT $3, 1 FROM "fence_lock" WHERE TRUE\n' + + ' ON CONFLICT (key) DO UPDATE SET generation = "typegraph_fences".generation + 1\n' + + " RETURNING generation, current_setting('transaction_isolation') AS transaction_isolation\n" + + " \n" + + " "; // `postgresFenceSql` always supplies `lockTables`, but `FenceSql`'s type // keeps every member optional (a `row`-mechanism target may omit it), so // this pinned test asserts presence once instead of at every call site. @@ -75,6 +97,22 @@ describe("postgresFenceSql renders the bundled PostgreSQL spelling", () => { ]); }); + it("acquireKeyedSnapshotFence: the lock, then the fence-row advance, then the isolation fact, in one statement", () => { + const rendered = renderPostgres( + acquireKeyedSnapshotFence("typegraph:recorded-graph-write", "graph-1"), + ); + expect(rendered.sql).toBe(SNAPSHOT_FENCE_SQL); + expect(rendered.params).toEqual([ + "typegraph:recorded-graph-write", + "graph-1", + "typegraph:recorded-graph-write:graph-1", + ]); + }); + + it("acquireKeyedSnapshotFence: absent for a target naming no fences relation, which cannot advance a fence row", () => { + expect(fenceStatements.acquireKeyedSnapshotFence).toBeUndefined(); + }); + it.each([ ["share", "SHARE MODE"], ["share-row-exclusive", "SHARE ROW EXCLUSIVE MODE"], @@ -126,22 +164,36 @@ describe("resolveFenceStatements: the default (advisory) style's isolationFact() * statement ran; this proves which one. */ describe("postgresFenceSql: every lock site's real call, pinned", () => { - it("store/recorded-capture/clock.ts lockRecordedGraphWrite: acquireKeyedWithIsolation(typegraph:recorded-graph-write, graphId)", () => { + it("store/recorded-capture/clock.ts lockRecordedGraphWrite: acquireKeyedWithIsolation(typegraph:recorded-graph-write, graphId), with no fence-row write", () => { const rendered = renderPostgres( - fenceStatements.acquireKeyedWithIsolation( + fenceStatementsWithFenceRow.acquireKeyedWithIsolation( "typegraph:recorded-graph-write", "graph-1", ), ); expect(rendered.sql).toBe( - "\n SELECT\n" + + "\n" + + " SELECT\n" + " pg_advisory_xact_lock(hashtext($1), hashtext($2)),\n" + " current_setting('transaction_isolation') AS transaction_isolation\n" + " ", ); + expect(rendered.sql).not.toContain("typegraph_fences"); + expect(rendered.params).toEqual([ + "typegraph:recorded-graph-write", + "graph-1", + ]); + }); + + it("store/recorded-capture/clock.ts lockRecordedGraphDecision: acquireKeyedSnapshotFence(typegraph:recorded-graph-write, graphId)", () => { + const rendered = renderPostgres( + acquireKeyedSnapshotFence("typegraph:recorded-graph-write", "graph-1"), + ); + expect(rendered.sql).toBe(SNAPSHOT_FENCE_SQL); expect(rendered.params).toEqual([ "typegraph:recorded-graph-write", "graph-1", + "typegraph:recorded-graph-write:graph-1", ]); }); diff --git a/packages/typegraph/tests/postgres-working-copy-name-seams.test.ts b/packages/typegraph/tests/postgres-working-copy-name-seams.test.ts index 8e3274e2b..3a56890ff 100644 --- a/packages/typegraph/tests/postgres-working-copy-name-seams.test.ts +++ b/packages/typegraph/tests/postgres-working-copy-name-seams.test.ts @@ -3,6 +3,12 @@ import { z } from "zod"; import { defineNode } from "../src"; import { deriveBackend } from "../src/backend/derive-backend"; +import { + allocationNames, + allocationPhysicalPrefix, + allocationRelationSuffixes, +} from "../src/backend/drizzle/postgres-working-copy-names"; +import { defaultPostgresTableNames } from "../src/backend/drizzle/schema/postgres"; import type { GraphBackend } from "../src/backend/types"; import { ConfigurationError } from "../src/errors"; import { defineNodeIndex } from "../src/indexes"; @@ -145,6 +151,106 @@ describe("allocation vector table prefix", () => { ); }); +/** + * Relations an allocation created by an earlier release already has on disk, + * named by the leading characters of their key. Renaming one would strand its + * rows on reopen. + */ +const RELEASED_RELATION_KEYS = [ + "nodes", + "edges", + "recordedNodes", + "recordedEdges", + "recordedClock", + "revisionOrigins", + "revisionChanges", + "identityAssertions", + "recordedIdentityAssertions", + "identityClosure", + "identitySeparation", + "uniques", + "edgeClaims", + "baseSchemaVersions", + "schemaVersions", + "graphTemplates", + "fulltext", + "indexMaterializations", + "contributionMaterializations", + "kindRemovals", + "reconciliationMarkers", + "fences", +] as const satisfies readonly (keyof typeof defaultPostgresTableNames)[]; +const RELEASED_SUFFIX_LENGTH = 15; + +describe("allocation relation names", () => { + it("gives every bundled relation its own physical name within the identifier limit", async () => { + const names = await allocationNames("allocation-under-test"); + const physical = Object.values(names); + console.log("allocation relation names", names); + + expect(Object.keys(names).toSorted()).toEqual( + Object.keys(defaultPostgresTableNames).toSorted(), + ); + expect(new Set(physical).size).toBe(physical.length); + for (const name of physical) { + expect(name.length).toBeLessThanOrEqual(POSTGRES_IDENTIFIER_LIMIT); + } + expect(names.identityTransitions).not.toBe( + names.identityTransitionRetention, + ); + }); + + it("recovers the ledger prefix every relation is named under", async () => { + const names = await allocationNames("allocation-under-test"); + const prefix = allocationPhysicalPrefix(names); + + expect(prefix).toMatch(/^tgw_[0-9a-f]{24}_$/u); + expect(() => allocationVectorTablePrefix(prefix)).not.toThrow(); + for (const name of Object.values(names)) { + expect(name.startsWith(prefix)).toBe(true); + } + expect( + allocationPhysicalPrefix(await allocationNames("another-allocation")), + ).not.toBe(prefix); + }); + + it("keeps the names earlier releases gave their relations", async () => { + const names = await allocationNames("allocation-under-test"); + const prefix = allocationPhysicalPrefix(names); + for (const key of RELEASED_RELATION_KEYS) { + expect(names[key]).toBe( + `${prefix}${key.slice(0, RELEASED_SUFFIX_LENGTH)}`, + ); + } + }); + + it("tells apart keys that share their leading characters, whatever their order", () => { + const keys = [ + "identityTransitions", + "identityTransitionRetention", + "identityTransitionRetentionWindow", + "nodes", + ] as const; + const forward = allocationRelationSuffixes(keys); + const backward = allocationRelationSuffixes(keys.toReversed()); + + expect(new Set(forward.values()).size).toBe(keys.length); + expect(forward.get("nodes")).toBe("nodes"); + for (const key of keys) { + expect(backward.get(key)).toBe(forward.get(key)); + expect(forward.get(key)?.length).toBeLessThanOrEqual( + RELEASED_SUFFIX_LENGTH, + ); + } + }); + + it("refuses a key set it cannot name distinctly", () => { + expect(() => allocationRelationSuffixes(["nodes", "nodes"])).toThrow( + /resolve to the same physical suffix/u, + ); + }); +}); + // Only object identity keys the binding; no backend behavior is exercised. const backend = (): GraphBackend => ({}) as GraphBackend; diff --git a/packages/typegraph/tests/predicate-compiler.test.ts b/packages/typegraph/tests/predicate-compiler.test.ts index 6d190005f..c0073c8f8 100644 --- a/packages/typegraph/tests/predicate-compiler.test.ts +++ b/packages/typegraph/tests/predicate-compiler.test.ts @@ -69,7 +69,7 @@ function subqueryAst(alias: string, kind: string, graphId?: string): QueryAst { start: { alias, kinds: [kind], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [], predicates: [], diff --git a/packages/typegraph/tests/predicate-utils.test.ts b/packages/typegraph/tests/predicate-utils.test.ts index ea2bc3f0e..a729a05a8 100644 --- a/packages/typegraph/tests/predicate-utils.test.ts +++ b/packages/typegraph/tests/predicate-utils.test.ts @@ -51,7 +51,7 @@ function makeNodePredicate( function makeMinimalAst(overrides: Partial = {}): QueryAst { return { - start: { alias: "p", kinds: ["Person"], includeSubClasses: false }, + start: { alias: "p", kinds: ["Person"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { fields: [] }, diff --git a/packages/typegraph/tests/predicates-builder.test.ts b/packages/typegraph/tests/predicates-builder.test.ts index 1ebc450a8..40ab37d1b 100644 --- a/packages/typegraph/tests/predicates-builder.test.ts +++ b/packages/typegraph/tests/predicates-builder.test.ts @@ -573,7 +573,7 @@ describe("subquery predicates", () => { start: { alias: "u", kinds: ["User"], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [], predicates: [], diff --git a/packages/typegraph/tests/prepared-query.test.ts b/packages/typegraph/tests/prepared-query.test.ts index 8374a661b..fb48a1001 100644 --- a/packages/typegraph/tests/prepared-query.test.ts +++ b/packages/typegraph/tests/prepared-query.test.ts @@ -64,7 +64,7 @@ function makeComparison( function makeMinimalAst(predicates: readonly NodePredicate[] = []): QueryAst { return { - start: { alias: "p", kinds: ["Person"], includeSubClasses: false }, + start: { alias: "p", kinds: ["Person"], expansion: "exact" as const }, traversals: [], predicates, projection: { fields: [] }, @@ -220,7 +220,7 @@ describe("hasParameterReferences", () => { __type: "exists", subquery: { ...subqueryAst, - start: { alias: "q", kinds: ["Order"], includeSubClasses: false }, + start: { alias: "q", kinds: ["Order"], expansion: "exact" as const }, }, negated: false, }; @@ -236,7 +236,7 @@ describe("hasParameterReferences", () => { "q", ), ]), - start: { alias: "q", kinds: ["Order"], includeSubClasses: false }, + start: { alias: "q", kinds: ["Order"], expansion: "exact" as const }, }; const expr: PredicateExpression = { __type: "in_subquery", diff --git a/packages/typegraph/tests/prepared-result-stop-predicates.test.ts b/packages/typegraph/tests/prepared-result-stop-predicates.test.ts index 9ba92c034..85ee8da5f 100644 --- a/packages/typegraph/tests/prepared-result-stop-predicates.test.ts +++ b/packages/typegraph/tests/prepared-result-stop-predicates.test.ts @@ -23,7 +23,7 @@ function parameterComparison(name: string): PredicateExpression { function queryWithResultAndStopParameters(): QueryAst { return { graphId: "prepared-predicates", - start: { alias: "root", includeSubClasses: false, kinds: ["Node"] }, + start: { alias: "root", expansion: "exact", kinds: ["Node"] }, traversals: [ { direction: "out", diff --git a/packages/typegraph/tests/profiler.test.ts b/packages/typegraph/tests/profiler.test.ts index 5dd2e6db3..3e1271245 100644 --- a/packages/typegraph/tests/profiler.test.ts +++ b/packages/typegraph/tests/profiler.test.ts @@ -814,14 +814,20 @@ describe("QueryProfiler", () => { expect(emailRec).toBeUndefined(); }); - it("attributes includeSubClasses filters to matching kinds", async () => { + it("attributes subclass-expansion filters to matching kinds", async () => { const industryPointer = jsonPointer(["industry"]); const profiler = new QueryProfiler({ minFrequencyForRecommendation: 1 }); const profiledStore = profiler.attachToStore(store); + // "industry" is a Company-only field, not on the polymorphic + // Organization alias's own (parent) schema — the typed-subsumption checks guarantee only + // the PARENT's properties on a polymorphic alias, so this predicate + // (deliberately probing a subclass-only field) needs the same + // documented cast pattern graph-extension tests use for a property + // outside the alias's statically-known shape. await profiledStore .query() - .from("Organization", "o", { includeSubClasses: true }) + .from("Organization", "o", { expansion: "subclasses" }) // Deliberate low-level subclass-only predicate: the typed shared-field // accessor refuses industry because Organization does not declare it. .whereNode("o", () => @@ -1425,13 +1431,13 @@ describe("AST Extractor", () => { expect(ageAccess).toBeDefined(); }); - it("includes expanded kinds for includeSubClasses aliases", () => { + it("includes expanded kinds for subclass-expanded aliases", () => { const namePointer = jsonPointer(["name"]); const builder = store.query().from("Organization", "o", { - includeSubClasses: true, + expansion: "subclasses", }); const query = builder - .whereNode("o", (o) => requireDefined(o["name"]).eq("Acme")) + .whereNode("o", (o) => requireDefined(o.name).eq("Acme")) .select((ctx) => ctx.o); const accesses = extractPropertyAccesses(query.toAst()); diff --git a/packages/typegraph/tests/property/arbitraries.ts b/packages/typegraph/tests/property/arbitraries.ts index 4e69f678d..edede0569 100644 --- a/packages/typegraph/tests/property/arbitraries.ts +++ b/packages/typegraph/tests/property/arbitraries.ts @@ -6,6 +6,10 @@ */ import fc from "fast-check"; +import { isObjectSchema } from "../../src/schema/migration"; +import { type JsonSchema } from "../../src/schema/types"; +import { requireDefined } from "../../src/utils/presence"; + // ============================================================ // String Arbitraries // ============================================================ @@ -42,3 +46,503 @@ export const unicodeStringArb = fc.oneof( * Sort directions. */ export const sortDirectionArb = fc.constantFrom("asc", "desc"); + +// ============================================================ +// JSON-Schema Arbitraries (structural subtyping) +// +// Promoted here (rather than kept local to the structural-subtype property +// test) so the child/parent-agreement +// property test can import them without re-spelling the generator. +// ============================================================ + +/** + * Object-schema field names the generator draws from. Kept disjoint from + * `ADDED_PROPERTY_NAME_POOL` (below) so `tightenArb`'s "add a required + * property" operation never needs to filter for a name collision — the two + * pools simply never overlap. + */ +const OBJECT_FIELD_NAME_POOL = ["fieldA", "fieldB", "fieldC"] as const; + +/** Property names `tightenArb` uses when adding a new required property. */ +const ADDED_PROPERTY_NAME_POOL = [ + "addedW", + "addedX", + "addedY", + "addedZ", +] as const; + +/** A small fixed pool of scalar values for `enum` / `const` generation. */ +const ENUM_VALUE_POOL: readonly unknown[] = [ + "alpha", + "beta", + "gamma", + 0, + 1, + 2, + true, + false, +]; + +/** Patterns and formats a string leaf may carry; compared by equality. */ +const PATTERN_POOL = ["^a", "b$", "^[a-c]+$"] as const; +const FORMAT_POOL = ["email", "uuid", "date-time"] as const; + +function stringLeafArb(): fc.Arbitrary { + return fc + .record( + { + minLength: fc.option(fc.nat({ max: 4 }), { nil: undefined }), + maxLength: fc.option(fc.integer({ min: 4, max: 12 }), { + nil: undefined, + }), + pattern: fc.option(fc.constantFrom(...PATTERN_POOL), { + nil: undefined, + }), + format: fc.option(fc.constantFrom(...FORMAT_POOL), { nil: undefined }), + }, + { requiredKeys: [] }, + ) + .map((bounds) => ({ type: "string", ...bounds }) as JsonSchema); +} + +function numberLeafArb(): fc.Arbitrary { + return fc + .record( + { + type: fc.constantFrom("number", "integer"), + minimum: fc.option(fc.integer({ min: -10, max: 0 }), { + nil: undefined, + }), + maximum: fc.option(fc.integer({ min: 0, max: 10 }), { nil: undefined }), + exclusiveMinimum: fc.option(fc.integer({ min: -12, max: -1 }), { + nil: undefined, + }), + exclusiveMaximum: fc.option(fc.integer({ min: 1, max: 12 }), { + nil: undefined, + }), + multipleOf: fc.option(fc.constantFrom(2, 0.5, 0.25), { + nil: undefined, + }), + }, + { requiredKeys: ["type"] }, + ) + .map((bounds) => bounds as JsonSchema); +} + +/** A union of bare primitives, as the projection writes `.nullable()`. */ +function typeTokenArrayLeafArb(): fc.Arbitrary { + return fc + .record( + { + type: fc.constantFrom( + ["string", "null"], + ["number", "null"], + ["string", "number"], + ), + minLength: fc.option(fc.nat({ max: 3 }), { nil: undefined }), + }, + { requiredKeys: ["type"] }, + ) + .map((fields) => fields as JsonSchema); +} + +function enumLeafArb(): fc.Arbitrary { + return fc + .subarray([...ENUM_VALUE_POOL], { minLength: 1 }) + .map((values) => ({ enum: values })); +} + +function constLeafArb(): fc.Arbitrary { + return fc.constantFrom(...ENUM_VALUE_POOL).map((value) => ({ const: value })); +} + +function scalarSchemaArb(): fc.Arbitrary { + return fc.oneof( + stringLeafArb(), + numberLeafArb(), + typeTokenArrayLeafArb(), + fc.constant({ type: "integer" }), + fc.constant({ type: "boolean" }), + fc.constant({ type: "null" }), + enumLeafArb(), + constLeafArb(), + ); +} + +function arrayContainerArb( + schemaArb: fc.Arbitrary, +): fc.Arbitrary { + return fc + .record( + { + items: schemaArb, + minItems: fc.option(fc.nat({ max: 2 }), { nil: undefined }), + maxItems: fc.option(fc.integer({ min: 2, max: 5 }), { nil: undefined }), + }, + { requiredKeys: ["items"] }, + ) + .map((fields) => ({ type: "array", ...fields }) as JsonSchema); +} + +/** A closed tuple, as the projection writes `z.tuple()` with no rest. */ +function tupleContainerArb( + schemaArb: fc.Arbitrary, +): fc.Arbitrary { + return fc.array(schemaArb, { minLength: 1, maxLength: 3 }).map((members) => ({ + type: "array", + prefixItems: members, + items: false, + minItems: members.length, + maxItems: members.length, + })); +} + +function objectContainerArb( + schemaArb: fc.Arbitrary, +): fc.Arbitrary { + const fieldArb = fc.record({ + name: fc.constantFrom(...OBJECT_FIELD_NAME_POOL), + propertySchema: schemaArb, + isRequired: fc.boolean(), + }); + return fc + .record({ + fields: fc.uniqueArray(fieldArb, { + selector: (field) => field.name, + maxLength: OBJECT_FIELD_NAME_POOL.length, + }), + // `false` (the common `z.object` shape), `{}` — an explicitly OPEN, + // unconstrained extras schema (`z.looseObject`'s projection) — or a + // typed catchall (`.catchall(schema)`), so P2 and P6 also exercise + // compareObject's width-subtyping branch for a parent property the + // child leaves to its own open extras. The tightening operations + // below keep a typed catchall's extras under it (see + // `tightenAddRequiredPropertyArb`). + extras: fc.oneof( + fc.constant(false), + fc.constant({}), + schemaArb, + ), + }) + .map(({ fields, extras }) => { + const properties: Record = {}; + const required: string[] = []; + for (const field of fields) { + properties[field.name] = field.propertySchema; + if (field.isRequired) required.push(field.name); + } + return { + type: "object", + properties, + required, + additionalProperties: extras, + }; + }); +} + +/** A record, as the projection writes `z.record(keys, values)`. */ +function recordContainerArb( + schemaArb: fc.Arbitrary, +): fc.Arbitrary { + return fc + .record({ + keys: fc.oneof( + fc.constant({ type: "string" }), + fc.constant({ type: "string", minLength: 1 }), + fc + .subarray([...OBJECT_FIELD_NAME_POOL], { minLength: 1 }) + .map((names): JsonSchema => ({ type: "string", enum: names })), + ), + values: schemaArb, + }) + .map(({ keys, values }) => ({ + type: "object", + propertyNames: keys, + additionalProperties: values, + })); +} + +function unionContainerArb( + schemaArb: fc.Arbitrary, +): fc.Arbitrary { + return fc.oneof( + fc + .tuple(schemaArb, schemaArb) + .map(([first, second]) => ({ anyOf: [first, second] })), + // Members of different types, the shape of an exclusive `oneOf`. + fc + .tuple(stringLeafArb(), numberLeafArb()) + .map(([first, second]) => ({ oneOf: [first, second] })), + ); +} + +const jsonSchemaLetrec = fc.letrec<{ + schema: JsonSchema; + container: JsonSchema; +}>((tie) => ({ + schema: fc.oneof({ maxDepth: 3 }, scalarSchemaArb(), tie("container")), + container: fc.oneof( + arrayContainerArb(tie("schema")), + tupleContainerArb(tie("schema")), + objectContainerArb(tie("schema")), + recordContainerArb(tie("schema")), + unionContainerArb(tie("schema")), + ), +})); + +/** + * A JSON Schema drawn from the fragment `isStructuralSubtype` actually + * compares: scalars (string with optional length bounds, `pattern` and + * `format`; number or integer with optional inclusive and exclusive bounds + * and `multipleOf`; a `type` token array; integer, boolean, null), `enum` / + * `const` over a small fixed value pool, an array of a subschema with + * optional item-count bounds, a closed tuple, an object with 0-3 properties + * split between required and optional whose extras are closed, open or a + * typed catchall, a record with `propertyNames`, a 2-member `anyOf`, and a + * `oneOf` of a string and a number. Depth is capped at 3. Deliberately never generates `$ref`, `not`, `allOf`, or an + * unrecognized key — reflexivity is claimed only over this comparable + * fragment, not over constructs the predicate refuses. + */ +export const comparableSchemaArb: fc.Arbitrary = + jsonSchemaLetrec.schema; + +function omitProperty( + properties: Readonly>, + name: string, +): Record { + const remaining: Record = {}; + for (const [key, value] of Object.entries(properties)) { + if (key !== name) remaining[key] = value; + } + return remaining; +} + +// Each bound tightens independently of the other: raising `minLength` (or +// `minimum`) is a valid tightening regardless of the current `maxLength` (or +// `maximum`), and vice versa. An earlier version tried to keep the two +// bounds mutually "sensible" by clamping the lowered bound up to the raised +// one — but that clamp could push the lowered bound BACK UP past the +// parent's own value, silently un-tightening it (caught by property P3: +// chaining two such tightenings produced a schema that was no longer a +// subtype of the original parent). Applying each bound in isolation avoids +// the interaction entirely. +function tightenStringArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (parent.type !== "string") return undefined; + const parentMaxLength = parent.maxLength; + return fc + .record({ + minLengthDelta: fc.nat({ max: 3 }), + maxLengthDelta: fc.nat({ max: 3 }), + }) + .map(({ minLengthDelta, maxLengthDelta }) => ({ + ...parent, + minLength: (parent.minLength ?? 0) + minLengthDelta, + ...(parentMaxLength === undefined ? + {} + : { maxLength: parentMaxLength - maxLengthDelta }), + })); +} + +function tightenNumericArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (parent.type !== "number" && parent.type !== "integer") return undefined; + const parentMaximum = parent.maximum; + return fc + .record({ + minimumDelta: fc.nat({ max: 3 }), + maximumDelta: fc.nat({ max: 3 }), + }) + .map(({ minimumDelta, maximumDelta }) => ({ + ...parent, + minimum: (parent.minimum ?? 0) + minimumDelta, + ...(parentMaximum === undefined ? + {} + : { maximum: parentMaximum - maximumDelta }), + })); +} + +function tightenEnumArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (parent.enum === undefined || parent.enum.length === 0) return undefined; + return fc + .subarray([...parent.enum], { minLength: 1 }) + .map((values) => ({ ...parent, enum: values })); +} + +function tightenToConstFromEnumArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (parent.enum === undefined || parent.enum.length === 0) return undefined; + return fc + .constantFrom(...parent.enum) + .map((value) => ({ ...parent, const: value })); +} + +function tightenAddRequiredPropertyArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (!isObjectSchema(parent)) return undefined; + const parentProps = parent.properties ?? {}; + const freshNames = ADDED_PROPERTY_NAME_POOL.filter( + (name) => !Object.hasOwn(parentProps, name), + ); + if (freshNames.length === 0) return undefined; + // A key the parent does not name must satisfy the parent's typed catchall. + const extras = parent.additionalProperties; + const propertySchemaArb = + typeof extras === "object" && Object.keys(extras).length > 0 ? + tightenArb(extras) + : comparableSchemaArb; + return fc + .tuple(fc.constantFrom(...freshNames), propertySchemaArb) + .map(([name, propertySchema]) => ({ + ...parent, + properties: { ...parentProps, [name]: propertySchema }, + required: [...(parent.required ?? []), name], + })); +} + +function tightenDropOptionalPropertyArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (!isObjectSchema(parent)) return undefined; + // Dropping a declared property is only a sound tightening when `parent` is + // CLOSED: an open `parent` still constrains that property name + // through its own additional-properties schema, so simply omitting the + // property from `properties` would fall back to that OPEN schema — which + // may be looser than the one being dropped, un-tightening the child. + if (parent.additionalProperties !== false) return undefined; + const parentProps = parent.properties ?? {}; + const parentRequired = new Set(parent.required); + const optionalNames = Object.keys(parentProps).filter( + (name) => !parentRequired.has(name), + ); + if (optionalNames.length === 0) return undefined; + return fc.constantFrom(...optionalNames).map((name) => ({ + ...parent, + properties: omitProperty(parentProps, name), + })); +} + +function tightenNestedPropertyArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + if (!isObjectSchema(parent)) return undefined; + const parentProps = parent.properties ?? {}; + const names = Object.keys(parentProps); + if (names.length === 0) return undefined; + return fc.constantFrom(...names).chain((name) => + tightenArb( + requireDefined( + parentProps[name], + `Expected property "${name}" to be present.`, + ), + ).map((tightenedProperty) => ({ + ...parent, + properties: { ...parentProps, [name]: tightenedProperty }, + })), + ); +} + +function tightenExclusiveBoundsArb( + parent: JsonSchema, +): fc.Arbitrary | undefined { + const exclusiveMinimum = + typeof parent["exclusiveMinimum"] === "number" ? + parent["exclusiveMinimum"] + : undefined; + const exclusiveMaximum = + typeof parent["exclusiveMaximum"] === "number" ? + parent["exclusiveMaximum"] + : undefined; + if (exclusiveMinimum === undefined && exclusiveMaximum === undefined) { + return undefined; + } + return fc + .record({ lowerDelta: fc.nat({ max: 2 }), upperDelta: fc.nat({ max: 2 }) }) + .map(({ lowerDelta, upperDelta }) => ({ + ...parent, + ...(exclusiveMinimum === undefined ? + {} + : { exclusiveMinimum: exclusiveMinimum + lowerDelta }), + ...(exclusiveMaximum === undefined ? + {} + : { exclusiveMaximum: exclusiveMaximum - upperDelta }), + })); +} + +function replaceMember( + members: readonly JsonSchema[], + index: number, + member: JsonSchema, +): JsonSchema[] { + return members.map((existing, position) => + position === index ? member : existing, + ); +} + +/** One `key` member list (`prefixItems`, `anyOf`, `oneOf`) with a member tightened. */ +function tightenMemberArb( + parent: JsonSchema, + key: "prefixItems" | "anyOf" | "oneOf", +): fc.Arbitrary | undefined { + const members = parent[key]; + if (members === undefined || members.length === 0) return undefined; + return fc.nat({ max: members.length - 1 }).chain((index) => + tightenArb(requireDefined(members[index])).map((member) => ({ + ...parent, + [key]: replaceMember(members, index, member), + })), + ); +} + +/** An array's `items`, or an object's typed extras, tightened. */ +function tightenSubschemaArb( + parent: JsonSchema, + key: "items" | "additionalProperties", +): fc.Arbitrary | undefined { + const subschema = parent[key]; + if (typeof subschema !== "object" || Object.keys(subschema).length === 0) { + return undefined; + } + return tightenArb(subschema).map((tightened) => ({ + ...parent, + [key]: tightened, + })); +} + +/** + * A schema guaranteed BY CONSTRUCTION to be a structural subtype of `parent`: + * identity, or one of the tightening operations applicable to `parent`'s + * shape (raise a string/number bound or an exclusive bound, narrow an enum, + * promote an enum member to `const`, add a fresh required property, drop an + * optional property, or recursively tighten one existing property, tuple + * member, union member, array item schema or typed extras schema). Only operations that apply to + * `parent`'s actual shape are offered, so the result is always well-typed. + */ +export function tightenArb(parent: JsonSchema): fc.Arbitrary { + const candidates = [ + fc.constant(parent), + tightenStringArb(parent), + tightenNumericArb(parent), + tightenEnumArb(parent), + tightenToConstFromEnumArb(parent), + tightenAddRequiredPropertyArb(parent), + tightenDropOptionalPropertyArb(parent), + tightenNestedPropertyArb(parent), + tightenExclusiveBoundsArb(parent), + tightenMemberArb(parent, "prefixItems"), + tightenMemberArb(parent, "anyOf"), + tightenMemberArb(parent, "oneOf"), + tightenSubschemaArb(parent, "items"), + tightenSubschemaArb(parent, "additionalProperties"), + ].filter( + (candidate): candidate is fc.Arbitrary => + candidate !== undefined, + ); + return fc.oneof(...candidates); +} diff --git a/packages/typegraph/tests/property/claim-axis.test.ts b/packages/typegraph/tests/property/claim-axis.test.ts index d8c239bed..2b5960bf8 100644 --- a/packages/typegraph/tests/property/claim-axis.test.ts +++ b/packages/typegraph/tests/property/claim-axis.test.ts @@ -15,6 +15,10 @@ import { z } from "zod"; import { getKindsForUniquenessCheck } from "../../src/constraints"; import { defineNode } from "../../src/core/node"; import { type NodeType } from "../../src/core/types"; +import { + META_EDGE_EQUIVALENT_TO, + META_EDGE_SUB_CLASS_OF, +} from "../../src/ontology/constants"; import { core } from "../../src/ontology/core-meta-edges"; import { type OntologyRelation } from "../../src/ontology/types"; import { @@ -36,11 +40,31 @@ function kindType(name: string): NodeType { function subClassRegistry( edges: readonly (readonly [string, string])[], ): KindRegistry { - const relations: OntologyRelation[] = edges.map(([child, parent]) => ({ - metaEdge: core.subClassOfMetaEdge, - from: kindType(child), - to: kindType(parent), - })); + return mixedRegistry(edges, []); +} + +/** + * Builds a registry from a mix of `subClassOf` and `equivalentTo` relations — + * The registry folds both into the SAME subsumption closure, so the axis determinism + * property must hold whether a component is connected by subclassing, + * equivalence, or both. + */ +function mixedRegistry( + subClassEdges: readonly (readonly [string, string])[], + equivalenceEdges: readonly (readonly [string, string])[], +): KindRegistry { + const relations: OntologyRelation[] = [ + ...subClassEdges.map(([child, parent]): OntologyRelation => ({ + metaEdge: core.subClassOfMetaEdge, + from: kindType(child), + to: kindType(parent), + })), + ...equivalenceEdges.map(([left, right]): OntologyRelation => ({ + metaEdge: core.equivalentToMetaEdge, + from: kindType(left), + to: kindType(right), + })), + ]; return new KindRegistry( new Map(), new Map(), @@ -81,16 +105,44 @@ const subClassEdgesArb = fc ), ); -/** Every kind reachable from `kind` through subclass edges in either direction. */ +/** + * Random unordered `equivalentTo` edges over the same kind names. No + * acyclicity constraint is needed — equivalence is symmetric/transitive by + * construction, so any pair set is coherent. + */ +const equivalenceEdgesArb = fc + .uniqueArray( + fc + .tuple( + fc.integer({ min: 0, max: KIND_NAMES.length - 2 }), + fc.integer({ min: 1, max: KIND_NAMES.length - 1 }), + ) + .filter(([left, right]) => left < right), + { maxLength: 8 }, + ) + .map((pairs) => + pairs.map( + ([left, right]) => + [KIND_NAMES[left] as string, KIND_NAMES[right] as string] as const, + ), + ); + +/** + * Every kind reachable from `kind` through subclass edges OR equivalence + * edges, in either direction — the same "one connected component, one axis" + * property must hold regardless of which meta-edge joined two kinds, since + * the registry folds both into one subsumption closure. + */ function connectedKinds( kind: string, - edges: readonly (readonly [string, string])[], + subClassEdges: readonly (readonly [string, string])[], + equivalenceEdges: readonly (readonly [string, string])[] = [], ): readonly string[] { const members = new Set([kind]); let grew = true; while (grew) { grew = false; - for (const [child, parent] of edges) { + for (const [child, parent] of subClassEdges) { if (members.has(child) && !members.has(parent)) { members.add(parent); grew = true; @@ -100,28 +152,134 @@ function connectedKinds( grew = true; } } + for (const [left, right] of equivalenceEdges) { + if (members.has(left) && !members.has(right)) { + members.add(right); + grew = true; + } + if (members.has(right) && !members.has(left)) { + members.add(left); + grew = true; + } + } } return [...members]; } +/** + * A bare external IRI, usable as a `subClassOf`/`equivalentTo` endpoint + * alongside the ordinary kind names above. `KIND_NAMES`-only arbitraries can + * never generate the shape a real review defect lived in — an equivalence + * class routed through an IRI that is ALSO a `subClassOf` endpoint — because + * every endpoint they produce is a registered kind. + */ +const EXTERNAL_IRI = "https://example.com/claim-axis-probe"; + +const endpointWithIriArb = fc.oneof( + fc.constantFrom(...KIND_NAMES), + fc.constant(EXTERNAL_IRI), +); + +const relationWithIriArb = fc + .tuple( + fc.constantFrom(META_EDGE_SUB_CLASS_OF, META_EDGE_EQUIVALENT_TO), + endpointWithIriArb, + endpointWithIriArb, + ) + .filter(([, from, to]) => from !== to); + +/** + * `EXTERNAL_IRI` is always ALSO the target of a fixed `equivalentTo` from a + * real kind, so it is guaranteed to belong to an equivalence class in every + * generated ontology. This is deliberate, not an incomplete generator: a bare + * `subClassOf` naming an external IRI that is equivalentTo NOTHING is a + * separate, pre-existing gap in `rootAncestor` (an IRI with an empty ancestor + * set trivially passes its "maximal" check and can itself become the chosen + * root, whose `expandSubClasses` then never contains the base kind) — already + * present before the equivalence fold, unrelated to the equivalence-routing + * defect this property pins, and out of scope here. + */ +const ontologyWithIriArb = fc + .array(relationWithIriArb, { maxLength: 10 }) + .map((relations) => [ + [META_EDGE_EQUIVALENT_TO, "Alpha", EXTERNAL_IRI] as const, + ...relations, + ]); + +/** + * Builds a registry directly from raw `(metaEdge, from, to)` tuples, with + * `from`/`to` used exactly as given — including the external IRI, which + * `getKindName` (the sole consumer inside `computeClosuresFromOntology`) + * passes through unchanged. This reaches the same closure code a + * graph-extension document's untyped strings do, without routing an IRI + * through `kindType`/`defineNode`. + */ +function registryFromRawRelations( + relations: readonly (readonly [string, string, string])[], +): KindRegistry { + const ontologyRelations: OntologyRelation[] = relations.map( + ([metaEdge, from, to]): OntologyRelation => ({ + metaEdge: + metaEdge === META_EDGE_SUB_CLASS_OF ? + core.subClassOfMetaEdge + : core.equivalentToMetaEdge, + from, + to, + }), + ); + return new KindRegistry( + new Map(), + new Map(), + computeClosuresFromOntology(ontologyRelations), + ); +} + describe("uniqueness claim axis determinism", () => { - it("gives every kind in a connected component the same axis", () => { + // Regression for a defect found reviewing the equivalence fold: a `subClassOf` endpoint that + // is an external IRI belonging to an equivalence class made `rootAncestor` + // pick a "root" whose own `expandSubClasses` did not contain the kind that + // asked — `getKindsForUniquenessCheck` returned a probe set missing the + // base kind entirely, contradicting its own documented contract ("the root + // and all its descendants, WHICH INCLUDES baseKind"). Root-caused and fixed + // in `computeEquivalenceRepresentatives` (kind-registry.ts): reverting that + // fix makes this fail immediately (a `TypeError` before the fold even + // stabilizes, or a probe set missing `kind`, depending on which shape + // fast-check shrinks to first). + it("always includes the base kind in its own kindWithSubClasses probe, even when an IRI mediates the connection", () => { fc.assert( - fc.property(subClassEdgesArb, (edges) => { - const registry = subClassRegistry(edges); + fc.property(ontologyWithIriArb, (tuples) => { + const registry = registryFromRawRelations(tuples); for (const kind of KIND_NAMES) { - const axis = uniquenessClaimAxis( - kind, - "kindWithSubClasses", - registry, - ); - for (const member of connectedKinds(kind, edges)) { - expect( - uniquenessClaimAxis(member, "kindWithSubClasses", registry), - ).toBe(axis); - } + expect( + getKindsForUniquenessCheck(kind, "kindWithSubClasses", registry), + ).toContain(kind); } }), + { numRuns: 500 }, + ); + }); + + it("gives every kind in a connected component the same axis", () => { + fc.assert( + fc.property( + subClassEdgesArb, + equivalenceEdgesArb, + (edges, equivEdges) => { + const registry = mixedRegistry(edges, equivEdges); + for (const kind of KIND_NAMES) { + const axis = uniquenessClaimAxis( + kind, + "kindWithSubClasses", + registry, + ); + for (const member of connectedKinds(kind, edges, equivEdges)) { + expect( + uniquenessClaimAxis(member, "kindWithSubClasses", registry), + ).toBe(axis); + } + } + }, + ), { numRuns: 200 }, ); }); @@ -131,19 +289,23 @@ describe("uniqueness claim axis determinism", () => { // key at an axis this kind never visits, so the probe's set must be inside // the component the axis is folded from. fc.assert( - fc.property(subClassEdgesArb, (edges) => { - const registry = subClassRegistry(edges); - for (const kind of KIND_NAMES) { - const component = new Set(connectedKinds(kind, edges)); - for (const probed of getKindsForUniquenessCheck( - kind, - "kindWithSubClasses", - registry, - )) { - expect(component.has(probed)).toBe(true); + fc.property( + subClassEdgesArb, + equivalenceEdgesArb, + (edges, equivEdges) => { + const registry = mixedRegistry(edges, equivEdges); + for (const kind of KIND_NAMES) { + const component = new Set(connectedKinds(kind, edges, equivEdges)); + for (const probed of getKindsForUniquenessCheck( + kind, + "kindWithSubClasses", + registry, + )) { + expect(component.has(probed)).toBe(true); + } } - } - }), + }, + ), { numRuns: 200 }, ); }); diff --git a/packages/typegraph/tests/property/composition-model.test.ts b/packages/typegraph/tests/property/composition-model.test.ts new file mode 100644 index 000000000..12eb8a4d7 --- /dev/null +++ b/packages/typegraph/tests/property/composition-model.test.ts @@ -0,0 +1,2069 @@ +/** + * Model-based composition property: random command sequences over a small + * composition graph, each command judged against an independent ownership + * model rather than by re-running the code under test. + * + * The graph covers every shape the write paths distinguish: a whole + * (`CpWhole`); a required `oneActive` mid kind (`CpMid`) realized by BOTH a + * part-side `partOf` edge and a whole-side `hasPart` edge, so one part's + * attachment spans two edge kinds; a required `one` leaf (`CpLeaf`); an + * optional `one` leaf (`CpTag`); and a reflexive optional folder + * (`CpFolder`), the one kind a composition cycle can run through. + * + * After every command the oracle checks: the allow/refuse verdict and error + * class the model predicts; that a refusal changed no node, edge or claim row; + * that the stored rows equal the model's; that no part holds more than one + * attaching edge and every live required part hangs from a live whole (read + * from the rows, not the model); `verifyConstraintFences()`; and that + * `parts()` / `wholes()` agree with the model. A node delete additionally + * checks its `planDelete` preview and the `cascadedParts` it reports against + * the model's closure, leaf-first and by kind, then id, within a level. + * + * The Date is faked and advanced per command, with an occasional step + * backwards, so a move instant can fall before an incumbent's start and a + * window end can fall before its edge's start. + */ +import fc from "fast-check"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + type CompositionCascadedPart, + CompositionError, + CompositionExistenceError, + ConfigurationError, + defineEdge, + defineGraph, + defineNode, + EdgeAcyclicityError, + EndpointNotFoundError, + type GraphBackend, + hasPart, + type NodeDeletePlan, + NodeDeletePlanMismatchError, + NodeNotFoundError, + type OperationHookContext, + partOf, + ValidationError, +} from "../../src"; +import { createPostgresBackend } from "../../src/backend/drizzle/postgres"; +import { createLocalPgliteBackend } from "../../src/backend/postgres/pglite"; +import { sql } from "../../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../../src/query/sql-intent"; +import { createStoreWithSchema } from "../../src/store"; +import { requireDefined } from "../../src/utils/presence"; +import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; + +// ============================================================ +// Graph +// ============================================================ + +const CpWhole = defineNode("CpWhole", { schema: z.object({}) }); +const CpMid = defineNode("CpMid", { schema: z.object({}) }); +const CpLeaf = defineNode("CpLeaf", { schema: z.object({}) }); +const CpTag = defineNode("CpTag", { schema: z.object({}) }); +const CpFolder = defineNode("CpFolder", { schema: z.object({}) }); +const cpMidOf = defineEdge("cpMidOf", { schema: z.object({}) }); +const cpWholeHasMid = defineEdge("cpWholeHasMid", { schema: z.object({}) }); +const cpLeafOf = defineEdge("cpLeafOf", { schema: z.object({}) }); +const cpTagOf = defineEdge("cpTagOf", { schema: z.object({}) }); +const cpFolderOf = defineEdge("cpFolderOf", { schema: z.object({}) }); + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + CpWhole: { type: CpWhole }, + CpMid: { type: CpMid }, + CpLeaf: { type: CpLeaf }, + CpTag: { type: CpTag }, + CpFolder: { type: CpFolder }, + }, + edges: { + cpMidOf: { + type: cpMidOf, + from: [CpMid], + to: [CpWhole], + cardinality: "oneActive", + }, + cpWholeHasMid: { + type: cpWholeHasMid, + from: [CpWhole], + to: [CpMid], + targetCardinality: "oneActive", + }, + cpLeafOf: { + type: cpLeafOf, + from: [CpLeaf], + to: [CpMid], + cardinality: "one", + }, + cpTagOf: { + type: cpTagOf, + from: [CpTag], + to: [CpMid], + cardinality: "one", + }, + cpFolderOf: { + type: cpFolderOf, + from: [CpFolder], + to: [CpFolder], + cardinality: "one", + }, + }, + ontology: [ + partOf(CpMid, CpWhole, { via: cpMidOf, existence: "required" }), + hasPart(CpWhole, CpMid, { via: cpWholeHasMid, existence: "required" }), + partOf(CpLeaf, CpMid, { via: cpLeafOf, existence: "required" }), + partOf(CpTag, CpMid, { via: cpTagOf }), + partOf(CpFolder, CpFolder, { via: cpFolderOf, partSide: "from" }), + ], + }); +} + +type CompositionGraph = ReturnType; +type CompositionStore = Awaited< + ReturnType> +>[0]; + +const NODE_KINDS = ["CpWhole", "CpMid", "CpLeaf", "CpTag", "CpFolder"] as const; +type NodeKind = (typeof NODE_KINDS)[number]; +type PartKind = Exclude; +const PART_KINDS: readonly PartKind[] = [ + "CpMid", + "CpLeaf", + "CpTag", + "CpFolder", +]; + +const EDGE_KINDS = [ + "cpMidOf", + "cpWholeHasMid", + "cpLeafOf", + "cpTagOf", + "cpFolderOf", +] as const; +type EdgeKind = (typeof EDGE_KINDS)[number]; + +/** The reference model's copy of the ontology above, written out by hand. */ +type ModelPair = Readonly<{ + partKind: PartKind; + wholeKind: NodeKind; + partSide: "from" | "to"; + population: "one" | "oneActive"; +}>; +const PAIRS: Readonly> = { + cpMidOf: { + partKind: "CpMid", + wholeKind: "CpWhole", + partSide: "from", + population: "oneActive", + }, + cpWholeHasMid: { + partKind: "CpMid", + wholeKind: "CpWhole", + partSide: "to", + population: "oneActive", + }, + cpLeafOf: { + partKind: "CpLeaf", + wholeKind: "CpMid", + partSide: "from", + population: "one", + }, + cpTagOf: { + partKind: "CpTag", + wholeKind: "CpMid", + partSide: "from", + population: "one", + }, + cpFolderOf: { + partKind: "CpFolder", + wholeKind: "CpFolder", + partSide: "from", + population: "one", + }, +}; +const REQUIRED_PART_KINDS: ReadonlySet = new Set(["CpMid", "CpLeaf"]); +/** Leaf kinds the raw backend may retire without orphaning anything below them. */ +const RAW_RETIRABLE_KINDS = ["CpLeaf", "CpTag"] as const; + +function edgeKindsOfPart(kind: PartKind): readonly EdgeKind[] { + return EDGE_KINDS.filter((edgeKind) => PAIRS[edgeKind].partKind === kind); +} + +// ============================================================ +// Reference model +// ============================================================ + +interface ModelNode { + kind: NodeKind; + live: boolean; +} +interface ModelEdge { + kind: EdgeKind; + partId: string; + wholeId: string; + validFrom: number; + validTo: number | undefined; + deleted: boolean; +} +interface Model { + clock: number; + /** Every node row that exists, live or tombstoned; a purged row is absent. */ + nodes: Map; + /** Every edge row that exists; ids under `PENDING_PREFIX` await their stored id. */ + edges: Map; + /** Every node id ever issued, by kind, so a purged id can be named again. */ + issuedIds: Map; + nextSerial: number; +} + +const PENDING_PREFIX = "pending:"; +const START_INSTANT = Date.parse("2099-01-01T00:00:00.000Z"); + +function initialModel(): Model { + return { + clock: START_INSTANT, + nodes: new Map(), + edges: new Map(), + issuedIds: new Map(NODE_KINDS.map((kind) => [kind, []])), + nextSerial: 0, + }; +} + +function issueId(model: Model, kind: NodeKind, salt: string): string { + model.nextSerial += 1; + const id = `${salt}${model.nextSerial}`; + requireDefined(model.issuedIds.get(kind)).push(id); + return id; +} + +function isLive(model: Model, id: string): boolean { + return model.nodes.get(id)?.live === true; +} + +function edgeCounts(edge: ModelEdge): boolean { + if (edge.deleted) return false; + return PAIRS[edge.kind].population === "one" || edge.validTo === undefined; +} + +function attachmentOf( + model: Model, + partId: string, +): Readonly<{ id: string; edge: ModelEdge }> | undefined { + for (const [id, edge] of model.edges) { + if (edge.partId === partId && edgeCounts(edge)) return { id, edge }; + } + return undefined; +} + +function countingPartEdges( + model: Model, + wholeId: string, +): readonly Readonly<{ id: string; edge: ModelEdge }>[] { + return [...model.edges] + .filter(([, edge]) => edge.wholeId === wholeId && edgeCounts(edge)) + .map(([id, edge]) => ({ id, edge })); +} + +/** Whether `ancestorId` is `nodeId` or one of the wholes above it. */ +function isSelfOrAncestor( + model: Model, + ancestorId: string, + nodeId: string, +): boolean { + const seen = new Set(); + let current: string | undefined = nodeId; + while (current !== undefined && !seen.has(current)) { + if (current === ancestorId) return true; + seen.add(current); + current = attachmentOf(model, current)?.edge.wholeId; + } + return false; +} + +function compareTuples( + left: readonly string[], + right: readonly string[], +): number { + for (const [index, value] of left.entries()) { + const other = requireDefined(right[index]); + if (value < other) return -1; + if (value > other) return 1; + } + return 0; +} + +/** + * The cascade closure of one whole: breadth-first through every counting + * composition edge (walking THROUGH a dead member), deepest level first, each + * level by kind then id, dead members dropped last. + */ +function cascadeClosure( + model: Model, + rootId: string, +): readonly CompositionCascadedPart[] { + const rounds: CompositionCascadedPart[][] = []; + let frontier = [rootId]; + while (frontier.length > 0) { + const round: CompositionCascadedPart[] = []; + for (const wholeId of frontier) { + const whole = requireDefined(model.nodes.get(wholeId)); + for (const { edge } of countingPartEdges(model, wholeId)) { + round.push({ + kind: requireDefined(model.nodes.get(edge.partId)).kind, + id: edge.partId, + whole: { kind: whole.kind, id: wholeId }, + viaEdgeKind: edge.kind, + }); + } + } + if (round.length > 0) rounds.push(round); + frontier = round.map((member) => member.id); + } + return rounds + .toReversed() + .flatMap((round) => + round.toSorted((left, right) => + compareTuples([left.kind, left.id], [right.kind, right.id]), + ), + ) + .filter((member) => isLive(model, member.id)); +} + +function incidentEdgeIds( + model: Model, + nodeIds: ReadonlySet, + includeDeleted: boolean, +): readonly string[] { + return [...model.edges] + .filter( + ([, edge]) => + (includeDeleted || !edge.deleted) && + (nodeIds.has(edge.partId) || nodeIds.has(edge.wholeId)), + ) + .map(([id]) => id) + .toSorted(); +} + +type ModelPlan = Readonly<{ + cascadedParts: readonly CompositionCascadedPart[]; + removedEdgeIds: readonly string[]; +}>; + +function deletePlan(model: Model, id: string): ModelPlan | undefined { + if (!isLive(model, id)) return undefined; + const cascadedParts = cascadeClosure(model, id); + const deleted = new Set([id, ...cascadedParts.map((part) => part.id)]); + return { + cascadedParts, + removedEdgeIds: incidentEdgeIds(model, deleted, false), + }; +} + +function cascadedPartKey(part: CompositionCascadedPart): string { + return `${part.kind}/${part.id}<${part.whole.kind}/${part.whole.id}:${part.viaEdgeKind}`; +} + +function plansMatch(left: ModelPlan, right: ModelPlan): boolean { + const sortedParts = (plan: ModelPlan) => + plan.cascadedParts.map((part) => cascadedPartKey(part)).toSorted(); + return ( + JSON.stringify(sortedParts(left)) === JSON.stringify(sortedParts(right)) && + JSON.stringify(left.removedEdgeIds) === JSON.stringify(right.removedEdgeIds) + ); +} + +function applySoftDelete(model: Model, id: string): void { + const plan = deletePlan(model, id); + if (plan === undefined) return; + for (const edgeId of plan.removedEdgeIds) { + requireDefined(model.edges.get(edgeId)).deleted = true; + } + for (const nodeId of [id, ...plan.cascadedParts.map((part) => part.id)]) { + requireDefined(model.nodes.get(nodeId)).live = false; + } +} + +function applyHardDelete(model: Model, id: string): void { + if (!model.nodes.has(id)) return; + const members = cascadeClosure(model, id).map((part) => part.id); + const purged = new Set([id, ...members]); + for (const edgeId of incidentEdgeIds(model, purged, true)) { + model.edges.delete(edgeId); + } + for (const nodeId of purged) model.nodes.delete(nodeId); +} + +let pendingSerial = 0; +function addEdge(model: Model, edge: Omit): void { + pendingSerial += 1; + model.edges.set(`${PENDING_PREFIX}${pendingSerial}`, { + ...edge, + deleted: false, + }); +} + +function storedEdgeIds(model: Model, kind: EdgeKind): readonly string[] { + return [...model.edges] + .filter( + ([id, edge]) => + edge.kind === kind && !edge.deleted && !id.startsWith(PENDING_PREFIX), + ) + .map(([id]) => id); +} + +function pick(items: readonly T[], index: number): T | undefined { + return items.length === 0 ? undefined : items[index % items.length]; +} + +/** Issued ids of a kind, plus one that was never issued. */ +function candidateIds(model: Model, kind: NodeKind): readonly string[] { + return [...requireDefined(model.issuedIds.get(kind)), `${kind}-ghost`]; +} + +/** + * Four picks in five name a live node of the kind (when there is one); the + * fifth names any issued id or the never-issued one, so refusals over dead + * and missing nodes stay in the mix without starving the graph of structure. + */ +function pickNodeId( + model: Model, + kind: NodeKind, + value: number, +): string | undefined { + const live = requireDefined(model.issuedIds.get(kind)).filter((id) => + isLive(model, id), + ); + const index = Math.floor(value / 5); + return value % 5 !== 0 && live.length > 0 ? + pick(live, index) + : pick(candidateIds(model, kind), index); +} + +function iso(instant: number): string { + return new Date(instant).toISOString(); +} + +// ============================================================ +// Steps: one write, its predicted verdict, and its effect on the model +// ============================================================ + +type ErrorClass = abstract new (...args: never[]) => Error; + +type DynamicNodeCollection = Readonly<{ + create: ( + props: object, + options?: Readonly<{ id?: string; partOf?: object }>, + ) => Promise; + delete: ( + id: string, + options?: Readonly<{ expect: NodeDeletePlan }>, + ) => Promise; + hardDelete: (id: string) => Promise; + bulkDelete: (ids: readonly string[]) => Promise; + upsertById: (id: string, props: object) => Promise; + reparent: (id: string, options: object) => Promise; + bulkReparent: ( + items: readonly Readonly<{ id: string; options: object }>[], + ) => Promise; + planDelete: (id: string) => Promise; + find: ( + filter: object, + temporal: Readonly<{ temporalMode: "includeTombstones" }>, + ) => Promise; +}>; +type NodeRefInput = Readonly<{ kind: string; id: string }>; +type DynamicEdgeCollection = Readonly<{ + create: ( + from: NodeRefInput, + to: NodeRefInput, + props: object, + ) => Promise; + update: ( + id: string, + props: object, + options: Readonly<{ validTo: string }>, + ) => Promise; + delete: (id: string) => Promise; + hardDelete: (id: string) => Promise; + bulkDelete: (ids: readonly string[]) => Promise; + find: ( + filter: object, + temporal: Readonly<{ temporalMode: "includeTombstones" }>, + ) => Promise; +}>; +type Surface = Readonly<{ + nodes: Readonly>; + edges: Readonly>; +}>; + +type Real = Readonly<{ + store: CompositionStore; + backend: GraphBackend; + graphId: string; + /** `cascadedParts` of every node delete's `onOperationEnd`, by node id. */ + cascades: Map; +}>; + +type Step = Readonly<{ + label: string; + /** Empty: the write must succeed. Otherwise any of these classes is accepted. */ + refusals: readonly ErrorClass[]; + perform: (surface: Surface, real: Real) => Promise; + apply?: (model: Model) => void; + /** Further checks after a successful top-level step, against the updated model. */ + verify?: (real: Real, model: Model) => Promise; +}>; + +/** Builds the next step from the model as it stands, or `undefined` when nothing applies. */ +type StepFactory = (model: Model) => Step | undefined; + +function surfaceOf(target: object): Surface { + return target as unknown as Surface; +} + +function partOfOption( + model: Model, + wholeId: string, + wholeKind: NodeKind, + via: EdgeKind | undefined, +): object { + return { + whole: { kind: wholeKind, id: wholeId }, + ...(via === undefined ? {} : { via }), + }; +} + +function createNodeStep( + kind: NodeKind, + salt: string, + attachment: + | Readonly<{ + wholePick: number; + via: EdgeKind | "omit"; + /** A stated whole, or the most recently issued id of the whole kind. */ + whole?: Readonly<{ id: string }> | "latest"; + }> + | undefined, +): StepFactory { + return (model) => { + const id = issueId(model, kind, salt); + const node = (surface: Surface) => surface.nodes[kind]; + if (attachment === undefined || kind === "CpWhole") { + return { + label: `create ${kind}/${id} bare`, + refusals: + REQUIRED_PART_KINDS.has(kind) ? [CompositionExistenceError] : [], + perform: async (surface) => { + await node(surface).create({}, { id }); + }, + apply: (next) => { + next.nodes.set(id, { kind, live: true }); + }, + }; + } + const partKind = kind; + const viaKind = + attachment.via === "omit" ? + requireDefined(edgeKindsOfPart(partKind)[0]) + : attachment.via; + const pair = PAIRS[viaKind]; + const wholeId = + attachment.whole === "latest" ? + (requireDefined(model.issuedIds.get(pair.wholeKind)).at( + pair.wholeKind === kind ? -2 : -1, + ) ?? `${pair.wholeKind}-ghost`) + : (attachment.whole?.id ?? + requireDefined( + pickNodeId(model, pair.wholeKind, attachment.wholePick), + )); + const stateVia = attachment.via === "omit" ? undefined : attachment.via; + const refusals: ErrorClass[] = []; + if (stateVia === undefined && edgeKindsOfPart(partKind).length > 1) { + refusals.push(ConfigurationError); + } + if (wholeId === id) refusals.push(EdgeAcyclicityError); + else if (!isLive(model, wholeId)) refusals.push(EndpointNotFoundError); + return { + label: `create ${kind}/${id} partOf ${wholeId} via ${attachment.via}`, + refusals, + perform: async (surface) => { + await node(surface).create( + {}, + { + id, + partOf: partOfOption(model, wholeId, pair.wholeKind, stateVia), + }, + ); + }, + apply: (next) => { + next.nodes.set(id, { kind, live: true }); + addEdge(next, { + kind: viaKind, + partId: id, + wholeId, + validFrom: next.clock, + validTo: undefined, + }); + }, + }; + }; +} + +function createEdgeStep( + edgeKind: EdgeKind, + partPick: number, + wholePick: number, +): StepFactory { + return (model) => { + const pair = PAIRS[edgeKind]; + const partId = requireDefined(pickNodeId(model, pair.partKind, partPick)); + const wholeId = requireDefined( + pickNodeId(model, pair.wholeKind, wholePick), + ); + const refusals: ErrorClass[] = []; + if (!isLive(model, partId) || !isLive(model, wholeId)) { + refusals.push(EndpointNotFoundError); + } + if (attachmentOf(model, partId) !== undefined) { + refusals.push(CompositionError); + } + if (isSelfOrAncestor(model, partId, wholeId)) { + refusals.push(EdgeAcyclicityError); + } + const part = { kind: pair.partKind, id: partId }; + const whole = { kind: pair.wholeKind, id: wholeId }; + return { + label: `edge ${edgeKind} create ${partId} -> ${wholeId}`, + refusals, + perform: async (surface) => { + const [from, to] = + pair.partSide === "from" ? [part, whole] : [whole, part]; + await surface.edges[edgeKind].create(from, to, {}); + }, + apply: (next) => { + addEdge(next, { + kind: edgeKind, + partId, + wholeId, + validFrom: next.clock, + validTo: undefined, + }); + }, + }; + }; +} + +type AtMode = "omit" | "now" | "past" | "beforeIncumbent" | "future"; +type MoveSpec = Readonly<{ + partPick: number; + wholePick: number; + via: EdgeKind | "omit"; + at: AtMode; +}>; +type ResolvedMove = Readonly<{ + partId: string; + via: EdgeKind; + statedVia: EdgeKind | undefined; + wholeId: string; + at: number | undefined; + noop: boolean; + /** The refusals this item earns on its own, before the batch's cycle check. */ + refusals: readonly ErrorClass[]; + options: object; +}>; + +function resolveMove( + model: Model, + kind: PartKind, + spec: MoveSpec, +): ResolvedMove { + const kinds = edgeKindsOfPart(kind); + const statedVia = spec.via === "omit" ? undefined : spec.via; + const via = statedVia ?? requireDefined(kinds[0]); + const pair = PAIRS[via]; + const partId = requireDefined(pickNodeId(model, kind, spec.partPick)); + const wholeId = requireDefined( + pickNodeId(model, pair.wholeKind, spec.wholePick), + ); + const incumbent = attachmentOf(model, partId); + const at = ((): number | undefined => { + switch (spec.at) { + case "omit": { + return undefined; + } + case "now": { + return model.clock; + } + case "past": { + return model.clock - 20_000; + } + case "future": { + return model.clock + 20_000; + } + case "beforeIncumbent": { + return (incumbent?.edge.validFrom ?? model.clock) - 10_000; + } + } + })(); + const noop = + incumbent?.edge.wholeId === wholeId && incumbent.edge.kind === via; + const refusals: ErrorClass[] = []; + if (statedVia === undefined && kinds.length > 1) { + refusals.push(ConfigurationError); + } + if (!isLive(model, partId)) refusals.push(NodeNotFoundError); + if (!isLive(model, wholeId)) refusals.push(EndpointNotFoundError); + if (at !== undefined && at > model.clock) refusals.push(CompositionError); + // A no-op move cannot honor a stated instant other than the held edge's + // own start, so it refuses it rather than dropping it. + if (noop && at !== undefined && at !== incumbent.edge.validFrom) { + refusals.push(ValidationError); + } + if ( + !noop && + at !== undefined && + incumbent !== undefined && + PAIRS[incumbent.edge.kind].population === "oneActive" && + at < incumbent.edge.validFrom + ) { + refusals.push(CompositionError); + } + return { + partId, + via, + statedVia, + wholeId, + at, + noop, + refusals, + options: { + ...partOfOption(model, wholeId, pair.wholeKind, statedVia), + ...(at === undefined ? {} : { at: iso(at) }), + }, + }; +} + +function applyMove(model: Model, move: ResolvedMove): void { + if (move.noop) return; + const incumbent = attachmentOf(model, move.partId); + const oneActiveIncumbent = + incumbent !== undefined && + PAIRS[incumbent.edge.kind].population === "oneActive"; + const instant = + move.at ?? + (oneActiveIncumbent ? + Math.max(model.clock, requireDefined(incumbent).edge.validFrom) + : model.clock); + if (incumbent !== undefined) { + if (oneActiveIncumbent) incumbent.edge.validTo = instant; + else incumbent.edge.deleted = true; + } + addEdge(model, { + kind: move.via, + partId: move.partId, + wholeId: move.wholeId, + validFrom: instant, + validTo: undefined, + }); +} + +function hasCycleAbove(model: Model, partId: string): boolean { + const seen = new Set(); + let current: string | undefined = partId; + while (current !== undefined) { + if (seen.has(current)) return true; + seen.add(current); + current = attachmentOf(model, current)?.edge.wholeId; + } + return false; +} + +function reparentStep(kind: PartKind, spec: MoveSpec): StepFactory { + return (model) => { + const move = resolveMove(model, kind, spec); + const refusals = [...move.refusals]; + if ( + !move.noop && + (move.partId === move.wholeId || + isSelfOrAncestor(model, move.partId, move.wholeId)) + ) { + refusals.push(EdgeAcyclicityError); + } + return { + label: `reparent ${kind}/${move.partId} -> ${move.wholeId} via ${spec.via} at ${spec.at}`, + refusals, + perform: async (surface) => { + await surface.nodes[kind].reparent(move.partId, move.options); + }, + apply: (next) => { + applyMove(next, move); + }, + }; + }; +} + +function bulkReparentStep( + kind: PartKind, + specs: readonly MoveSpec[], +): StepFactory { + return (model) => { + const moves = specs.map((spec) => resolveMove(model, kind, spec)); + const refusals = moves.flatMap((move) => move.refusals); + const partIds = moves.map((move) => move.partId); + if (new Set(partIds).size < partIds.length) refusals.push(ValidationError); + if (refusals.length === 0) { + const projected = structuredClone(model); + for (const move of moves) applyMove(projected, move); + if (moves.some((move) => hasCycleAbove(projected, move.partId))) { + refusals.push(EdgeAcyclicityError); + } + } + return { + label: `bulkReparent ${kind} [${moves.map((move) => `${move.partId}->${move.wholeId}`).join(", ")}]`, + refusals, + perform: async (surface) => { + await surface.nodes[kind].bulkReparent( + moves.map((move) => ({ id: move.partId, options: move.options })), + ); + }, + apply: (next) => { + for (const move of moves) applyMove(next, move); + }, + }; + }; +} + +type NodeDeleteForm = "delete" | "hardDelete"; + +function nodeDeleteStep( + kind: NodeKind, + idPick: number, + form: NodeDeleteForm, +): StepFactory { + return (model) => { + const id = requireDefined(pickNodeId(model, kind, idPick)); + const expectedPlan = deletePlan(model, id); + let preview: NodeDeletePlan | undefined; + return { + label: `${form} ${kind}/${id}`, + refusals: [], + perform: async (surface, real) => { + const collection = surface.nodes[kind]; + preview = await collection.planDelete(id); + real.cascades.delete(id); + await (form === "delete" ? + collection.delete(id) + : collection.hardDelete(id)); + }, + apply: (next) => { + if (form === "delete") applySoftDelete(next, id); + else applyHardDelete(next, id); + }, + // `planDelete` previews the soft delete; a hard delete cascades + // through the same closure. + verify: async (real) => { + expect({ id, preview: previewOf(preview) }).toEqual({ + id, + preview: expectedPlan, + }); + if (expectedPlan === undefined) return; + expect({ id, cascadedParts: real.cascades.get(id) }).toEqual({ + id, + cascadedParts: expectedPlan.cascadedParts, + }); + await Promise.resolve(); + }, + }; + }; +} + +function previewOf(plan: NodeDeletePlan | undefined): ModelPlan | undefined { + if (plan === undefined) return undefined; + return { + cascadedParts: plan.cascadedParts.map((part) => ({ + kind: part.kind, + id: part.id, + whole: { kind: part.whole.kind, id: part.whole.id }, + viaEdgeKind: part.viaEdgeKind, + })), + removedEdgeIds: [...plan.removedEdgeIds], + }; +} + +function nodeBulkDeleteStep( + kind: NodeKind, + picks: readonly number[], +): StepFactory { + return (model) => { + const ids = [ + ...new Set( + picks.map((index) => requireDefined(pickNodeId(model, kind, index))), + ), + ]; + return { + label: `bulkDelete ${kind} [${ids.join(", ")}]`, + refusals: [], + perform: async (surface) => { + await surface.nodes[kind].bulkDelete(ids); + }, + apply: (next) => { + for (const id of ids) applySoftDelete(next, id); + }, + }; + }; +} + +function detachRefusal(model: Model, edge: ModelEdge): readonly ErrorClass[] { + const pair = PAIRS[edge.kind]; + return ( + edgeCounts(edge) && + REQUIRED_PART_KINDS.has(pair.partKind) && + isLive(model, edge.partId) + ) ? + [CompositionExistenceError] + : []; +} + +type EdgeWriteForm = "delete" | "hardDelete"; + +function edgeDeleteStep( + edgeKind: EdgeKind, + edgePick: number, + form: EdgeWriteForm, +): StepFactory { + return (model) => { + const id = pick(storedEdgeIds(model, edgeKind), edgePick); + if (id === undefined) return; + const edge = requireDefined(model.edges.get(id)); + return { + label: `edge ${edgeKind} ${form} ${id} (${edge.partId} -> ${edge.wholeId})`, + refusals: detachRefusal(model, edge), + perform: async (surface) => { + await (form === "delete" ? + surface.edges[edgeKind].delete(id) + : surface.edges[edgeKind].hardDelete(id)); + }, + apply: (next) => { + if (form === "delete") + requireDefined(next.edges.get(id)).deleted = true; + else next.edges.delete(id); + }, + }; + }; +} + +function edgeBulkDeleteStep( + edgeKind: EdgeKind, + picks: readonly number[], +): StepFactory { + return (model) => { + const candidates = storedEdgeIds(model, edgeKind); + if (candidates.length === 0) return; + const ids = [ + ...new Set(picks.map((index) => requireDefined(pick(candidates, index)))), + ]; + const refusals = ids.flatMap((id) => + detachRefusal(model, requireDefined(model.edges.get(id))), + ); + return { + label: `edge ${edgeKind} bulkDelete [${ids.join(", ")}]`, + refusals, + perform: async (surface) => { + await surface.edges[edgeKind].bulkDelete(ids); + }, + apply: (next) => { + for (const id of ids) requireDefined(next.edges.get(id)).deleted = true; + }, + }; + }; +} + +type EndMode = "now" | "past" | "beforeStart" | "future"; + +function edgeEndStep( + edgeKind: EdgeKind, + edgePick: number, + mode: EndMode, + partId?: () => string | undefined, +): StepFactory { + return (model) => { + const candidates = storedEdgeIds(model, edgeKind).filter( + (id) => + partId === undefined || + requireDefined(model.edges.get(id)).partId === partId(), + ); + const id = pick(candidates, edgePick); + if (id === undefined) return; + const edge = requireDefined(model.edges.get(id)); + const validTo = ((): number => { + switch (mode) { + case "now": { + return model.clock; + } + case "past": { + return model.clock - 10_000; + } + case "beforeStart": { + return edge.validFrom - 10_000; + } + case "future": { + return model.clock + 10_000; + } + } + })(); + const refusals: ErrorClass[] = []; + if (validTo > model.clock) refusals.push(CompositionError); + if (validTo < edge.validFrom) refusals.push(ValidationError); + if (edge.validTo === undefined) + refusals.push(...detachRefusal(model, edge)); + return { + label: `edge ${edgeKind} end ${id} at ${mode} (${edge.partId} -> ${edge.wholeId}, open=${edge.validTo === undefined})`, + refusals, + perform: async (surface) => { + await surface.edges[edgeKind].update(id, {}, { validTo: iso(validTo) }); + }, + apply: (next) => { + requireDefined(next.edges.get(id)).validTo = validTo; + }, + }; + }; +} + +function restoreStep( + kind: NodeKind, + idPick: number, + stated?: () => string | undefined, +): StepFactory { + return (model) => { + const id = + stated === undefined ? + requireDefined(pickNodeId(model, kind, idPick)) + : stated(); + if (id === undefined) return; + const node = model.nodes.get(id); + const attachment = attachmentOf(model, id); + const holdsLiveWhole = + node !== undefined && + attachment !== undefined && + isLive(model, attachment.edge.wholeId); + const refusals = + node?.live !== true && REQUIRED_PART_KINDS.has(kind) && !holdsLiveWhole ? + [CompositionExistenceError] + : []; + return { + label: `upsertById ${kind}/${id} (${ + node === undefined ? "absent" + : node.live ? "live" + : "tombstoned" + })`, + refusals, + perform: async (surface) => { + await surface.nodes[kind].upsertById(id, {}); + }, + apply: (next) => { + next.nodes.set(id, { kind, live: true }); + if (!requireDefined(next.issuedIds.get(kind)).includes(id)) { + requireDefined(next.issuedIds.get(kind)).push(id); + } + }, + }; + }; +} + +/** + * Retires a leaf through the RAW backend member, which leaves its composition + * edge live: the dirty shape a cascade must walk through without reporting + * the dead part, and the one route to an ended `one` window on a part that is + * live again later. + */ +function rawRetireStep( + kind: (typeof RAW_RETIRABLE_KINDS)[number], + idPick: number, + retired?: (id: string | undefined) => void, +): StepFactory { + return (model) => { + const id = pick( + requireDefined(model.issuedIds.get(kind)).filter((candidate) => + isLive(model, candidate), + ), + idPick, + ); + retired?.(id); + if (id === undefined) return; + return { + label: `raw retire ${kind}/${id}`, + refusals: [], + perform: async (_surface, real) => { + await real.backend.deleteNode({ graphId: real.graphId, kind, id }); + }, + apply: (next) => { + requireDefined(next.nodes.get(id)).live = false; + }, + }; + }; +} + +type Interleave = "none" | "attach" | "unrelated"; + +/** `planDelete`, then a write that may change the plan, then `delete({ expect })`. */ +function guardedDeleteSteps( + kind: NodeKind, + idPick: number, + interleave: Interleave, + salt: string, +): readonly StepFactory[] { + let preview: NodeDeletePlan | undefined; + let planned: ModelPlan | undefined; + let id = `${kind}-ghost`; + const interleaved: StepFactory = (model) => { + id = requireDefined(pickNodeId(model, kind, idPick)); + planned = deletePlan(model, id); + if (interleave === "none" || planned === undefined) { + return { + label: `planDelete ${kind}/${id}`, + refusals: [], + perform: async (surface) => { + preview = await surface.nodes[kind].planDelete(id); + }, + verify: async () => { + expect({ id, preview: previewOf(preview) }).toEqual({ + id, + preview: planned, + }); + await Promise.resolve(); + }, + }; + } + const attachUnder: Readonly< + Record + > = { + CpWhole: ["CpMid", "cpMidOf"], + CpMid: ["CpTag", "cpTagOf"], + CpFolder: ["CpFolder", "cpFolderOf"], + CpLeaf: undefined, + CpTag: undefined, + }; + const target = interleave === "attach" ? attachUnder[kind] : undefined; + const create = + target === undefined ? + createNodeStep("CpWhole", salt, undefined)(model) + : createNodeStep(target[0], salt, { + wholePick: 0, + via: target[1], + whole: { id }, + })(model); + const write = requireDefined(create); + return { + label: `planDelete ${kind}/${id}, then ${write.label}`, + refusals: [], + perform: async (surface, real) => { + preview = await surface.nodes[kind].planDelete(id); + await write.perform(surface, real); + }, + apply: (next) => { + write.apply?.(next); + }, + verify: async () => { + expect({ id, preview: previewOf(preview) }).toEqual({ + id, + preview: planned, + }); + await Promise.resolve(); + }, + }; + }; + const guarded: StepFactory = (model) => { + if (planned === undefined) return; + const before = planned; + const now = requireDefined(deletePlan(model, id)); + return { + label: `delete ${kind}/${id} expecting the earlier plan`, + refusals: plansMatch(before, now) ? [] : [NodeDeletePlanMismatchError], + perform: async (surface) => { + await surface.nodes[kind].delete(id, { + expect: requireDefined(preview), + }); + }, + apply: (next) => { + applySoftDelete(next, id); + }, + }; + }; + return [interleaved, guarded]; +} + +// ============================================================ +// Observing the store +// ============================================================ + +type RowState = Readonly<{ + nodes: readonly string[]; + edges: readonly string[]; +}>; + +function instantOf(value: string | undefined): number | undefined { + return value === undefined ? undefined : Date.parse(value); +} + +async function readRows(real: Real): Promise< + Readonly<{ + nodes: readonly Readonly<{ id: string; kind: string; live: boolean }>[]; + edges: readonly Readonly<{ + id: string; + kind: EdgeKind; + fromId: string; + toId: string; + validFrom: number | undefined; + validTo: number | undefined; + deleted: boolean; + }>[]; + raw: unknown; + }> +> { + const nodeRows = await Promise.all( + NODE_KINDS.map((kind) => + real.backend.findNodesByKind({ + graphId: real.graphId, + kind, + excludeDeleted: false, + temporalMode: "includeTombstones", + }), + ), + ); + const edgeRows = await Promise.all( + EDGE_KINDS.map((kind) => + real.backend.findEdgesByKind({ + graphId: real.graphId, + kind, + excludeDeleted: false, + temporalMode: "includeTombstones", + }), + ), + ); + const claims = await real.backend.execute( + asCompiledRowsSql(sql` + SELECT axis, key, edge_id, updated_at + FROM typegraph_edge_claims + WHERE graph_id = ${real.graphId} + ORDER BY axis, key + `), + ); + const nodes = nodeRows + .flat() + .map((row) => ({ + id: row.id, + kind: row.kind, + live: row.deleted_at === undefined, + })) + .toSorted((left, right) => compareTuples([left.id], [right.id])); + const edges = edgeRows + .flat() + .map((row) => ({ + id: row.id, + kind: row.kind as EdgeKind, + fromId: row.from_id, + toId: row.to_id, + validFrom: instantOf(row.valid_from), + validTo: instantOf(row.valid_to), + deleted: row.deleted_at !== undefined, + })) + .toSorted((left, right) => compareTuples([left.id], [right.id])); + return { + nodes, + edges, + raw: { + nodes: nodeRows + .flat() + .toSorted((left, right) => compareTuples([left.id], [right.id])), + edges: edgeRows + .flat() + .toSorted((left, right) => compareTuples([left.id], [right.id])), + claims, + }, + }; +} + +function describeModel(model: Model): RowState { + return { + nodes: [...model.nodes] + .map(([id, node]) => `${id}:${node.kind}:${node.live ? "live" : "dead"}`) + .toSorted(), + edges: [...model.edges] + .map(([id, edge]) => describeEdge(id, edge)) + .toSorted(), + }; +} + +function describeEdge( + id: string, + edge: Readonly<{ + kind: EdgeKind; + partId: string; + wholeId: string; + validFrom: number | undefined; + validTo: number | undefined; + deleted: boolean; + }>, +): string { + return `${id}:${edge.kind}:${edge.partId}<${edge.wholeId}:from=${edge.validFrom}:to=${edge.validTo ?? "open"}:${edge.deleted ? "deleted" : "live"}`; +} + +/** Gives every pending model edge the id the store assigned the row it wrote. */ +function reconcileEdgeIds( + model: Model, + rows: Awaited>, +): void { + const unclaimed = rows.edges.filter((row) => !model.edges.has(row.id)); + for (const [pendingId, edge] of model.edges) { + if (!pendingId.startsWith(PENDING_PREFIX)) continue; + const pair = PAIRS[edge.kind]; + const index = unclaimed.findIndex((row) => { + const partId = pair.partSide === "from" ? row.fromId : row.toId; + const wholeId = pair.partSide === "from" ? row.toId : row.fromId; + return ( + row.kind === edge.kind && + partId === edge.partId && + wholeId === edge.wholeId && + row.deleted === edge.deleted && + row.validTo === edge.validTo + ); + }); + if (index === -1) continue; + const [row] = unclaimed.splice(index, 1); + model.edges.delete(pendingId); + model.edges.set(requireDefined(row).id, edge); + } +} + +function describeRows(rows: Awaited>): RowState { + return { + nodes: rows.nodes + .map((node) => `${node.id}:${node.kind}:${node.live ? "live" : "dead"}`) + .toSorted(), + edges: rows.edges + .map((row) => { + const pair = PAIRS[row.kind]; + return describeEdge(row.id, { + kind: row.kind, + partId: pair.partSide === "from" ? row.fromId : row.toId, + wholeId: pair.partSide === "from" ? row.toId : row.fromId, + validFrom: row.validFrom, + validTo: row.validTo, + deleted: row.deleted, + }); + }) + .toSorted(), + }; +} + +/** Invariants read from the stored rows alone, never from the model. */ +function assertRowInvariants(rows: Awaited>): void { + const liveNodes = new Set( + rows.nodes.filter((node) => node.live).map((node) => node.id), + ); + const attachments = new Map(); + for (const row of rows.edges) { + const pair = PAIRS[row.kind]; + if (row.deleted) continue; + if (pair.population === "oneActive" && row.validTo !== undefined) continue; + const partId = pair.partSide === "from" ? row.fromId : row.toId; + const wholeId = pair.partSide === "from" ? row.toId : row.fromId; + attachments.set(partId, [...(attachments.get(partId) ?? []), wholeId]); + } + const shared = [...attachments].filter(([, wholes]) => wholes.length > 1); + expect({ partsWithSeveralAttachingEdges: shared }).toEqual({ + partsWithSeveralAttachingEdges: [], + }); + const orphans = rows.nodes + .filter( + (node) => node.live && REQUIRED_PART_KINDS.has(node.kind as NodeKind), + ) + .filter((node) => { + const [wholeId] = attachments.get(node.id) ?? []; + return wholeId === undefined || !liveNodes.has(wholeId); + }) + .map((node) => node.id); + expect({ liveRequiredPartsWithoutLiveWhole: orphans }).toEqual({ + liveRequiredPartsWithoutLiveWhole: [], + }); +} + +function expectedParts(model: Model, rootId: string): readonly string[] { + const found: string[] = []; + let frontier = [rootId]; + while (frontier.length > 0) { + frontier = frontier.flatMap((wholeId) => + countingPartEdges(model, wholeId) + .map(({ edge }) => edge.partId) + .filter((partId) => isLive(model, partId)), + ); + found.push(...frontier); + } + return found; +} + +function expectedWholes(model: Model, rootId: string): readonly string[] { + const found: string[] = []; + let current = attachmentOf(model, rootId)?.edge.wholeId; + while (current !== undefined && isLive(model, current)) { + found.push(current); + current = attachmentOf(model, current)?.edge.wholeId; + } + return found; +} + +function navigationPairs( + rows: readonly Readonly<{ root: string; node: string }>[], +): readonly string[] { + return rows.map((row) => `${row.root}>${row.node}`).toSorted(); +} + +async function assertNavigationMatches( + real: Real, + model: Model, +): Promise { + const liveOf = (kinds: readonly NodeKind[]) => + [...model.nodes] + .filter(([, node]) => node.live && kinds.includes(node.kind)) + .map(([id]) => id); + const query = () => real.store.query().temporal("includeEnded"); + const actualParts = [ + ...(await query() + .from("CpWhole", "root") + .parts("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ...(await query() + .from("CpMid", "root") + .parts("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ...(await query() + .from("CpFolder", "root") + .parts("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ]; + const actualWholes = [ + ...(await query() + .from("CpMid", "root") + .wholes("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ...(await query() + .from("CpLeaf", "root") + .wholes("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ...(await query() + .from("CpTag", "root") + .wholes("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ...(await query() + .from("CpFolder", "root") + .wholes("node") + .select((ctx) => ({ root: ctx.root.id, node: ctx.node.id })) + .execute()), + ]; + const expectedPartRows = liveOf(["CpWhole", "CpMid", "CpFolder"]).flatMap( + (root) => expectedParts(model, root).map((node) => ({ root, node })), + ); + const expectedWholeRows = liveOf([ + "CpMid", + "CpLeaf", + "CpTag", + "CpFolder", + ]).flatMap((root) => + expectedWholes(model, root).map((node) => ({ root, node })), + ); + expect({ + parts: navigationPairs(actualParts), + wholes: navigationPairs(actualWholes), + }).toEqual({ + parts: navigationPairs(expectedPartRows), + wholes: navigationPairs(expectedWholeRows), + }); +} + +/** + * The audit findings the model expects: none, except the composition rows + * whose window starts or ends after the clock — rows written before the clock + * stepped backwards, which the audit names however they were written. + */ +function expectedFenceViolations(model: Model): readonly unknown[] { + return EDGE_KINDS.toSorted() + .map((edgeKind) => ({ + family: "compositionFutureWindow", + edgeKind, + edgeIds: [...model.edges] + .filter( + ([, edge]) => + edge.kind === edgeKind && + !edge.deleted && + (edge.validFrom > model.clock || + (edge.validTo ?? model.clock) > model.clock), + ) + .map(([id]) => id) + .toSorted(), + })) + .filter((violation) => violation.edgeIds.length > 0); +} + +function errorSummary(error: unknown): string { + if (error === undefined) return "success"; + if (error instanceof Error) { + const code = (error as Error & { code?: unknown }).code; + return `${error.name}${typeof code === "string" ? ` ${code}` : ""}: ${error.message}`; + } + return `non-Error rejection: ${typeof error}`; +} + +/** The error `operation` rejects with, or `undefined` when it resolves. */ +async function outcomeOf(operation: Promise): Promise { + try { + await operation; + } catch (error) { + return error; + } + return undefined; +} + +function assertVerdict(step: Step, error: unknown): void { + if (step.refusals.length === 0) { + expect({ step: step.label, outcome: errorSummary(error) }).toEqual({ + step: step.label, + outcome: "success", + }); + return; + } + const accepted = step.refusals.map((refusal) => refusal.name); + expect({ + step: step.label, + accepted, + refusedAsPredicted: + error !== undefined && + step.refusals.some((refusal) => error instanceof refusal), + outcome: errorSummary(error), + }).toMatchObject({ step: step.label, accepted, refusedAsPredicted: true }); +} + +async function assertNothingWritten( + real: Real, + step: Step, + before: Awaited>, +): Promise { + const after = await readRows(real); + expect({ refused: step.label, rows: after.raw }).toEqual({ + refused: step.label, + rows: before.raw, + }); +} + +async function assertStoreMatchesModel( + real: Real, + model: Model, +): Promise { + const rows = await readRows(real); + reconcileEdgeIds(model, rows); + expect(describeRows(rows)).toEqual(describeModel(model)); + assertRowInvariants(rows); + expect(await real.store.verifyConstraintFences()).toEqual( + expectedFenceViolations(model), + ); + await assertNavigationMatches(real, model); +} + +// ============================================================ +// Commands +// ============================================================ + +class StepsCommand implements fc.AsyncCommand { + constructor( + readonly name: string, + readonly tick: number, + readonly factories: readonly StepFactory[], + ) {} + + check(): boolean { + return true; + } + + async run(model: Model, real: Real): Promise { + advanceClock(model, this.tick); + const surface = surfaceOf(real.store); + for (const factory of this.factories) { + const step = factory(model); + if (step === undefined) continue; + const before = + step.refusals.length === 0 ? undefined : await readRows(real); + const error = await outcomeOf(step.perform(surface, real)); + assertVerdict(step, error); + if (error === undefined) { + step.apply?.(model); + await assertStoreMatchesModel(real, model); + await step.verify?.(real, model); + } else { + await assertNothingWritten(real, step, requireDefined(before)); + } + } + } + + toString(): string { + return `${this.name} [tick ${this.tick}]`; + } +} + +/** Several steps in one `store.transaction`, each refusal caught inside it. */ +class TransactionCommand implements fc.AsyncCommand { + constructor( + readonly name: string, + readonly tick: number, + readonly factories: readonly StepFactory[], + ) {} + + check(): boolean { + return true; + } + + async run(model: Model, real: Real): Promise { + advanceClock(model, this.tick); + await real.store.transaction(async (tx) => { + const surface = surfaceOf(tx); + for (const factory of this.factories) { + const step = factory(model); + if (step === undefined) continue; + const before = + step.refusals.length === 0 ? undefined : await rowsThrough(surface); + const error = await outcomeOf(step.perform(surface, real)); + assertVerdict(step, error); + if (error === undefined) step.apply?.(model); + else await assertNothingWrittenThrough(surface, step, before); + } + }); + await assertStoreMatchesModel(real, model); + } + + toString(): string { + return `${this.name} [tick ${this.tick}]`; + } +} + +/** + * Every node and edge row, read through `surface`: inside a transaction the + * raw backend is not reachable, so the collections' widest read stands in for + * the row snapshot. Claim rows are left to the constraint-fence audit that + * runs once the transaction commits. + */ +async function rowsThrough(surface: Surface): Promise { + const tombstones = { temporalMode: "includeTombstones" } as const; + const nodes = await Promise.all( + NODE_KINDS.map((kind) => surface.nodes[kind].find({}, tombstones)), + ); + const edges = await Promise.all( + EDGE_KINDS.map((kind) => surface.edges[kind].find({}, tombstones)), + ); + return { nodes, edges }; +} + +async function assertNothingWrittenThrough( + surface: Surface, + step: Step, + before: unknown, +): Promise { + expect({ refused: step.label, rows: await rowsThrough(surface) }).toEqual({ + refused: step.label, + rows: before, + }); +} + +function advanceClock(model: Model, tick: number): void { + model.clock += tick; + vi.setSystemTime(model.clock); +} + +const tickArb = fc.oneof( + { weight: 5, arbitrary: fc.constant(60_000) }, + { weight: 1, arbitrary: fc.constant(-30_000) }, +); +const saltArb = fc.constantFrom("a", "q", "Z"); +const pickArb = fc.nat({ max: 999 }); +const viaOfMid = fc.constantFrom( + "cpMidOf", + "cpMidOf", + "cpWholeHasMid", + "cpWholeHasMid", + "omit", +); + +function viaArbOf(kind: PartKind): fc.Arbitrary { + return kind === "CpMid" ? viaOfMid : fc.constant("omit"); +} + +const partKindArb = fc.constantFrom(...PART_KINDS); +const nodeKindArb = fc.constantFrom(...NODE_KINDS); +const edgeKindArb = fc.constantFrom(...EDGE_KINDS); +const atArb = fc.constantFrom( + "omit", + "omit", + "now", + "past", + "beforeIncumbent", + "future", +); + +const endModeArb = fc.constantFrom( + "now", + "past", + "beforeStart", + "future", +); + +const createArb = fc + .tuple(nodeKindArb, saltArb, fc.option(pickArb, { freq: 4 })) + .chain(([kind, salt, wholePick]) => + (kind === "CpWhole" ? fc.constant("omit" as const) : viaArbOf(kind)).map( + (via) => ({ + name: `create ${kind}`, + factories: [ + createNodeStep( + kind, + salt, + wholePick === null ? undefined : { wholePick, via }, + ), + ], + }), + ), + ); + +/** + * A whole with a mid under it, a leaf and a tag under that mid, and a folder + * inside a folder: depth the single creates rarely reach on their own. + */ +const growArb = fc + .tuple( + saltArb, + viaOfMid.filter((via) => via !== "omit"), + ) + .map(([salt, via]) => ({ + name: "grow", + factories: [ + createNodeStep("CpWhole", salt, undefined), + createNodeStep("CpMid", salt, { wholePick: 0, via, whole: "latest" }), + createNodeStep("CpLeaf", salt, { + wholePick: 0, + via: "omit", + whole: "latest", + }), + createNodeStep("CpTag", salt, { + wholePick: 0, + via: "omit", + whole: "latest", + }), + createNodeStep("CpFolder", salt, undefined), + createNodeStep("CpFolder", salt, { + wholePick: 0, + via: "omit", + whole: "latest", + }), + ], + })); + +const createEdgeArb = fc + .tuple(edgeKindArb, pickArb, pickArb) + .map(([edgeKind, partPick, wholePick]) => ({ + name: `edge ${edgeKind} create`, + factories: [createEdgeStep(edgeKind, partPick, wholePick)], + })); + +function moveSpecArb(kind: PartKind): fc.Arbitrary { + return fc.record({ + partPick: pickArb, + wholePick: pickArb, + via: viaArbOf(kind), + at: atArb, + }); +} + +const reparentArb = partKindArb.chain((kind) => + fc.oneof( + { + weight: 3, + arbitrary: moveSpecArb(kind).map((spec) => ({ + name: `reparent ${kind}`, + factories: [reparentStep(kind, spec)], + })), + }, + { + weight: 1, + arbitrary: fc + .array(moveSpecArb(kind), { minLength: 1, maxLength: 3 }) + .map((specs) => ({ + name: `bulkReparent ${kind}`, + factories: [bulkReparentStep(kind, specs)], + })), + }, + ), +); + +const nodeDeleteArb = fc.oneof( + fc + .tuple( + nodeKindArb, + pickArb, + fc.constantFrom("delete", "hardDelete"), + ) + .map(([kind, idPick, form]) => ({ + name: `${form} ${kind}`, + factories: [nodeDeleteStep(kind, idPick, form)], + })), + fc + .tuple(nodeKindArb, fc.array(pickArb, { minLength: 1, maxLength: 3 })) + .map(([kind, picks]) => ({ + name: `bulkDelete ${kind}`, + factories: [nodeBulkDeleteStep(kind, picks)], + })), +); + +const edgeWriteArb = fc.oneof( + { + weight: 1, + arbitrary: fc + .tuple( + edgeKindArb, + pickArb, + fc.constantFrom("delete", "hardDelete"), + ) + .map(([edgeKind, edgePick, form]) => ({ + name: `edge ${edgeKind} ${form}`, + factories: [edgeDeleteStep(edgeKind, edgePick, form)], + })), + }, + { + weight: 2, + arbitrary: fc + .tuple(edgeKindArb, fc.array(pickArb, { minLength: 1, maxLength: 3 })) + .map(([edgeKind, picks]) => ({ + name: `edge ${edgeKind} bulkDelete`, + factories: [edgeBulkDeleteStep(edgeKind, picks)], + })), + }, + { + weight: 2, + arbitrary: fc + .tuple(edgeKindArb, pickArb, endModeArb) + .map(([edgeKind, edgePick, mode]) => ({ + name: `edge ${edgeKind} end`, + factories: [edgeEndStep(edgeKind, edgePick, mode)], + })), + }, +); + +const restoreArb = fc.tuple(nodeKindArb, pickArb).map(([kind, idPick]) => ({ + name: `upsertById ${kind}`, + factories: [restoreStep(kind, idPick)], +})); + +const rawRetireArb = fc + .tuple(fc.constantFrom(...RAW_RETIRABLE_KINDS), pickArb) + .map(([kind, idPick]) => ({ + name: `raw retire ${kind}`, + factories: [rawRetireStep(kind, idPick)], + })); + +/** + * Retire a leaf raw, end its window while it is dead, restore it, and end the + * window again: the one route to a live part whose `one` window has already + * ended, where re-ending it is no detachment. + */ +const endedWindowDetourArb = fc + .tuple(fc.constantFrom(...RAW_RETIRABLE_KINDS), pickArb, endModeArb) + .map(([kind, idPick, reEnd]) => { + let retiredId: string | undefined; + const edgeKind = kind === "CpLeaf" ? "cpLeafOf" : "cpTagOf"; + return { + name: `ended-window detour ${kind}`, + factories: [ + rawRetireStep(kind, idPick, (id) => { + retiredId = id; + }), + edgeEndStep(edgeKind, 0, "now", () => retiredId), + restoreStep(kind, 0, () => retiredId), + edgeEndStep(edgeKind, 0, reEnd, () => retiredId), + ], + }; + }); + +const guardedDeleteArb = fc + .tuple( + nodeKindArb, + pickArb, + fc.constantFrom("none", "attach", "unrelated"), + saltArb, + ) + .map(([kind, idPick, interleave, salt]) => ({ + name: `guarded delete ${kind} (${interleave})`, + factories: guardedDeleteSteps(kind, idPick, interleave, salt), + })); + +/** A write the model always refuses, so a batch is guaranteed one caught refusal. */ +const refusalArb = fc.oneof( + saltArb.map((salt) => createNodeStep("CpLeaf", salt, undefined)), + fc.tuple(pickArb, pickArb).map(([partPick, wholePick]) => + reparentStep("CpLeaf", { + partPick, + wholePick, + via: "omit", + at: "future", + }), + ), +); + +/** Writes a `store.transaction` callback may issue (the raw backend member is not one). */ +const transactionalArb = fc.oneof( + { weight: 4, arbitrary: createArb }, + { weight: 2, arbitrary: growArb }, + { weight: 1, arbitrary: createEdgeArb }, + { weight: 2, arbitrary: reparentArb }, + { weight: 3, arbitrary: nodeDeleteArb }, + { weight: 3, arbitrary: edgeWriteArb }, + { weight: 1, arbitrary: restoreArb }, +); + +const simpleArb = fc.oneof( + { weight: 12, arbitrary: transactionalArb }, + { weight: 1, arbitrary: rawRetireArb }, + { weight: 1, arbitrary: endedWindowDetourArb }, +); + +const commandArbs = [ + ...Array.from({ length: 4 }, () => + fc + .tuple(simpleArb, tickArb) + .map( + ([command, tick]) => + new StepsCommand(command.name, tick, command.factories), + ), + ), + fc + .tuple(guardedDeleteArb, tickArb) + .map( + ([command, tick]) => + new StepsCommand(command.name, tick, command.factories), + ), + fc + .tuple(transactionalArb, refusalArb, transactionalArb, tickArb) + .map( + ([first, refusal, last, tick]) => + new TransactionCommand( + `transaction [${first.name}, refused write, ${last.name}]`, + tick, + [...first.factories, refusal, ...last.factories], + ), + ), +]; + +const MAX_COMMANDS = 30; + +// ============================================================ +// Lanes +// ============================================================ + +type Lane = Readonly<{ + name: string; + runs: number; + open: () => Promise< + Readonly<{ backend: GraphBackend; close: () => Promise }> + >; +}>; + +const LANES: readonly Lane[] = [ + { + name: "SQLite", + runs: propertyRuns({ ci: 60, local: 200 }), + open: () => + Promise.resolve({ + backend: createTestBackend(), + close: () => Promise.resolve(), + }), + }, + { + name: "PGlite", + runs: propertyRuns({ ci: 20, local: 60 }), + open: async () => { + const local = await createLocalPgliteBackend({ vector: false }); + return { backend: local.backend, close: () => local.backend.close() }; + }, + }, + { + name: "PGlite root (fused-capable)", + runs: propertyRuns({ ci: 20, local: 60 }), + open: async () => { + const local = await createLocalPgliteBackend({ vector: false }); + return { + backend: createPostgresBackend(local.db, { vector: false }), + close: () => local.backend.close(), + }; + }, + }, +]; + +let graphSerial = 0; + +describe.each(LANES)("composition model ($name)", (lane) => { + let opened: Awaited>; + + beforeAll(async () => { + opened = await lane.open(); + }); + + afterAll(async () => { + await opened.close(); + }); + + it("every command's verdict, rows, closure and navigation match the ownership model", async () => { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + await fc.assert( + fc.asyncProperty( + fc.commands(commandArbs, { maxCommands: MAX_COMMANDS, size: "max" }), + async (commands) => { + graphSerial += 1; + vi.setSystemTime(START_INSTANT); + const graph = buildGraph(`composition_model_${graphSerial}`); + const cascades = new Map< + string, + readonly CompositionCascadedPart[] | undefined + >(); + const [store] = await createStoreWithSchema(graph, opened.backend, { + hooks: { + onOperationEnd: (ctx: OperationHookContext) => { + if (ctx.entity === "node" && ctx.operation === "delete") { + cascades.set(ctx.id, ctx.cascadedParts); + } + }, + }, + }); + const real: Real = { + store, + backend: opened.backend, + graphId: graph.id, + cascades, + }; + await fc.asyncModelRun( + () => ({ model: initialModel(), real }), + commands, + ); + }, + ), + { numRuns: lane.runs }, + ); + } finally { + vi.useRealTimers(); + } + }, 600_000); +}); diff --git a/packages/typegraph/tests/property/constraint-claims.test.ts b/packages/typegraph/tests/property/constraint-claims.test.ts index 4cd5a9796..69d793832 100644 --- a/packages/typegraph/tests/property/constraint-claims.test.ts +++ b/packages/typegraph/tests/property/constraint-claims.test.ts @@ -1,24 +1,34 @@ /** - * No declared constraint is ever left violated, whatever order writes arrive in. + * The claim relations accept exactly the writes the declared constraints + * admit, and hold exactly the claims the live data owes — over random write + * sequences. * - * The claim relations fence four families — a `scope: "kind"` unique, a - * `scope: "kindWithSubClasses"` unique spanning a hierarchy, a `disjointWith` - * pair, and an edge cardinality — and each one is decided by a fold (the claim - * AXIS) plus a table of facts (the cardinality SPEC). A fold that answers - * differently for two kinds of one scope, or a spec that names the wrong - * population, does not fail loudly: it writes two claim rows that can never - * collide, and the second violating write is silently ACCEPTED. That is a - * property of the write sequence, not of any one case, so it is checked over - * random ones. + * A pure reference model of the graph (nodes, edges, their windows and + * tombstones) predicts every step's verdict BEFORE the store runs it: accepted, + * or refused with one of the typed errors the violated constraint owes. The + * store must match both ways — a valid write the store refuses is as much a + * failure as a violating write it accepts — so a claim that is never released + * (a node delete that keeps its unique key, a stale edge claim that can never + * be taken over) fails the run the first time a later valid write reuses the + * axis, rather than hiding behind an "expected refusal" that swallows it. * - * The oracle is deliberately NOT the claim vocabulary. Each step's invariant is - * re-derived from the live rows themselves — two live nodes must not share a - * scoped key, an id must not live under both kinds of a disjoint pair, a - * `cardinality: "one"` source must not have two live edges — so a mutation to - * the axis or the spec that moves BOTH the writer and a claim-derived oracle - * cannot hide behind their agreement. `verifyConstraintFences()` is asserted - * alongside as a cross-check: on data the fences kept clean it must report - * nothing. + * The model's liveness rules are written out here, not read from the claim + * vocabulary: a `cardinality: "one"` axis counts every non-deleted edge, a + * `oneActive` axis only edges with no `validTo`, the composition axis every + * non-deleted edge of either realizing kind in either orientation. Only the + * NAMES of claim rows (axis strings and encoded keys) come from the source, + * because those are an encoding, not a decision. + * + * After every step three things must agree with the model: the live data the + * store reads back, the claim rows (a released claim is gone or tombstoned; an + * edge claim with no live holder may linger, since the edge fence has no + * release path, but every axis the model says is held names its holder), and + * `verifyConstraintFences()`, which must report nothing on data the fences + * kept clean. + * + * A second property plants violating rows behind the store's back — the + * pre-upgrade shapes the audit exists to find — and asserts the audit reports + * exactly the violations the model derives from those rows. */ import fc from "fast-check"; import { describe, expect, it } from "vitest"; @@ -28,31 +38,57 @@ import { asEdgeId, asNodeId, CardinalityError, - createStore, + CompositionError, + createStoreWithSchema, defineEdge, defineGraph, defineNode, DisjointError, disjointWith, - EdgeNotFoundError, - EndpointError, - NodeNotFoundError, - RestrictedDeleteError, + hasPart, + partOf, + type Store, subClassOf, + type TransactionContext, UniquenessError, - ValidationError, } from "../../src"; -import { type GraphBackend } from "../../src/backend/types"; +import { + type ClaimEdgeCardinalityParams, + type GraphBackend, +} from "../../src/backend/types"; +import { computeUniqueKey } from "../../src/constraints"; +import { createSqlSchema } from "../../src/query/compiler/schema"; +import { sql } from "../../src/query/sql-fragment"; +import { asCompiledRowsSql } from "../../src/query/sql-intent"; +import { buildKindRegistry } from "../../src/registry"; +import { + COMPOSITION_RELATION_NAME, + DISJOINT_CONSTRAINT_NAME, + disjointnessClaimAxis, + uniquenessClaimAxis, + uniquenessProbeKinds, +} from "../../src/store/claims/axis"; +import { edgeInsertClaims } from "../../src/store/claims/composition-claims"; +import { + edgeCardinalityAxisReferences, + edgeCardinalityClaimTarget, +} from "../../src/store/claims/edge-claims"; +import { compareStrings } from "../../src/utils/compare"; +import { requireDefined } from "../../src/utils/presence"; +import { encodeTupleKey } from "../../src/utils/tuple-key"; +import { readEdgeClaimRows } from "../edge-claim-rows"; import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; -const FencedWorker = defineNode("FencedWorker", { - schema: z.object({ email: z.string() }), -}); -const FencedEmployee = defineNode("FencedEmployee", { - schema: z.object({ email: z.string() }), -}); +// ============================================================ +// Graph +// ============================================================ + +const emailSchema = z.object({ email: z.string() }); +const FencedWorker = defineNode("FencedWorker", { schema: emailSchema }); +const FencedEmployee = defineNode("FencedEmployee", { schema: emailSchema }); const FencedContractor = defineNode("FencedContractor", { - schema: z.object({ email: z.string() }), + schema: emailSchema, }); const FencedCompany = defineNode("FencedCompany", { schema: z.object({ name: z.string() }), @@ -60,24 +96,52 @@ const FencedCompany = defineNode("FencedCompany", { const FencedVendor = defineNode("FencedVendor", { schema: z.object({ code: z.string() }), }); +const FencedPart = defineNode("FencedPart", { schema: z.object({}) }); +const FencedBook = defineNode("FencedBook", { schema: z.object({}) }); +const FencedAnthology = defineNode("FencedAnthology", { + schema: z.object({}), +}); + +const fencedManages = defineEdge("fencedManages", { schema: z.object({}) }); +const fencedAudits = defineEdge("fencedAudits", { schema: z.object({}) }); +const fencedAssigned = defineEdge("fencedAssigned", { schema: z.object({}) }); +const fencedPartInBook = defineEdge("fencedPartInBook", { + schema: z.object({}), +}); +const fencedAnthologyHasPart = defineEdge("fencedAnthologyHasPart", { + schema: z.object({}), +}); -const propertyManages = defineEdge("fencedManages", { schema: z.object({}) }); +const STAFF_EMAIL_CONSTRAINT = "fenced_staff_email"; +const VENDOR_CODE_CONSTRAINT = "fenced_vendor_code"; const STAFF_EMAIL_UNIQUE = { - name: "fenced_staff_email", + name: STAFF_EMAIL_CONSTRAINT, fields: ["email"], scope: "kindWithSubClasses", collation: "binary", } as const; const VENDOR_CODE_UNIQUE = { - name: "fenced_vendor_code", + name: VENDOR_CODE_CONSTRAINT, fields: ["code"], scope: "kind", collation: "binary", } as const; -/** One graph carrying all four families the claim relations fence. */ +const EDGE_DECLARATIONS = { + fencedManages: { cardinality: "one" }, + fencedAudits: { targetCardinality: "one" }, + fencedAssigned: { cardinality: "oneActive" }, + fencedPartInBook: { cardinality: "one" }, + fencedAnthologyHasPart: { targetCardinality: "one" }, +} as const; + +/** + * Every claim family: a uniqueness scope spanning a hierarchy, a `kind`-scoped + * unique, a disjoint pair, a source `one`, a target `one`, a `oneActive`, and a + * composition realized by two edge kinds in opposite orientations. + */ const claimGraph = defineGraph({ id: "fenced_constraint_claims", nodes: { @@ -86,262 +150,1566 @@ const claimGraph = defineGraph({ FencedContractor: { type: FencedContractor, unique: [STAFF_EMAIL_UNIQUE] }, FencedCompany: { type: FencedCompany }, FencedVendor: { type: FencedVendor, unique: [VENDOR_CODE_UNIQUE] }, + FencedPart: { type: FencedPart }, + FencedBook: { type: FencedBook }, + FencedAnthology: { type: FencedAnthology }, }, edges: { fencedManages: { - type: propertyManages, + type: fencedManages, from: [FencedEmployee], to: [FencedVendor], - cardinality: "one", + ...EDGE_DECLARATIONS.fencedManages, + }, + fencedAudits: { + type: fencedAudits, + from: [FencedContractor], + to: [FencedVendor], + ...EDGE_DECLARATIONS.fencedAudits, + }, + fencedAssigned: { + type: fencedAssigned, + from: [FencedContractor], + to: [FencedCompany], + ...EDGE_DECLARATIONS.fencedAssigned, + }, + fencedPartInBook: { + type: fencedPartInBook, + from: [FencedPart], + to: [FencedBook], + ...EDGE_DECLARATIONS.fencedPartInBook, + }, + fencedAnthologyHasPart: { + type: fencedAnthologyHasPart, + from: [FencedAnthology], + to: [FencedPart], + ...EDGE_DECLARATIONS.fencedAnthologyHasPart, }, }, ontology: [ subClassOf(FencedEmployee, FencedWorker), subClassOf(FencedContractor, FencedWorker), disjointWith(FencedEmployee, FencedCompany), + partOf(FencedPart, FencedBook, { via: fencedPartInBook }), + hasPart(FencedAnthology, FencedPart, { via: fencedAnthologyHasPart }), ], }); -/** Short pools, so collisions are the common case rather than the rare one. */ +const registry = buildKindRegistry(claimGraph); + +type ClaimStore = Store; +type WriteSurface = Pick< + TransactionContext, + "edges" | "nodes" +>; + +const STAFF_KINDS = ["FencedEmployee", "FencedContractor"] as const; +type StaffKind = (typeof STAFF_KINDS)[number]; + +const DISJOINT_PAIR = ["FencedEmployee", "FencedCompany"] as const; + +type NodeKind = + | StaffKind + | "FencedCompany" + | "FencedVendor" + | "FencedPart" + | "FencedBook" + | "FencedAnthology"; + +type EdgeKind = keyof typeof EDGE_DECLARATIONS; + +/** Each edge kind's one declared endpoint pair. */ +const EDGE_ENDPOINTS: Readonly< + Record> +> = { + fencedManages: { fromKind: "FencedEmployee", toKind: "FencedVendor" }, + fencedAudits: { fromKind: "FencedContractor", toKind: "FencedVendor" }, + fencedAssigned: { fromKind: "FencedContractor", toKind: "FencedCompany" }, + fencedPartInBook: { fromKind: "FencedPart", toKind: "FencedBook" }, + fencedAnthologyHasPart: { + fromKind: "FencedAnthology", + toKind: "FencedPart", + }, +}; + +/** Which endpoint is the part, for the two composition-realizing kinds. */ +const COMPOSITION_PART_SIDE: Readonly< + Partial> +> = { fencedPartInBook: "from", fencedAnthologyHasPart: "to" }; + +// Short pools, so collisions are the common case rather than the rare one. const ID_POOL = ["i1", "i2", "i3"] as const; -const EMAIL_POOL = ["a@x", "b@x"] as const; +const EMAIL_POOL = ["a@x", "b@x", "c@x"] as const; const CODE_POOL = ["c1", "c2"] as const; -const STAFF_KINDS = ["FencedEmployee", "FencedContractor"] as const; -const ALL_KINDS = [ +const PART_POOL = ["p1", "p2"] as const; +const BOOK_POOL = ["b1", "b2"] as const; +const ANTHOLOGY_POOL = ["a1", "a2"] as const; + +/** A window end that is set (so `oneActive` stops counting the edge) yet never reached. */ +const ENDED_VALID_TO = "2999-01-01T00:00:00.000Z"; + +const STAFF_AXIS = uniquenessClaimAxis( "FencedEmployee", - "FencedContractor", - "FencedCompany", - "FencedVendor", -] as const; + "kindWithSubClasses", + registry, +); +const DISJOINT_AXIS = disjointnessClaimAxis(...DISJOINT_PAIR, registry); -type StaffKind = (typeof STAFF_KINDS)[number]; -type AnyKind = (typeof ALL_KINDS)[number]; +function staffKey(email: string): string { + return computeUniqueKey({ email }, ["email"], "binary"); +} -type Step = +function vendorKey(code: string): string { + return computeUniqueKey({ code }, ["code"], "binary"); +} + +// ============================================================ +// Steps +// ============================================================ + +type PutStep = + | Readonly<{ type: "putStaff"; kind: StaffKind; id: string; email: string }> + | Readonly<{ type: "putCompany"; id: string }> + | Readonly<{ type: "putVendor"; id: string; code: string }>; + +type Endpoints = Readonly<{ fromId: string; toId: string }>; + +/** + * Endpoints as indexes into the LIVE nodes of each declared endpoint kind, + * resolved when the step runs: drawing ids from a pool instead would name a + * dead endpoint most of the time and spend the run on skipped steps. + */ +type EndpointPicks = Readonly<{ fromPick: number; toPick: number }>; + +type EdgeTargetType = + "deleteEdge" | "hardDeleteEdge" | "endWindow" | "reopenWindow"; + +/** + * One step, generic over how it names endpoints and edges: as generated + * (`EndpointPicks`, an edge pick) or as resolved against the model (ids). + */ +type StepOf = + | PutStep + | Readonly<{ type: "link"; edgeKind: EdgeKind } & TEndpoints> | Readonly<{ - type: "upsertStaff"; + type: "bulkStaff"; kind: StaffKind; + items: readonly Readonly<{ id: string; email: string }>[]; + }> + | Readonly<{ + type: "bulkLink"; + edgeKind: EdgeKind; + pairs: readonly TEndpoints[]; + }> + | Readonly<{ + type: "deleteNode" | "hardDeleteNode"; + kind: StaffKind | "FencedCompany" | "FencedVendor"; id: string; - email: string; }> - | Readonly<{ type: "upsertCompany"; id: string }> - | Readonly<{ type: "upsertVendor"; id: string; code: string }> - | Readonly<{ type: "deleteNode"; kind: AnyKind; id: string }> - | Readonly<{ type: "link"; employeeId: string; vendorId: string }> - | Readonly<{ type: "unlink"; employeeId: string; vendorId: string }>; + | Readonly<{ type: EdgeTargetType } & TEdge>; -const stepArb: fc.Arbitrary = fc.oneof( +/** A step a transaction may carry: a node write or a single edge create. */ +type SimpleStep = Extract< + StepOf>, + Readonly<{ type: "putStaff" | "putCompany" | "putVendor" | "link" }> +>; + +type Step = + | StepOf> + | Readonly<{ type: "transaction"; steps: readonly SimpleStep[] }>; + +type GeneratedStep = Exclude>; +type ResolvedStep = StepOf>; + +const endpointPicksArb: fc.Arbitrary = fc.record({ + fromPick: fc.nat({ max: 5 }), + toPick: fc.nat({ max: 5 }), +}); + +const edgeKindArb = fc.constantFrom( + ...(Object.keys(EDGE_DECLARATIONS) as EdgeKind[]), +); + +const putStepArb: fc.Arbitrary = fc.oneof( fc.record({ - type: fc.constant("upsertStaff" as const), + type: fc.constant("putStaff" as const), kind: fc.constantFrom(...STAFF_KINDS), id: fc.constantFrom(...ID_POOL), email: fc.constantFrom(...EMAIL_POOL), }), fc.record({ - type: fc.constant("upsertCompany" as const), + type: fc.constant("putCompany" as const), id: fc.constantFrom(...ID_POOL), }), fc.record({ - type: fc.constant("upsertVendor" as const), + type: fc.constant("putVendor" as const), id: fc.constantFrom(...ID_POOL), code: fc.constantFrom(...CODE_POOL), }), - fc.record({ - type: fc.constant("deleteNode" as const), - kind: fc.constantFrom(...ALL_KINDS), - id: fc.constantFrom(...ID_POOL), - }), - fc.record({ - type: fc.constant("link" as const), - employeeId: fc.constantFrom(...ID_POOL), - vendorId: fc.constantFrom(...ID_POOL), - }), - fc.record({ - type: fc.constant("unlink" as const), - employeeId: fc.constantFrom(...ID_POOL), - vendorId: fc.constantFrom(...ID_POOL), - }), ); +const linkStepArb = fc.record({ + type: fc.constant("link" as const), + edgeKind: edgeKindArb, + fromPick: fc.nat({ max: 5 }), + toPick: fc.nat({ max: 5 }), +}); + +const simpleStepArb: fc.Arbitrary = fc.oneof( + putStepArb, + linkStepArb, +); + +const stepArb: fc.Arbitrary = fc.oneof( + { arbitrary: putStepArb, weight: 3 }, + { arbitrary: linkStepArb, weight: 6 }, + { + arbitrary: fc.record({ + type: fc.constant("bulkStaff" as const), + kind: fc.constantFrom(...STAFF_KINDS), + items: fc.uniqueArray( + fc.record({ + id: fc.constantFrom(...ID_POOL), + email: fc.constantFrom(...EMAIL_POOL), + }), + { minLength: 1, maxLength: 2, selector: (item) => item.id }, + ), + }), + weight: 1, + }, + { + arbitrary: fc.record({ + type: fc.constant("bulkLink" as const), + edgeKind: edgeKindArb, + pairs: fc.array(endpointPicksArb, { minLength: 1, maxLength: 2 }), + }), + weight: 2, + }, + { + arbitrary: fc.record({ + type: fc.constantFrom("deleteNode" as const, "hardDeleteNode" as const), + kind: fc.constantFrom( + ...STAFF_KINDS, + "FencedCompany" as const, + "FencedVendor" as const, + ), + id: fc.constantFrom(...ID_POOL), + }), + weight: 2, + }, + { + arbitrary: fc.record({ + type: fc.constantFrom( + "deleteEdge" as const, + "hardDeleteEdge" as const, + "endWindow" as const, + "reopenWindow" as const, + ), + pick: fc.nat({ max: 7 }), + }), + weight: 5, + }, + { + arbitrary: fc.record({ + type: fc.constant("transaction" as const), + steps: fc.array(simpleStepArb, { minLength: 2, maxLength: 3 }), + }), + weight: 1, + }, +); + +// ============================================================ +// Reference model +// ============================================================ + +type ModelNode = Readonly<{ + kind: NodeKind; + id: string; + /** The email or vendor code, for the kinds that declare a unique. */ + uniqueValue?: string; + deleted: boolean; + /** False for a row planted past the store: it holds no claim rows. */ + claimed: boolean; +}>; + +type ModelEdge = Readonly<{ + id: string; + kind: EdgeKind; + fromKind: NodeKind; + fromId: string; + toKind: NodeKind; + toId: string; + ended: boolean; + deleted: boolean; +}>; + +/** A `uniques` row written past the store, at a legacy (non-axis) node kind. */ +type PlantedUnique = Readonly<{ + nodeKind: string; + key: string; + concreteKind: StaffKind; + nodeId: string; +}>; + +type ErrorClass = abstract new (...args: never[]) => Error; + +type Verdict = + | Readonly<{ accepted: true }> + | Readonly<{ + accepted: false; + refusals: readonly ErrorClass[]; + reason: string; + }>; + +const ACCEPT: Verdict = { accepted: true }; + +function refuse(refusals: readonly ErrorClass[], reason: string): Verdict { + return { accepted: false, refusals, reason }; +} + /** - * The refusals a random sequence is EXPECTED to produce: a declared constraint - * saying no, or a write naming a row that is not there. Anything else is a - * defect and is re-thrown — swallowing every error would turn a store that - * rejects all writes into a passing run. + * A write that violates several constraints may be refused by whichever the + * store checks first, so the combined verdict admits any of their errors. */ -function isExpectedRefusal(error: unknown): boolean { - return ( - error instanceof UniquenessError || - error instanceof DisjointError || - error instanceof CardinalityError || - error instanceof EndpointError || - error instanceof NodeNotFoundError || - error instanceof EdgeNotFoundError || - error instanceof RestrictedDeleteError || - error instanceof ValidationError +function combineVerdicts(verdicts: readonly Verdict[]): Verdict { + const refusals = verdicts.filter( + (verdict): verdict is Extract => + !verdict.accepted, + ); + if (refusals.length === 0) return ACCEPT; + return refuse( + refusals.flatMap((verdict) => verdict.refusals), + refusals.map((verdict) => verdict.reason).join("; "), ); } -type Store = ReturnType>; +function nodeKey(kind: string, id: string): string { + return `${kind}/${id}`; +} -/** Runs one step, letting the declared refusals through as no-ops. */ -async function applyStep( - store: Store, - edgeIds: Map, - step: Step, -): Promise { - try { +/** + * The graph as the declared constraints see it, plus a verdict for every step. + * Mutated only through {@link ClaimModel.apply} after the store accepted the + * same step, so a prediction is always made against the state the store had. + */ +class ClaimModel { + readonly nodes = new Map(); + readonly edges = new Map(); + readonly plantedUniques: PlantedUnique[] = []; + private edgeSequence = 0; + + clone(): ClaimModel { + const copy = new ClaimModel(); + for (const [key, node] of this.nodes) copy.nodes.set(key, node); + for (const [key, edge] of this.edges) copy.edges.set(key, edge); + copy.plantedUniques.push(...this.plantedUniques); + copy.edgeSequence = this.edgeSequence; + return copy; + } + + nextEdgeId(): string { + this.edgeSequence += 1; + return `e${String(this.edgeSequence)}`; + } + + node(kind: string, id: string): ModelNode | undefined { + return this.nodes.get(nodeKey(kind, id)); + } + + liveNode(kind: string, id: string): ModelNode | undefined { + const node = this.node(kind, id); + return node?.deleted === false ? node : undefined; + } + + liveNodes(): readonly ModelNode[] { + return [...this.nodes.values()].filter((node) => !node.deleted); + } + + /** Non-deleted edges, ordered by id so a `pick` resolves deterministically. */ + liveEdges(): readonly ModelEdge[] { + return [...this.edges.values()] + .filter((edge) => !edge.deleted) + .toSorted((left, right) => compareStrings(left.id, right.id)); + } + + edgeRows(): readonly ModelEdge[] { + return [...this.edges.values()].toSorted((left, right) => + compareStrings(left.id, right.id), + ); + } + + /** Whether any non-deleted edge (current, ended or otherwise) touches the node. */ + hasLiveIncidentEdge(kind: string, id: string): boolean { + return this.liveEdges().some( + (edge) => + (edge.fromKind === kind && edge.fromId === id) || + (edge.toKind === kind && edge.toId === id), + ); + } + + /** The edges a step of `type` can name, ordered by id. */ + private edgesFor(type: EdgeTargetType): readonly ModelEdge[] { + switch (type) { + case "deleteEdge": { + return this.liveEdges(); + } + case "hardDeleteEdge": { + return this.edgeRows(); + } + case "endWindow": { + return this.liveEdges().filter( + (edge) => edge.kind === "fencedAssigned" && !edge.ended, + ); + } + case "reopenWindow": { + return this.liveEdges().filter( + (edge) => edge.kind === "fencedAssigned" && edge.ended, + ); + } + } + } + + private static nth(values: readonly T[], pick: number): T | undefined { + return values[pick % Math.max(1, values.length)]; + } + + /** The live node of `kind` a pick names, ordered by id. */ + private pickLiveNode(kind: NodeKind, pick: number): string | undefined { + const ids = this.liveNodes() + .filter((node) => node.kind === kind) + .map((node) => node.id) + .toSorted((left, right) => compareStrings(left, right)); + return ClaimModel.nth(ids, pick); + } + + private resolveEndpoints( + edgeKind: EdgeKind, + picks: EndpointPicks, + ): Endpoints | undefined { + const { fromKind, toKind } = EDGE_ENDPOINTS[edgeKind]; + const fromId = this.pickLiveNode(fromKind, picks.fromPick); + const toId = this.pickLiveNode(toKind, picks.toPick); + return fromId === undefined || toId === undefined ? + undefined + : { fromId, toId }; + } + + edge(id: string): ModelEdge { + return requireDefined(this.edges.get(id), `model edge ${id}`); + } + + // --- the constraint predicates, from the live rows --- + + /** Another live claimed staff node (a different owner) holding `email`. */ + staffEmailHolder( + email: string, + owner: Readonly<{ kind: NodeKind; id: string }>, + ): ModelNode | undefined { + return this.liveNodes().find( + (node) => + node.claimed && + (node.kind === "FencedEmployee" || node.kind === "FencedContractor") && + node.uniqueValue === email && + nodeKey(node.kind, node.id) !== nodeKey(owner.kind, owner.id), + ); + } + + vendorCodeHolder(code: string, id: string): ModelNode | undefined { + return this.liveNodes().find( + (node) => + node.claimed && + node.kind === "FencedVendor" && + node.uniqueValue === code && + node.id !== id, + ); + } + + /** The other kind of the disjoint pair, when `kind` is in it. */ + static disjointPartner(kind: NodeKind): NodeKind | undefined { + if (kind === DISJOINT_PAIR[0]) return DISJOINT_PAIR[1]; + if (kind === DISJOINT_PAIR[1]) return DISJOINT_PAIR[0]; + return undefined; + } + + /** + * The non-deleted edges a new edge would share an axis with, per axis it + * declares: a source `one` counts every edge from the source, a target + * `one` every edge into the target, a `oneActive` only the unended ones, + * and the composition axis every edge of EITHER realizing kind naming the + * same part, whichever side it sits on. + */ + axisContenders(candidate: ModelEdge): readonly string[] { + const reasons: string[] = []; + const peers = this.liveEdges().filter((edge) => edge.id !== candidate.id); + const sameKind = peers.filter((edge) => edge.kind === candidate.kind); + const declaration = EDGE_DECLARATIONS[candidate.kind]; + if ("cardinality" in declaration) { + const activeOnly = declaration.cardinality === "oneActive"; + const holder = sameKind.find( + (edge) => + edge.fromId === candidate.fromId && (!activeOnly || !edge.ended), + ); + if (holder !== undefined && (!activeOnly || !candidate.ended)) + reasons.push(`source ${declaration.cardinality} held by ${holder.id}`); + } + if ("targetCardinality" in declaration) { + const holder = sameKind.find((edge) => edge.toId === candidate.toId); + if (holder !== undefined) reasons.push(`target one held by ${holder.id}`); + } + const part = compositionPart(candidate); + if (part !== undefined) { + const holder = peers.find((edge) => compositionPart(edge) === part); + if (holder !== undefined) + reasons.push(`composition part ${part} held by ${holder.id}`); + } + return reasons; + } + + // --- verdicts --- + + /** + * The step with its picks resolved against the current state, or + * `undefined` when it names state this model does not decide (no live + * endpoint to pick, a node with live edges to delete) and is skipped. + */ + resolve(step: GeneratedStep): ResolvedStep | undefined { switch (step.type) { - case "upsertStaff": { - await store.nodes[step.kind].upsertById(step.id, { - email: step.email, - }); - return; + case "putStaff": + case "putCompany": + case "putVendor": { + return step; + } + case "link": { + const endpoints = this.resolveEndpoints(step.edgeKind, step); + return endpoints === undefined ? undefined : ( + { type: "link", edgeKind: step.edgeKind, ...endpoints } + ); + } + case "bulkLink": { + const pairs = step.pairs.map((pair) => + this.resolveEndpoints(step.edgeKind, pair), + ); + return pairs.includes(undefined) ? undefined : ( + { + type: "bulkLink", + edgeKind: step.edgeKind, + pairs: pairs.map((pair) => requireDefined(pair)), + } + ); + } + case "bulkStaff": { + // A create-only batch over ids with no row at all: what a create does + // to a tombstoned id is the upsert's business, not this one's. + return ( + step.items.every( + (item) => this.node(step.kind, item.id) === undefined, + ) + ) ? + step + : undefined; + } + case "deleteNode": + case "hardDeleteNode": { + const node = + step.type === "deleteNode" ? + this.liveNode(step.kind, step.id) + : this.node(step.kind, step.id); + return ( + node === undefined || this.hasLiveIncidentEdge(step.kind, step.id) + ) ? + undefined + : step; + } + case "deleteEdge": + case "hardDeleteEdge": + case "endWindow": + case "reopenWindow": { + const edge = ClaimModel.nth(this.edgesFor(step.type), step.pick); + return edge === undefined ? undefined : ( + { type: step.type, edgeId: edge.id } + ); + } + } + } + + /** The verdict for one non-composite step; `edgeId` names a created edge. */ + verdict(step: ResolvedStep, edgeIds: readonly string[]): Verdict { + switch (step.type) { + case "putStaff": { + const holder = this.staffEmailHolder(step.email, step); + return combineVerdicts([ + holder === undefined ? ACCEPT : ( + refuse( + [UniquenessError], + `${step.email} held by ${nodeKey(holder.kind, holder.id)}`, + ) + ), + this.disjointVerdict(step.kind, step.id), + ]); + } + case "putCompany": { + return this.disjointVerdict("FencedCompany", step.id); + } + case "putVendor": { + const holder = this.vendorCodeHolder(step.code, step.id); + return holder === undefined ? ACCEPT : ( + refuse([UniquenessError], `${step.code} held by ${holder.id}`) + ); + } + case "link": { + return this.linkVerdict( + this.proposedEdge(step.edgeKind, step, requireDefined(edgeIds[0])), + ); + } + case "bulkStaff": { + return this.batchVerdict( + step.items.map((item): ResolvedStep => ({ + type: "putStaff", + kind: step.kind, + ...item, + })), + step.items.map(() => ""), + ); + } + case "bulkLink": { + return this.batchVerdict( + step.pairs.map((pair): ResolvedStep => ({ + type: "link", + edgeKind: step.edgeKind, + ...pair, + })), + edgeIds, + ); } - case "upsertCompany": { - await store.nodes.FencedCompany.upsertById(step.id, { name: step.id }); + case "reopenWindow": { + return this.linkVerdict({ ...this.edge(step.edgeId), ended: false }); + } + case "deleteNode": + case "hardDeleteNode": + case "deleteEdge": + case "hardDeleteEdge": + case "endWindow": { + return ACCEPT; + } + } + } + + /** + * A node write is refused when the id is live under the OTHER kind of a + * declared disjoint pair — unless the node itself is already live, whose + * update claims nothing new. + */ + private disjointVerdict(kind: NodeKind, id: string): Verdict { + const partner = ClaimModel.disjointPartner(kind); + if (partner === undefined) return ACCEPT; + if (this.liveNode(kind, id) !== undefined) return ACCEPT; + const other = this.liveNode(partner, id); + return other?.claimed === true ? + refuse([DisjointError], `${id} is live as ${partner}`) + : ACCEPT; + } + + private linkVerdict(candidate: ModelEdge): Verdict { + const reasons = this.axisContenders(candidate); + if (reasons.length === 0) return ACCEPT; + return refuse( + COMPOSITION_PART_SIDE[candidate.kind] === undefined ? + [CardinalityError] + : [CardinalityError, CompositionError], + reasons.join("; "), + ); + } + + /** A batch is all-or-nothing: the first refused item refuses it whole. */ + private batchVerdict( + items: readonly ResolvedStep[], + edgeIds: readonly string[], + ): Verdict { + const scratch = this.clone(); + for (const [index, item] of items.entries()) { + const itemEdgeIds = item.type === "link" ? [edgeIds[index] ?? ""] : []; + const verdict = scratch.verdict(item, itemEdgeIds); + if (!verdict.accepted) + return refuse( + verdict.refusals, + `item ${String(index)}: ${verdict.reason}`, + ); + scratch.apply(item, itemEdgeIds); + } + return ACCEPT; + } + + proposedEdge( + edgeKind: EdgeKind, + endpoints: Readonly<{ fromId: string; toId: string }>, + id: string, + ): ModelEdge { + return { + id, + kind: edgeKind, + ...EDGE_ENDPOINTS[edgeKind], + fromId: endpoints.fromId, + toId: endpoints.toId, + ended: false, + deleted: false, + }; + } + + /** Applies an accepted step. */ + apply(step: ResolvedStep, edgeIds: readonly string[]): void { + switch (step.type) { + case "putStaff": { + this.putNode(step.kind, step.id, step.email); return; } - case "upsertVendor": { - await store.nodes.FencedVendor.upsertById(step.id, { code: step.code }); + case "putCompany": { + this.putNode("FencedCompany", step.id, undefined); return; } - case "deleteNode": { - await store.nodes[step.kind].delete(asNodeId(step.id)); + case "putVendor": { + this.putNode("FencedVendor", step.id, step.code); return; } case "link": { - const employee = await store.nodes.FencedEmployee.getById( - asNodeId(step.employeeId), - ); - const vendor = await store.nodes.FencedVendor.getById( - asNodeId(step.vendorId), - ); - if (employee === undefined || vendor === undefined) return; - const edge = await store.edges.fencedManages.create( - employee, - vendor, - {}, + const edge = this.proposedEdge( + step.edgeKind, + step, + requireDefined(edgeIds[0]), ); - edgeIds.set(`${step.employeeId}->${step.vendorId}`, edge.id); + this.edges.set(edge.id, edge); + return; + } + case "bulkStaff": { + for (const item of step.items) + this.putNode(step.kind, item.id, item.email); + return; + } + case "bulkLink": { + for (const [index, pair] of step.pairs.entries()) { + const edge = this.proposedEdge( + step.edgeKind, + pair, + requireDefined(edgeIds[index]), + ); + this.edges.set(edge.id, edge); + } + return; + } + case "deleteNode": { + const node = requireDefined(this.node(step.kind, step.id)); + this.nodes.set(nodeKey(step.kind, step.id), { ...node, deleted: true }); + return; + } + case "hardDeleteNode": { + this.nodes.delete(nodeKey(step.kind, step.id)); + // A hard delete also purges the tombstoned edges at the node. + for (const edge of this.edgeRows()) { + const touches = + (edge.fromKind === step.kind && edge.fromId === step.id) || + (edge.toKind === step.kind && edge.toId === step.id); + if (touches) this.edges.delete(edge.id); + } return; } - case "unlink": { - const edgeId = edgeIds.get(`${step.employeeId}->${step.vendorId}`); - if (edgeId === undefined) return; - await store.edges.fencedManages.delete(asEdgeId(edgeId)); - edgeIds.delete(`${step.employeeId}->${step.vendorId}`); + case "deleteEdge": { + this.edges.set(step.edgeId, { + ...this.edge(step.edgeId), + deleted: true, + }); + return; + } + case "hardDeleteEdge": { + this.edges.delete(step.edgeId); + return; + } + case "endWindow": + case "reopenWindow": { + this.edges.set(step.edgeId, { + ...this.edge(step.edgeId), + ended: step.type === "endWindow", + }); return; } } + } + + putNode(kind: NodeKind, id: string, uniqueValue: string | undefined): void { + this.nodes.set(nodeKey(kind, id), { + kind, + id, + ...(uniqueValue === undefined ? {} : { uniqueValue }), + deleted: false, + claimed: true, + }); + } + + // --- what the claim relations must hold --- + + /** Every live `uniques` row the store owes: uniqueness and disjointness claims. */ + expectedUniqueRows(): readonly UniqueRow[] { + const rows: UniqueRow[] = []; + for (const node of this.liveNodes()) { + if (!node.claimed) continue; + const owner = { node_id: node.id, concrete_kind: node.kind }; + if (node.kind === "FencedEmployee" || node.kind === "FencedContractor") { + rows.push({ + node_kind: STAFF_AXIS, + constraint_name: STAFF_EMAIL_CONSTRAINT, + key: staffKey(requireDefined(node.uniqueValue)), + ...owner, + }); + } + if (node.kind === "FencedVendor") { + rows.push({ + node_kind: "FencedVendor", + constraint_name: VENDOR_CODE_CONSTRAINT, + key: vendorKey(requireDefined(node.uniqueValue)), + ...owner, + }); + } + if (ClaimModel.disjointPartner(node.kind) !== undefined) { + rows.push({ + node_kind: DISJOINT_AXIS, + constraint_name: DISJOINT_CONSTRAINT_NAME, + key: node.id, + ...owner, + }); + } + } + return rows; + } + + /** + * `(axis, key) -> holder edge id` for every axis a live edge holds, by the + * model's own membership rule (an ended edge leaves its `oneActive` axis). + * The axis and key strings are the claim owner's encoding. + */ + expectedEdgeClaims(): ReadonlyMap { + const held = new Map(); + for (const edge of this.liveEdges()) { + const claims = edgeInsertClaims(registry, EDGE_DECLARATIONS[edge.kind], { + graphId: claimGraph.id, + ...edge, + }); + for (const claim of claims) { + if (claim.cardinality === "oneActive" && edge.ended) continue; + const target = edgeClaimTargetKey(claim); + held.set(target, edge.id); + } + } + return held; + } +} + +function compositionPart(edge: ModelEdge): string | undefined { + const side = COMPOSITION_PART_SIDE[edge.kind]; + if (side === undefined) return undefined; + return side === "from" ? + nodeKey(edge.fromKind, edge.fromId) + : nodeKey(edge.toKind, edge.toId); +} + +// ============================================================ +// Store side +// ============================================================ + +type UniqueRow = Readonly<{ + node_kind: string; + constraint_name: string; + key: string; + node_id: string; + concrete_kind: string; +}>; + +async function readLiveUniqueRows( + backend: GraphBackend, +): Promise { + const schema = createSqlSchema(backend.tableNames); + return backend.execute( + asCompiledRowsSql(sql` + SELECT node_kind, constraint_name, key, node_id, concrete_kind + FROM ${sql.identifier(schema.tables.uniques)} + WHERE graph_id = ${claimGraph.id} AND deleted_at IS NULL + `), + ); +} + +function edgeClaimTargetKey(claim: ClaimEdgeCardinalityParams): string { + const target = edgeCardinalityClaimTarget(claim); + return `${target.axis}\u0000${target.key}`; +} + +function sortedJson(values: readonly unknown[]): readonly string[] { + return values + .map((value) => JSON.stringify(value)) + .toSorted((left, right) => compareStrings(left, right)); +} + +function nodeRef(kind: NodeKind, id: string) { + return { kind, id: asNodeId(id) }; +} + +/** Runs one step against the store (or a transaction's write surface). */ +async function execute( + surface: WriteSurface, + model: ClaimModel, + step: ResolvedStep, + edgeIds: readonly string[], +): Promise { + switch (step.type) { + case "putStaff": { + await surface.nodes[step.kind].upsertById(step.id, { email: step.email }); + return; + } + case "putCompany": { + await surface.nodes.FencedCompany.upsertById(step.id, { name: step.id }); + return; + } + case "putVendor": { + await surface.nodes.FencedVendor.upsertById(step.id, { code: step.code }); + return; + } + case "bulkStaff": { + await surface.nodes[step.kind].bulkCreate( + step.items.map((item) => ({ + props: { email: item.email }, + id: item.id, + })), + ); + return; + } + case "link": { + await createEdge( + surface, + step.edgeKind, + step, + requireDefined(edgeIds[0]), + ); + return; + } + case "bulkLink": { + await bulkCreateEdges(surface, step.edgeKind, step.pairs, edgeIds); + return; + } + case "deleteNode": { + await surface.nodes[step.kind].delete(asNodeId(step.id)); + return; + } + case "hardDeleteNode": { + await surface.nodes[step.kind].hardDelete(asNodeId(step.id)); + return; + } + case "deleteEdge": { + const edge = model.edge(step.edgeId); + await surface.edges[edge.kind].delete(asEdgeId(edge.id)); + return; + } + case "hardDeleteEdge": { + const edge = model.edge(step.edgeId); + await surface.edges[edge.kind].hardDelete(asEdgeId(edge.id)); + return; + } + case "endWindow": { + const edge = model.edge(step.edgeId); + await surface.edges.fencedAssigned.update( + asEdgeId(edge.id), + {}, + { validTo: ENDED_VALID_TO }, + ); + return; + } + case "reopenWindow": { + const edge = model.edge(step.edgeId); + await surface.edges.fencedAssigned.update( + asEdgeId(edge.id), + {}, + { clearValidTo: true }, + ); + return; + } + } +} + +async function createEdge( + surface: WriteSurface, + edgeKind: EdgeKind, + endpoints: Readonly<{ fromId: string; toId: string }>, + id: string, +): Promise { + const { fromKind, toKind } = EDGE_ENDPOINTS[edgeKind]; + const from = nodeRef(fromKind, endpoints.fromId); + const to = nodeRef(toKind, endpoints.toId); + // The collections are typed per kind; the endpoint kinds above are the one + // pair each kind declares, which the store re-validates regardless. + const collection = surface.edges[edgeKind] as unknown as Readonly<{ + create: ( + from: unknown, + to: unknown, + props: object, + options: Readonly<{ id: string }>, + ) => Promise; + }>; + await collection.create(from, to, {}, { id }); +} + +async function bulkCreateEdges( + surface: WriteSurface, + edgeKind: EdgeKind, + pairs: readonly Readonly<{ fromId: string; toId: string }>[], + ids: readonly string[], +): Promise { + const { fromKind, toKind } = EDGE_ENDPOINTS[edgeKind]; + const collection = surface.edges[edgeKind] as unknown as Readonly<{ + bulkCreate: (items: readonly unknown[]) => Promise; + }>; + await collection.bulkCreate( + pairs.map((pair, index) => ({ + from: nodeRef(fromKind, pair.fromId), + to: nodeRef(toKind, pair.toId), + props: {}, + id: requireDefined(ids[index]), + })), + ); +} + +/** + * Runs `run` and holds its outcome to `verdict` in BOTH directions, with + * enough of the step and the model's reasoning in the message to debug from. + */ +async function expectVerdict( + verdict: Verdict, + label: string, + run: () => Promise, +): Promise { + try { + await run(); } catch (error) { - if (!isExpectedRefusal(error)) throw error; + if (verdict.accepted) { + throw new Error(`${label}: the model accepts it, but the store refused`, { + cause: error, + }); + } + if (!verdict.refusals.some((refusal) => error instanceof refusal)) { + throw new Error( + `${label}: the model expected ${verdict.refusals.map((refusal) => refusal.name).join(" | ")} (${verdict.reason}), but the store threw something else`, + { cause: error }, + ); + } + return false; + } + if (!verdict.accepted) { + throw new Error( + `${label}: the model refuses it (${verdict.reason}), but the store accepted`, + ); } + return true; } -/** Every live node of a kind, as `(kind, id, key)` triples. */ -async function liveKeys( - store: Store, - kind: StaffKind | "FencedVendor", -): Promise[]> { - if (kind === "FencedVendor") { - const rows = await store.nodes.FencedVendor.find(); - return rows.map((row) => ({ kind, id: row.id, key: row.code })); - } - const rows = - kind === "FencedEmployee" ? - await store.nodes.FencedEmployee.find() - : await store.nodes.FencedContractor.find(); - return rows.map((row) => ({ kind, id: row.id, key: row.email })); +function edgeIdsFor(model: ClaimModel, step: ResolvedStep): readonly string[] { + if (step.type === "link") return [model.nextEdgeId()]; + if (step.type === "bulkLink") return step.pairs.map(() => model.nextEdgeId()); + return []; } -/** Every declared constraint, re-derived from the live rows alone. */ -async function assertConstraintsHold( - store: Store, +/** Predicts, runs and (when accepted) applies one non-transaction step. */ +async function runStep( + surface: WriteSurface, + model: ClaimModel, + generated: GeneratedStep, +): Promise { + const step = model.resolve(generated); + if (step === undefined) return; + const edgeIds = edgeIdsFor(model, step); + const verdict = model.verdict(step, edgeIds); + const accepted = await expectVerdict( + verdict, + `${JSON.stringify(step)} as ${edgeIds.join(",") || "-"}`, + () => execute(surface, model, step, edgeIds), + ); + if (accepted) model.apply(step, edgeIds); +} + +/** Everything the store holds must agree with the model. */ +async function assertAgreesWithModel( + store: ClaimStore, backend: GraphBackend, + model: ClaimModel, ): Promise { - // 1. The shared scope: one live claimant per email across the hierarchy. - const staff = [ - ...(await liveKeys(store, "FencedEmployee")), - ...(await liveKeys(store, "FencedContractor")), - ]; - const staffOwnersByKey = new Map(); - for (const entry of staff) { - const owners = staffOwnersByKey.get(entry.key) ?? []; - owners.push(`${entry.kind}/${entry.id}`); - staffOwnersByKey.set(entry.key, owners); - } - for (const [key, owners] of staffOwnersByKey) - expect(owners, `live staff sharing email ${key}`).toHaveLength(1); - - // 2. The single-kind scope: one live claimant per vendor code. - const vendors = await liveKeys(store, "FencedVendor"); - const codes = vendors.map((vendor) => vendor.key); - expect(new Set(codes).size, "live vendors sharing a code").toBe(codes.length); - - // 3. Disjointness: no id live under both kinds of the declared pair. - const companies = await store.nodes.FencedCompany.find(); - const companyIds = new Set(companies.map((company) => company.id)); - const overlap = staff - .filter((entry) => entry.kind === "FencedEmployee") - .filter((entry) => companyIds.has(entry.id)); - expect(overlap, "ids live as both FencedEmployee and FencedCompany").toEqual( - [], + // 1. The live data itself. + const storeNodes: string[] = []; + for (const node of await store.nodes.FencedEmployee.find()) + storeNodes.push(`FencedEmployee/${node.id}=${node.email}`); + for (const node of await store.nodes.FencedContractor.find()) + storeNodes.push(`FencedContractor/${node.id}=${node.email}`); + for (const node of await store.nodes.FencedVendor.find()) + storeNodes.push(`FencedVendor/${node.id}=${node.code}`); + for (const node of await store.nodes.FencedCompany.find()) + storeNodes.push(`FencedCompany/${node.id}=`); + const modelNodes = model + .liveNodes() + .filter((node) => + [ + "FencedEmployee", + "FencedContractor", + "FencedVendor", + "FencedCompany", + ].includes(node.kind), + ) + .map((node) => `${nodeKey(node.kind, node.id)}=${node.uniqueValue ?? ""}`); + expect(storeNodes.toSorted(), "live nodes").toEqual(modelNodes.toSorted()); + + const storeEdges: string[] = []; + for (const edgeKind of Object.keys(EDGE_DECLARATIONS) as EdgeKind[]) { + for (const edge of await store.edges[edgeKind].find()) + storeEdges.push(`${edgeKind}/${edge.id}`); + } + expect(storeEdges.toSorted(), "live edges").toEqual( + model + .liveEdges() + .map((edge) => `${edge.kind}/${edge.id}`) + .toSorted(), ); - // 4. Cardinality `one`: at most one live edge from each source. - for (const entry of staff.filter( - (candidate) => candidate.kind === "FencedEmployee", - )) { - const count = await backend.countEdgesFrom({ - graphId: claimGraph.id, - edgeKind: "fencedManages", - fromKind: "FencedEmployee", - fromId: entry.id, - activeOnly: false, - }); - expect(count, `live fencedManages edges from ${entry.id}`).toBeLessThan(2); + // 2. Released node claims are gone; owed ones are present, nothing else. + expect( + sortedJson(await readLiveUniqueRows(backend)), + "live uniques rows (uniqueness + disjointness claims)", + ).toEqual(sortedJson(model.expectedUniqueRows())); + + // 3. Every held edge axis names its holder. A row on an axis no live edge + // holds is a stale claim the next writer takes over, so it may linger. + const claimRows = await readEdgeClaimRows(backend, claimGraph.id); + const rowsByTarget = new Map( + claimRows.map((row) => [`${row.axis}\u0000${row.key}`, row.edge_id]), + ); + for (const [target, holder] of model.expectedEdgeClaims()) { + expect( + rowsByTarget.get(target), + `edge claim ${JSON.stringify(target)}`, + ).toBe(holder); } - // The diagnostic must agree with the four checks above on clean data. + // 4. On data the fences kept clean, the audit reports nothing. expect(await store.verifyConstraintFences()).toEqual([]); } +// ============================================================ +// Commands and seed +// ============================================================ + +type ClaimSystem = Readonly<{ store: ClaimStore; backend: GraphBackend }>; + +/** One step as a model-run command: skipped when the model cannot decide it. */ +class StepCommand implements fc.AsyncCommand { + constructor(readonly step: Step) {} + + check(model: Readonly): boolean { + return ( + this.step.type === "transaction" || model.resolve(this.step) !== undefined + ); + } + + async run(model: ClaimModel, system: ClaimSystem): Promise { + const { step } = this; + if (step.type === "transaction") { + // Each inner refusal is caught INSIDE the transaction, which must then + // commit the inner writes the model accepted and nothing of the refused + // ones. + await system.store.transaction(async (tx) => { + for (const inner of step.steps) await runStep(tx, model, inner); + }); + } else { + await runStep(system.store, model, step); + } + await assertAgreesWithModel(system.store, system.backend, model); + } + + toString(): string { + return JSON.stringify(this.step); + } +} + +const commandsArb = fc.commands( + [stepArb.map((step) => new StepCommand(step))], + { maxCommands: 24, size: "+1" }, +); + /** - * A populated starting state, identical for every run. - * - * Without it the interesting region is barely reachable: a second edge on one - * cardinality axis needs a live source and two live targets, which a purely - * random 16-step sequence over four kinds produces too rarely to be relied on. - * The prelude itself violates nothing — two staff with distinct emails, two - * vendors with distinct codes — so every violation a run could produce is - * produced by the random steps. + * A populated, violation-free starting state. Without it the interesting + * region — a second claimant on a held axis — needs live endpoints that a + * short random sequence rarely builds first. */ -async function seedStartingState(store: Store): Promise { - await store.nodes.FencedEmployee.upsertById("i1", { email: "a@x" }); - await store.nodes.FencedEmployee.upsertById("i2", { email: "b@x" }); - await store.nodes.FencedVendor.upsertById("i1", { code: "c1" }); - await store.nodes.FencedVendor.upsertById("i2", { code: "c2" }); +const SEED_STEPS: readonly SimpleStep[] = [ + { type: "putStaff", kind: "FencedEmployee", id: "i1", email: "a@x" }, + { type: "putStaff", kind: "FencedContractor", id: "i2", email: "b@x" }, + { type: "putVendor", id: "i1", code: "c1" }, + { type: "putVendor", id: "i2", code: "c2" }, + { type: "putCompany", id: "i3" }, +]; + +const STRUCTURAL_SEED: readonly Readonly<{ + kind: "FencedPart" | "FencedBook" | "FencedAnthology"; + ids: readonly string[]; +}>[] = [ + { kind: "FencedPart", ids: PART_POOL }, + { kind: "FencedBook", ids: BOOK_POOL }, + { kind: "FencedAnthology", ids: ANTHOLOGY_POOL }, +]; + +async function seed(store: ClaimStore, model: ClaimModel): Promise { + for (const step of SEED_STEPS) await runStep(store, model, step); + for (const { kind, ids } of STRUCTURAL_SEED) { + for (const id of ids) { + await store.nodes[kind].create({}, { id }); + model.putNode(kind, id, undefined); + } + } } -describe("constraint claims hold over random write sequences", () => { - it("never leaves two live claimants on one axis", async () => { +// ============================================================ +// Dirty rows: what the audit must report +// ============================================================ + +const PLANTED_VALID_FROM = "2000-01-01T00:00:00.000Z"; +const PLANTED_VALID_TO = "2001-01-01T00:00:00.000Z"; + +/** Kinds a staff claim folds onto STAFF_AXIS from, other than the axis itself. */ +const LEGACY_STAFF_AXES = uniquenessProbeKinds( + "FencedEmployee", + "kindWithSubClasses", + registry, +).slice(1); + +type Plant = + | Readonly< + { + type: "plantEdge"; + edgeKind: EdgeKind; + ended: boolean; + } & EndpointPicks + > + | Readonly<{ + type: "plantNode"; + kind: "FencedEmployee" | "FencedCompany"; + id: string; + }> + | Readonly<{ + type: "plantUnique"; + nodeKind: string; + email: string; + concreteKind: StaffKind; + nodeId: string; + }>; + +const plantArb: fc.Arbitrary = fc.oneof( + fc.record({ + type: fc.constant("plantEdge" as const), + edgeKind: edgeKindArb, + fromPick: fc.nat({ max: 5 }), + toPick: fc.nat({ max: 5 }), + ended: fc.boolean(), + }), + fc.record({ + type: fc.constant("plantNode" as const), + kind: fc.constantFrom("FencedEmployee" as const, "FencedCompany" as const), + id: fc.constantFrom(...ID_POOL), + }), + fc.record({ + type: fc.constant("plantUnique" as const), + nodeKind: fc.constantFrom(...LEGACY_STAFF_AXES), + email: fc.constantFrom(...EMAIL_POOL), + concreteKind: fc.constantFrom(...STAFF_KINDS), + nodeId: fc.constantFrom(...ID_POOL, "ghost"), + }), +); + +/** Writes one dirty row past the store, skipping one that would collide with a key. */ +async function plantRow( + backend: GraphBackend, + model: ClaimModel, + plant: Plant, +): Promise { + switch (plant.type) { + case "plantEdge": { + const { fromKind, toKind } = EDGE_ENDPOINTS[plant.edgeKind]; + const link = model.resolve({ ...plant, type: "link" }); + if (link?.type !== "link") return; + const id = `planted-${model.nextEdgeId()}`; + await backend.insertEdge({ + graphId: claimGraph.id, + id, + kind: plant.edgeKind, + fromKind, + fromId: link.fromId, + toKind, + toId: link.toId, + props: {}, + ...(plant.ended ? + { validFrom: PLANTED_VALID_FROM, validTo: PLANTED_VALID_TO } + : {}), + }); + model.edges.set(id, { + ...model.proposedEdge(plant.edgeKind, link, id), + ended: plant.ended, + }); + return; + } + case "plantNode": { + if (model.node(plant.kind, plant.id) !== undefined) return; + const uniqueValue = + plant.kind === "FencedEmployee" ? `planted-${plant.id}@x` : undefined; + await backend.insertNode({ + graphId: claimGraph.id, + kind: plant.kind, + id: plant.id, + props: + uniqueValue === undefined ? + { name: plant.id } + : { email: uniqueValue }, + }); + model.nodes.set(nodeKey(plant.kind, plant.id), { + kind: plant.kind, + id: plant.id, + ...(uniqueValue === undefined ? {} : { uniqueValue }), + deleted: false, + claimed: false, + }); + return; + } + case "plantUnique": { + const key = staffKey(plant.email); + const occupied = model.plantedUniques.some( + (row) => row.nodeKind === plant.nodeKind && row.key === key, + ); + if (occupied) return; + await backend.insertUnique({ + graphId: claimGraph.id, + nodeKind: plant.nodeKind, + constraintName: STAFF_EMAIL_CONSTRAINT, + key, + nodeId: plant.nodeId, + concreteKind: plant.concreteKind, + }); + model.plantedUniques.push({ + nodeKind: plant.nodeKind, + key, + concreteKind: plant.concreteKind, + nodeId: plant.nodeId, + }); + return; + } + } +} + +type Owner = Readonly<{ concreteKind: string; nodeId: string }>; + +function groupBy( + values: readonly T[], + keyOf: (value: T) => string, +): ReadonlyMap { + const groups = new Map(); + for (const value of values) { + const key = keyOf(value); + groups.set(key, [...(groups.get(key) ?? []), value]); + } + return groups; +} + +/** + * Every violation the audit must report, derived from the model's rows by the + * DECLARED rules rather than from the claim rows: two owners of one key on a + * uniqueness axis (store-written claims plus planted legacy-axis rows), one id + * live under both kinds of the disjoint pair, two members of one cardinality + * population, two wholes of one part. + */ +function expectedViolations(model: ClaimModel): readonly unknown[] { + const violations: unknown[] = []; + + const uniqueRows = [ + ...model + .expectedUniqueRows() + .filter((row) => row.constraint_name !== DISJOINT_CONSTRAINT_NAME) + .map((row) => ({ + axis: row.node_kind, + constraintName: row.constraint_name, + key: row.key, + owner: { concreteKind: row.concrete_kind, nodeId: row.node_id }, + })), + ...model.plantedUniques.map((row) => ({ + axis: STAFF_AXIS, + constraintName: STAFF_EMAIL_CONSTRAINT, + key: row.key, + owner: { concreteKind: row.concreteKind, nodeId: row.nodeId }, + })), + ]; + for (const rows of groupBy( + uniqueRows, + (row) => `${row.constraintName}\u0000${row.axis}\u0000${row.key}`, + ).values()) { + const owners = new Map( + rows.map((row) => [ + nodeKey(row.owner.concreteKind, row.owner.nodeId), + row.owner, + ]), + ); + if (owners.size < 2) continue; + const first = requireDefined(rows[0]); + violations.push({ + family: "nodeUniqueness", + target: { + relation: "uniques", + graphId: claimGraph.id, + axis: first.axis, + constraintName: first.constraintName, + key: first.key, + }, + owners: [...owners.values()], + }); + } + + for (const id of ID_POOL) { + if ( + model.liveNode(DISJOINT_PAIR[0], id) === undefined || + model.liveNode(DISJOINT_PAIR[1], id) === undefined + ) + continue; + violations.push({ + family: "nodeDisjointness", + target: { + relation: "uniques", + graphId: claimGraph.id, + axis: DISJOINT_AXIS, + constraintName: DISJOINT_CONSTRAINT_NAME, + key: id, + }, + owners: DISJOINT_PAIR.map((kind) => ({ concreteKind: kind, nodeId: id })), + }); + } + + const liveEdges = model.liveEdges(); + for (const edgeKind of Object.keys(EDGE_DECLARATIONS) as EdgeKind[]) { + for (const ref of edgeCardinalityAxisReferences( + EDGE_DECLARATIONS[edgeKind], + )) { + const members = liveEdges.filter( + (edge) => + edge.kind === edgeKind && + (ref.cardinality !== "oneActive" || !edge.ended), + ); + const keyed = groupBy(members, (edge) => + ref.direction === "source" ? edge.fromId : edge.toId, + ); + for (const group of keyed.values()) { + if (group.length < 2) continue; + const sample = requireDefined(group[0]); + violations.push({ + family: "edgeCardinality", + target: edgeCardinalityClaimTarget({ + ...ref, + graphId: claimGraph.id, + edgeKind, + edgeId: sample.id, + fromKind: sample.fromKind, + fromId: sample.fromId, + toKind: sample.toKind, + toId: sample.toId, + }), + edgeIds: group.map((edge) => edge.id), + }); + } + } + } + + const compositionMembers = liveEdges.filter( + (edge) => compositionPart(edge) !== undefined, + ); + for (const [part, group] of groupBy(compositionMembers, (edge) => + requireDefined(compositionPart(edge)), + )) { + if (group.length < 2) continue; + const [partKind, partId] = part.split("/"); + violations.push({ + family: "composition", + target: { + relation: "edgeClaims", + graphId: claimGraph.id, + axis: COMPOSITION_RELATION_NAME, + key: encodeTupleKey([requireDefined(partKind), requireDefined(partId)]), + }, + edgeIds: group.map((edge) => edge.id), + }); + } + return violations; +} + +/** A violation with every list in a canonical order, for set comparison. */ +function sortedIfList(list: unknown): unknown { + return Array.isArray(list) ? sortedJson(list as readonly unknown[]) : list; +} + +function canonicalViolation(violation: unknown): string { + const record = violation as Readonly>; + return JSON.stringify({ + ...record, + ...("owners" in record ? { owners: sortedIfList(record["owners"]) } : {}), + ...("edgeIds" in record ? + { edgeIds: sortedIfList(record["edgeIds"]) } + : {}), + }); +} + +function canonicalViolations( + violations: readonly unknown[], +): readonly string[] { + return violations + .map((violation) => canonicalViolation(violation)) + .toSorted((left, right) => compareStrings(left, right)); +} + +// ============================================================ +// Properties +// ============================================================ + +describe("constraint claims over random write sequences", () => { + it("accept exactly the writes the model admits and hold exactly the claims it owes", async () => { + await fc.assert( + fc.asyncProperty(commandsArb, async (commands) => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(claimGraph, backend); + const model = new ClaimModel(); + await seed(store, model); + await assertAgreesWithModel(store, backend, model); + await fc.asyncModelRun( + () => ({ model, real: { store, backend } }), + commands, + ); + }), + { numRuns: propertyRuns({ ci: 60, local: 120 }) }, + ); + }); + + it("report exactly the violations planted past the store", async () => { await fc.assert( fc.asyncProperty( - fc.array(stepArb, { minLength: 1, maxLength: 16 }), - async (steps) => { + fc.array(stepArb, { maxLength: 8 }), + fc.array(plantArb, { minLength: 1, maxLength: 4 }), + async (steps, plants) => { const backend = createTestBackend(); - const store = createStore(claimGraph, backend); - await seedStartingState(store); - const edgeIds = new Map(); - await assertConstraintsHold(store, backend); + const [store] = await createStoreWithSchema(claimGraph, backend); + const model = new ClaimModel(); + await seed(store, model); for (const step of steps) { - await applyStep(store, edgeIds, step); - await assertConstraintsHold(store, backend); + if (step.type === "transaction") continue; + await runStep(store, model, step); } + for (const plant of plants) await plantRow(backend, model, plant); + + expect( + canonicalViolations(await store.verifyConstraintFences()), + ).toEqual(canonicalViolations(expectedViolations(model))); }, ), - { numRuns: 60 }, + { numRuns: propertyRuns({ ci: 60, local: 120 }) }, ); }); }); diff --git a/packages/typegraph/tests/property/constraints.test.ts b/packages/typegraph/tests/property/constraints.test.ts index 4d14740bd..457a0287d 100644 --- a/packages/typegraph/tests/property/constraints.test.ts +++ b/packages/typegraph/tests/property/constraints.test.ts @@ -2,9 +2,7 @@ import fc from "fast-check"; import { describe, expect, it } from "vitest"; import { - checkCardinality, checkDisjointness, - checkUniqueEdge, checkWherePredicate, computeUniqueKey, } from "../../src/constraints"; @@ -13,6 +11,11 @@ import { createEmptyClosures, KindRegistry, } from "../../src/registry/kind-registry"; +import { + type EdgeCardinalityAxisRef, + type EdgeCardinalityDirection, + edgeCardinalityViolation, +} from "../../src/store/claims/edge-claims"; import { requireDefined } from "../../src/utils/presence"; // ============================================================ @@ -436,113 +439,106 @@ describe("checkWherePredicate Properties", () => { }); // ============================================================ -// Property Tests - checkCardinality +// Property Tests - edgeCardinalityViolation // ============================================================ -describe("checkCardinality Properties", () => { - describe("many cardinality", () => { - it("many never returns error", () => { - fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 0, max: 1000 }), - fc.boolean(), - (edgeKind, fromKind, fromId, count, hasActive) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "many", - count, - hasActive, - ); +describe("edgeCardinalityViolation Properties", () => { + const directionArb: fc.Arbitrary = fc.constantFrom( + "source", + "target", + ); + const endpointsArb = fc.record({ + edgeKind: edgeKindArb, + fromKind: kindNameArb, + fromId: nodeIdArb, + toKind: kindNameArb, + toId: nodeIdArb, + }); - expect(result).toBeUndefined(); - }, - ), - { numRuns: 50 }, - ); + describe("many is unrepresentable", () => { + // `EdgeCardinalityAxisRef` excludes `many` by construction + // (`ConstrainedCardinality` / `ConstrainedTargetCardinality`), so there is + // no runtime branch to exercise — the invariant is enforced by the type + // system alone. This is a type-level pin, not a unit test: it fails + // `pnpm typecheck` (the literals below stop needing `@ts-expect-error`) + // if either union is ever widened back to include `"many"`. + it('a ref naming "many" on either axis fails to typecheck', () => { + const sourceMany: EdgeCardinalityAxisRef = { + direction: "source", + // @ts-expect-error "many" is not a ConstrainedCardinality. + cardinality: "many", + }; + const targetMany: EdgeCardinalityAxisRef = { + direction: "target", + // @ts-expect-error "many" is not a ConstrainedTargetCardinality. + cardinality: "many", + }; + expect(sourceMany).toBeDefined(); + expect(targetMany).toBeDefined(); }); }); - describe("one cardinality", () => { - it("one allows zero existing edges", () => { + describe("one / oneActive, either direction", () => { + it("zero existing edges never violates", () => { fc.assert( fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - (edgeKind, fromKind, fromId) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "one", - 0, - false, - ); - + directionArb, + fc.constantFrom("one", "oneActive"), + endpointsArb, + (direction, cardinality, endpoints) => { + const ref = { direction, cardinality } as EdgeCardinalityAxisRef; + const result = edgeCardinalityViolation(ref, endpoints, 0); expect(result).toBeUndefined(); }, ), - { numRuns: 30 }, + { numRuns: 50 }, ); }); - it("one rejects any existing edges", () => { + it("any positive count violates, naming the declared direction", () => { fc.assert( fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 1, max: 100 }), - (edgeKind, fromKind, fromId, count) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "one", - count, - false, - ); + directionArb, + fc.constantFrom("one", "oneActive"), + endpointsArb, + fc.integer({ min: 1, max: 1000 }), + (direction, cardinality, endpoints, count) => { + const ref = { direction, cardinality } as EdgeCardinalityAxisRef; + const result = edgeCardinalityViolation(ref, endpoints, count); expect(result).toBeDefined(); - expect(requireDefined(result).message).toContain("one"); + const error = requireDefined(result); + expect(error.details.direction).toBe(direction); + expect(error.details.cardinality).toBe(cardinality); + expect(error.details.existingCount).toBe(count); + expect(error.details.fromKind).toBe(endpoints.fromKind); + expect(error.details.fromId).toBe(endpoints.fromId); + expect(error.details.toKind).toBe(endpoints.toKind); + expect(error.details.toId).toBe(endpoints.toId); + expect(error.message).toContain(cardinality); }, ), - { numRuns: 30 }, + { numRuns: 50 }, ); }); - it("one is monotonic: more edges = still error", () => { + it("is monotonic: a violating count stays a violation at a higher count", () => { fc.assert( fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, + directionArb, + fc.constantFrom("one", "oneActive"), + endpointsArb, fc.integer({ min: 1, max: 50 }), fc.integer({ min: 1, max: 50 }), - (edgeKind, fromKind, fromId, count1, count2) => { - const result1 = checkCardinality( - edgeKind, - fromKind, - fromId, - "one", - count1, - false, - ); - const result2 = checkCardinality( - edgeKind, - fromKind, - fromId, - "one", + (direction, cardinality, endpoints, count1, count2) => { + const ref = { direction, cardinality } as EdgeCardinalityAxisRef; + const result1 = edgeCardinalityViolation(ref, endpoints, count1); + const result2 = edgeCardinalityViolation( + ref, + endpoints, count1 + count2, - false, ); - // If error at count1, still error at count1 + count2 if (result1 === undefined) return; expect(result2).toBeDefined(); }, @@ -552,180 +548,39 @@ describe("checkCardinality Properties", () => { }); }); - describe("oneActive cardinality", () => { - it("oneActive allows when no active edge", () => { - fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 0, max: 100 }), - (edgeKind, fromKind, fromId, count) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "oneActive", - count, - false, // No active edge - ); + describe("source:unique", () => { + const ref: EdgeCardinalityAxisRef = { + direction: "source", + cardinality: "unique", + }; - expect(result).toBeUndefined(); - }, - ), - { numRuns: 30 }, - ); - }); - - it("oneActive rejects when active edge exists", () => { + it("allows when no existing pair", () => { fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - (edgeKind, fromKind, fromId) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "oneActive", - 1, - true, // Has active edge - ); - - expect(result).toBeDefined(); - expect(requireDefined(result).message).toContain("oneActive"); - }, - ), - { numRuns: 30 }, + fc.property(endpointsArb, (endpoints) => { + expect(edgeCardinalityViolation(ref, endpoints, 0)).toBeUndefined(); + }), + { numRuns: 50 }, ); }); - }); - describe("unique cardinality", () => { - it("unique passes through (checked separately)", () => { + it("rejects when the pair already exists", () => { fc.assert( fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 0, max: 100 }), - fc.boolean(), - (edgeKind, fromKind, fromId, count, hasActive) => { - const result = checkCardinality( - edgeKind, - fromKind, - fromId, - "unique", - count, - hasActive, - ); + endpointsArb, + fc.integer({ min: 1, max: 10 }), + (endpoints, count) => { + const result = edgeCardinalityViolation(ref, endpoints, count); - // unique is checked via checkUniqueEdge instead - expect(result).toBeUndefined(); + expect(result).toBeDefined(); + expect(requireDefined(result).message).toContain("unique"); }, ), - { numRuns: 30 }, + { numRuns: 50 }, ); }); }); }); -// ============================================================ -// Property Tests - checkUniqueEdge -// ============================================================ - -describe("checkUniqueEdge Properties", () => { - it("allows when no existing edge", () => { - fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - kindNameArb, - nodeIdArb, - (edgeKind, fromKind, fromId, toKind, toId) => { - const result = checkUniqueEdge( - edgeKind, - fromKind, - fromId, - toKind, - toId, - 0, // No existing - ); - - expect(result).toBeUndefined(); - }, - ), - { numRuns: 50 }, - ); - }); - - it("rejects when edge already exists", () => { - fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 1, max: 10 }), - (edgeKind, fromKind, fromId, toKind, toId, count) => { - const result = checkUniqueEdge( - edgeKind, - fromKind, - fromId, - toKind, - toId, - count, - ); - - expect(result).toBeDefined(); - expect(requireDefined(result).message).toContain("unique"); - }, - ), - { numRuns: 50 }, - ); - }); - - it("is monotonic: more edges = still error", () => { - fc.assert( - fc.property( - edgeKindArb, - kindNameArb, - nodeIdArb, - kindNameArb, - nodeIdArb, - fc.integer({ min: 1, max: 5 }), - fc.integer({ min: 1, max: 5 }), - (edgeKind, fromKind, fromId, toKind, toId, count1, count2) => { - const result1 = checkUniqueEdge( - edgeKind, - fromKind, - fromId, - toKind, - toId, - count1, - ); - const result2 = checkUniqueEdge( - edgeKind, - fromKind, - fromId, - toKind, - toId, - count1 + count2, - ); - - // If error at count1, still error at count1 + count2 - if (result1 === undefined) return; - expect(result2).toBeDefined(); - }, - ), - { numRuns: 30 }, - ); - }); -}); - // ============================================================ // Property Tests - checkDisjointness // ============================================================ diff --git a/packages/typegraph/tests/property/edge-acyclicity.test.ts b/packages/typegraph/tests/property/edge-acyclicity.test.ts new file mode 100644 index 000000000..a81c1da98 --- /dev/null +++ b/packages/typegraph/tests/property/edge-acyclicity.test.ts @@ -0,0 +1,735 @@ +/** + * The store's acyclicity verdicts must equal an in-memory DFS oracle. + * + * The first property probes one candidate edge against a random DAG — the + * case that catches an off-by-one in the reflexive seed or a wrong join + * direction. + * + * The second runs random write sequences — single creates, batches (whose + * rows may close a cycle only through each other), transactions of creates + * across edge kinds, soft deletes, window ends and reopens — over two acyclic + * relations sharing one node set: + * + * - the composition relation, the union of three realizing edge kinds walked + * part -> whole: `fcFolderOf` and `fcFiledIn` (part `from`) and + * `fcContains` (part `to`, the reversed member). Its `oneActive` rows count + * only while open, except `fcFiledIn`'s, which is also declared + * `acyclic: true` and so counts every non-deleted row; + * - the standalone `fcLinksTo` relation, which counts every non-deleted row + * and never combines with the composition relation. + * + * The oracle rebuilds both relations from a plain model of the rows and asks + * whether the write's resulting state has a cycle: the store must refuse + * exactly those writes with `EdgeAcyclicityError`, and a refused write must + * leave every row as it was. Writes that would be refused for another reason + * (a part that already has a whole) are not generated, so the acyclicity + * verdict is the only one under test. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + createStore, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + hasPart, + partOf, +} from "../../src"; +import { createLocalPgliteBackend } from "../../src/backend/postgres/pglite"; +import { type GraphBackend } from "../../src/backend/types"; +import { EdgeAcyclicityError } from "../../src/errors"; +import { type ValidityEndMutation } from "../../src/store/types"; +import { compareStrings } from "../../src/utils/compare"; +import { requireDefined } from "../../src/utils/presence"; +import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; + +const Node = defineNode("Node", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "property_edge_acyclicity", + nodes: { Node: { type: Node } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Node], + to: [Node], + acyclic: true, + }, + }, +}); + +/** A random DAG over node indices 0..nodeCount-1: every edge goes low -> high, so it is acyclic by construction. */ +const dagArbitrary = fc.integer({ min: 2, max: 12 }).chain((nodeCount) => + fc.record({ + nodeCount: fc.constant(nodeCount), + edges: fc.uniqueArray( + fc + .tuple( + fc.integer({ min: 0, max: nodeCount - 1 }), + fc.integer({ min: 0, max: nodeCount - 1 }), + ) + .filter(([from, to]) => from < to) + .map(([from, to]) => ({ from, to })), + { selector: (edge) => `${edge.from}\0${edge.to}` }, + ), + candidate: fc.tuple( + fc.integer({ min: 0, max: nodeCount - 1 }), + fc.integer({ min: 0, max: nodeCount - 1 }), + ), + }), +); + +type Arc = Readonly<{ from: number; to: number }>; + +/** DFS reachability oracle: does `to` reach `from` over `arcs`? */ +function reachesOracle( + arcs: readonly Arc[], + from: number, + to: number, +): boolean { + if (from === to) return true; + const adjacency = new Map(); + for (const arc of arcs) { + const targets = adjacency.get(arc.from) ?? []; + targets.push(arc.to); + adjacency.set(arc.from, targets); + } + const stack = [to]; + const seen = new Set([to]); + while (stack.length > 0) { + const current = stack.pop(); + if (current === undefined) break; + if (current === from) return true; + for (const next of adjacency.get(current) ?? []) { + if (seen.has(next)) continue; + seen.add(next); + stack.push(next); + } + } + return false; +} + +/** Whether `arcs` contain a directed cycle (a self-loop included). */ +function hasCycleOracle(arcs: readonly Arc[]): boolean { + return arcs.some((arc, index) => + reachesOracle( + arcs.filter((_, other) => other !== index), + arc.from, + arc.to, + ), + ); +} + +describe("edge acyclicity: property", () => { + it("agrees with a DFS reachability oracle over random DAGs and candidate edges", async () => { + await fc.assert( + fc.asyncProperty( + dagArbitrary, + async ({ nodeCount, edges, candidate }) => { + const backend = createTestBackend(); + const store = createStore(graph, backend); + const nodes = await store.nodes.Node.bulkCreate( + Array.from({ length: nodeCount }, () => ({ props: {} })), + ); + await store.edges.dependsOn.bulkCreate( + edges.map((edge) => ({ + from: requireDefined(nodes[edge.from]), + to: requireDefined(nodes[edge.to]), + })), + ); + + const [candidateFrom, candidateTo] = candidate; + const expectRefusal = reachesOracle( + edges, + candidateFrom, + candidateTo, + ); + + let refused = false; + try { + await store.edges.dependsOn.create( + requireDefined(nodes[candidateFrom]), + requireDefined(nodes[candidateTo]), + ); + } catch (error) { + if (!(error instanceof EdgeAcyclicityError)) throw error; + refused = true; + } + + expect(refused).toBe(expectRefusal); + }, + ), + { numRuns: propertyRuns({ ci: 60, local: 200 }) }, + ); + }, 60_000); +}); + +// ============================================================ +// Write sequences over a composition relation and a standalone relation +// ============================================================ + +const Folder = defineNode("Folder", { schema: z.object({}) }); +const fcFolderOf = defineEdge("fcFolderOf", { schema: z.object({}) }); +const fcFiledIn = defineEdge("fcFiledIn", { schema: z.object({}) }); +const fcContains = defineEdge("fcContains", { schema: z.object({}) }); +const fcLinksTo = defineEdge("fcLinksTo", { schema: z.object({}) }); + +function buildSequenceGraph(id: string) { + return defineGraph({ + id, + nodes: { Folder: { type: Folder } }, + edges: { + fcFolderOf: { + type: fcFolderOf, + from: [Folder], + to: [Folder], + cardinality: "oneActive", + }, + fcFiledIn: { + type: fcFiledIn, + from: [Folder], + to: [Folder], + cardinality: "oneActive", + acyclic: true, + }, + fcContains: { + type: fcContains, + from: [Folder], + to: [Folder], + targetCardinality: "oneActive", + }, + fcLinksTo: { + type: fcLinksTo, + from: [Folder], + to: [Folder], + acyclic: true, + }, + }, + ontology: [ + partOf(Folder, Folder, { via: fcFolderOf, partSide: "from" }), + partOf(Folder, Folder, { via: fcFiledIn, partSide: "from" }), + hasPart(Folder, Folder, { via: fcContains, partSide: "to" }), + ], + }); +} + +type SequenceGraph = ReturnType; + +const EDGE_KINDS = [ + "fcFolderOf", + "fcFiledIn", + "fcContains", + "fcLinksTo", +] as const; +type SequenceEdgeKind = (typeof EDGE_KINDS)[number]; + +const COMPOSITION_KINDS: ReadonlySet = new Set([ + "fcFolderOf", + "fcFiledIn", + "fcContains", +]); +/** Composition kinds whose ended rows still count: also declared `acyclic: true`. */ +const ALL_ROWS_COMPOSITION_KINDS: ReadonlySet = new Set([ + "fcFiledIn", +]); + +const FOLDER_COUNT = 5; +/** Every row's window starts here, so ending one at {@link ENDED_AT} is a valid past window. */ +const VALID_FROM = "1990-01-01T00:00:00.000Z"; +const ENDED_AT = "2000-01-01T00:00:00.000Z"; + +interface ModelEdge { + readonly id: string; + readonly kind: SequenceEdgeKind; + readonly from: number; + readonly to: number; + ended: boolean; + deleted: boolean; +} + +type Model = Readonly<{ edges: ModelEdge[] }>; + +type ProposedEdge = Readonly<{ + kind: SequenceEdgeKind; + from: number; + to: number; + ended: boolean; +}>; + +/** The part of a composition row: `from` for a part-`from` kind, `to` for the reversed `fcContains`. */ +function partIndex( + edge: Readonly<{ kind: SequenceEdgeKind; from: number; to: number }>, +): number { + return edge.kind === "fcContains" ? edge.to : edge.from; +} + +function wholeOf( + edge: Readonly<{ kind: SequenceEdgeKind; from: number; to: number }>, +): number { + return edge.kind === "fcContains" ? edge.from : edge.to; +} + +function compositionCounts(edge: ModelEdge): boolean { + return ( + COMPOSITION_KINDS.has(edge.kind) && + !edge.deleted && + (!edge.ended || ALL_ROWS_COMPOSITION_KINDS.has(edge.kind)) + ); +} + +/** Whether either relation, rebuilt from the model's rows, has a cycle. */ +function modelHasCycle(edges: readonly ModelEdge[]): boolean { + const composition = edges + .filter((edge) => compositionCounts(edge)) + .map((edge) => ({ from: partIndex(edge), to: wholeOf(edge) })); + const linksTo = edges + .filter((edge) => edge.kind === "fcLinksTo" && !edge.deleted) + .map((edge) => ({ from: edge.from, to: edge.to })); + return hasCycleOracle(composition) || hasCycleOracle(linksTo); +} + +/** The parts that hold a whole through an open composition row (the composition claim). */ +function heldParts(edges: readonly ModelEdge[]): Set { + return new Set( + edges + .filter( + (edge) => + COMPOSITION_KINDS.has(edge.kind) && !edge.deleted && !edge.ended, + ) + .map((edge) => partIndex(edge)), + ); +} + +/** + * Whether `proposed` claims no part twice and no part that already has a + * whole: the only shape in which the composition claim cannot refuse first. + */ +function claimsAreFree( + model: Model, + proposed: readonly ProposedEdge[], +): boolean { + const held = heldParts(model.edges); + for (const edge of proposed) { + if (!COMPOSITION_KINDS.has(edge.kind) || edge.ended) continue; + const part = partIndex(edge); + if (held.has(part)) return false; + held.add(part); + } + return true; +} + +const folderIndex = fc.integer({ min: 0, max: FOLDER_COUNT - 1 }); +const proposedEdgeArbitrary = (kind: fc.Arbitrary) => + fc.record({ + kind, + from: folderIndex, + to: folderIndex, + ended: fc.boolean(), + }); + +const anyKind = fc.constantFrom(...EDGE_KINDS); + +type Operation = + | Readonly<{ op: "create"; edge: ProposedEdge }> + | Readonly<{ + op: "bulkCreate"; + kind: SequenceEdgeKind; + edges: readonly ProposedEdge[]; + }> + | Readonly<{ op: "transaction"; edges: readonly ProposedEdge[] }> + | Readonly<{ op: "delete"; pick: number }> + | Readonly<{ op: "end"; pick: number }> + | Readonly<{ op: "reopen"; pick: number }>; + +const operationArbitrary: fc.Arbitrary = fc.oneof( + { + weight: 4, + arbitrary: proposedEdgeArbitrary(anyKind).map( + (edge) => ({ op: "create", edge }) as const, + ), + }, + { + weight: 2, + arbitrary: anyKind.chain((kind) => + fc + .array(proposedEdgeArbitrary(fc.constant(kind)), { + minLength: 2, + maxLength: 4, + }) + .map((edges) => ({ op: "bulkCreate", kind, edges }) as const), + ), + }, + { + weight: 1, + arbitrary: fc + .array( + proposedEdgeArbitrary(anyKind).map((edge) => ({ + ...edge, + ended: false, + })), + { minLength: 2, maxLength: 3 }, + ) + .map((edges) => ({ op: "transaction", edges }) as const), + }, + { + weight: 1, + arbitrary: fc.nat().map((pick) => ({ op: "delete", pick }) as const), + }, + { + weight: 1, + arbitrary: fc.nat().map((pick) => ({ op: "end", pick }) as const), + }, + { + weight: 2, + arbitrary: fc.nat().map((pick) => ({ op: "reopen", pick }) as const), + }, +); + +type SequenceStore = Awaited< + ReturnType> +>[0]; + +type FolderRef = Readonly<{ kind: "Folder"; id: string }>; + +function folderRef(index: number): FolderRef { + return { kind: "Folder", id: `folder-${index}` }; +} + +function edgeWindow(edge: ProposedEdge) { + return edge.ended ? + { validFrom: VALID_FROM, validTo: ENDED_AT } + : { validFrom: VALID_FROM }; +} + +function createOne( + store: + SequenceStore | Parameters[0]>[0], + edge: ProposedEdge, +): Promise> { + return store.edges[edge.kind].create( + folderRef(edge.from), + folderRef(edge.to), + {}, + edgeWindow(edge), + ); +} + +/** + * The id-taking writes for a row the model names by kind. Stored ids are + * branded per edge kind, so each kind's collection is reached by name. + */ +function edgeWrites( + store: SequenceStore, + kind: SequenceEdgeKind, +): Readonly<{ + delete: (id: string) => Promise; + update: (id: string, options: ValidityEndMutation) => Promise; +}> { + switch (kind) { + case "fcFolderOf": { + const edges = store.edges.fcFolderOf; + return { + delete: (id) => edges.delete(asEdgeId(id)), + update: (id, options) => + edges.update(asEdgeId(id), {}, options), + }; + } + case "fcFiledIn": { + const edges = store.edges.fcFiledIn; + return { + delete: (id) => edges.delete(asEdgeId(id)), + update: (id, options) => + edges.update(asEdgeId(id), {}, options), + }; + } + case "fcContains": { + const edges = store.edges.fcContains; + return { + delete: (id) => edges.delete(asEdgeId(id)), + update: (id, options) => + edges.update(asEdgeId(id), {}, options), + }; + } + case "fcLinksTo": { + const edges = store.edges.fcLinksTo; + return { + delete: (id) => edges.delete(asEdgeId(id)), + update: (id, options) => + edges.update(asEdgeId(id), {}, options), + }; + } + } +} + +/** Runs `write`, answering whether it was refused for closing a cycle. Any other error fails the property. */ +async function refusedForCycle( + write: () => Promise, +): Promise { + try { + await write(); + return false; + } catch (error) { + if (error instanceof EdgeAcyclicityError) return true; + throw error; + } +} + +/** Every stored row of every kind, as the model spells it. */ +async function storedRows(store: SequenceStore): Promise { + const rows: string[] = []; + for (const kind of EDGE_KINDS) { + const edges = await store.edges[kind].find( + {}, + { temporalMode: "includeTombstones" }, + ); + for (const edge of edges) { + rows.push( + describeRow({ + id: edge.id, + kind, + from: Number(edge.fromId.slice("folder-".length)), + to: Number(edge.toId.slice("folder-".length)), + ended: edge.meta.validTo !== undefined, + deleted: edge.meta.deletedAt !== undefined, + }), + ); + } + } + return rows.toSorted(compareStrings); +} + +function describeRow(edge: ModelEdge): string { + const state = + edge.deleted ? "deleted" + : edge.ended ? "ended" + : "open"; + return `${edge.kind}:${edge.id} ${edge.from}->${edge.to} ${state}`; +} + +function modelRows(model: Model): readonly string[] { + return model.edges.map((edge) => describeRow(edge)).toSorted(compareStrings); +} + +function newModelEdge(id: string, edge: ProposedEdge): ModelEdge { + return { id, ...edge, deleted: false }; +} + +/** + * Applies `operation` to the store and the model. Returns a description of + * the step for a failure message, or `undefined` when the operation does not + * apply to the current state (and was skipped). + */ +async function applyOperation( + store: SequenceStore, + model: Model, + operation: Operation, +): Promise { + switch (operation.op) { + case "create": { + if (!claimsAreFree(model, [operation.edge])) return undefined; + const expectRefusal = modelHasCycle([ + ...model.edges, + newModelEdge("proposed", operation.edge), + ]); + let created: Readonly<{ id: string }> | undefined; + const refused = await refusedForCycle(async () => { + created = await createOne(store, operation.edge); + }); + expect({ + step: `create ${JSON.stringify(operation.edge)}`, + refused, + }).toEqual({ + step: `create ${JSON.stringify(operation.edge)}`, + refused: expectRefusal, + }); + if (created !== undefined) { + model.edges.push(newModelEdge(created.id, operation.edge)); + } + return `create ${JSON.stringify(operation.edge)}`; + } + case "bulkCreate": { + if (!claimsAreFree(model, operation.edges)) return undefined; + const expectRefusal = modelHasCycle([ + ...model.edges, + ...operation.edges.map((edge, index) => + newModelEdge(`proposed-${index}`, edge), + ), + ]); + let created: readonly Readonly<{ id: string }>[] = []; + const refused = await refusedForCycle(async () => { + created = await store.edges[operation.kind].bulkCreate( + operation.edges.map((edge) => ({ + from: folderRef(edge.from), + to: folderRef(edge.to), + ...edgeWindow(edge), + })), + ); + }); + expect({ + step: `bulkCreate ${JSON.stringify(operation.edges)}`, + refused, + }).toEqual({ + step: `bulkCreate ${JSON.stringify(operation.edges)}`, + refused: expectRefusal, + }); + for (const [index, edge] of created.entries()) { + model.edges.push( + newModelEdge(edge.id, requireDefined(operation.edges[index])), + ); + } + return `bulkCreate ${JSON.stringify(operation.edges)}`; + } + case "transaction": { + if (!claimsAreFree(model, operation.edges)) return undefined; + const expectRefusal = modelHasCycle([ + ...model.edges, + ...operation.edges.map((edge, index) => + newModelEdge(`proposed-${index}`, edge), + ), + ]); + const created: Readonly<{ id: string }>[] = []; + const refused = await refusedForCycle(() => + store.transaction(async (tx) => { + for (const edge of operation.edges) { + created.push(await createOne(tx, edge)); + } + }), + ); + expect({ + step: `transaction ${JSON.stringify(operation.edges)}`, + refused, + }).toEqual({ + step: `transaction ${JSON.stringify(operation.edges)}`, + refused: expectRefusal, + }); + if (!refused) { + for (const [index, edge] of created.entries()) { + model.edges.push( + newModelEdge(edge.id, requireDefined(operation.edges[index])), + ); + } + } + return `transaction ${JSON.stringify(operation.edges)}`; + } + case "delete": { + const live = model.edges.filter((edge) => !edge.deleted); + const target = live[operation.pick % Math.max(live.length, 1)]; + if (target === undefined) return undefined; + await edgeWrites(store, target.kind).delete(target.id); + target.deleted = true; + return `delete ${describeRow(target)}`; + } + case "end": { + const open = model.edges.filter( + (edge) => + !edge.deleted && !edge.ended && COMPOSITION_KINDS.has(edge.kind), + ); + const target = open[operation.pick % Math.max(open.length, 1)]; + if (target === undefined) return undefined; + await edgeWrites(store, target.kind).update(target.id, { + validTo: ENDED_AT, + }); + target.ended = true; + return `end ${describeRow(target)}`; + } + case "reopen": { + const ended = model.edges.filter( + (edge) => + !edge.deleted && edge.ended && COMPOSITION_KINDS.has(edge.kind), + ); + const target = ended[operation.pick % Math.max(ended.length, 1)]; + if (target === undefined) return undefined; + const reopened = { ...target, ended: false }; + if (!claimsAreFree(model, [reopened])) return undefined; + const expectRefusal = modelHasCycle( + model.edges.map((edge) => (edge === target ? reopened : edge)), + ); + const refused = await refusedForCycle(() => + edgeWrites(store, target.kind).update(target.id, { + clearValidTo: true, + }), + ); + expect({ step: `reopen ${describeRow(target)}`, refused }).toEqual({ + step: `reopen ${describeRow(target)}`, + refused: expectRefusal, + }); + if (!refused) target.ended = false; + return `reopen ${describeRow(target)}`; + } + } +} + +let sequenceGraphCounter = 0; + +async function runSequence( + backend: GraphBackend, + operations: readonly Operation[], +): Promise { + sequenceGraphCounter += 1; + const [store] = await createStoreWithSchema( + buildSequenceGraph(`property_acyclic_sequence_${sequenceGraphCounter}`), + backend, + ); + await store.nodes.Folder.bulkCreate( + Array.from({ length: FOLDER_COUNT }, (_, index) => ({ + id: folderRef(index).id, + props: {}, + })), + ); + const model: Model = { edges: [] }; + const steps: string[] = []; + for (const operation of operations) { + const step = await applyOperation(store, model, operation); + if (step === undefined) continue; + steps.push(step); + expect({ steps, rows: await storedRows(store) }).toEqual({ + steps, + rows: modelRows(model), + }); + } + expect({ steps, violations: await store.verifyConstraintFences() }).toEqual({ + steps, + violations: [], + }); +} + +const sequenceArbitrary = fc.array(operationArbitrary, { + minLength: 4, + maxLength: 16, +}); + +describe("edge acyclicity: write sequences over a composition and a standalone relation", () => { + it("refuses exactly the writes whose resulting state has a cycle (SQLite)", async () => { + await fc.assert( + fc.asyncProperty(sequenceArbitrary, async (operations) => { + const backend = createTestBackend(); + try { + await runSequence(backend, operations); + } finally { + await backend.close(); + } + }), + { numRuns: propertyRuns({ ci: 150, local: 300 }) }, + ); + }, 120_000); + + it("refuses exactly the writes whose resulting state has a cycle (PGlite)", async () => { + const { backend } = await createLocalPgliteBackend({ vector: false }); + try { + await fc.assert( + fc.asyncProperty(sequenceArbitrary, (operations) => + runSequence(backend, operations), + ), + { numRuns: propertyRuns({ ci: 25, local: 50 }) }, + ); + } finally { + await backend.close(); + } + }, 180_000); +}); diff --git a/packages/typegraph/tests/property/graph-merge/candidate-bounded.test.ts b/packages/typegraph/tests/property/graph-merge/candidate-bounded.test.ts index bd8189c07..0ea3429d7 100644 --- a/packages/typegraph/tests/property/graph-merge/candidate-bounded.test.ts +++ b/packages/typegraph/tests/property/graph-merge/candidate-bounded.test.ts @@ -171,7 +171,7 @@ describe("bounded candidate planning", () => { ); expect(isErr(bounded)).toBe(false); if (!isErr(bounded)) - expect(canonicalMergePlanJson(bounded.data)).toBe( + expect(canonicalMergePlanJson(bounded.data.plan)).toBe( canonicalMergePlanJson(expected), ); } finally { @@ -305,7 +305,7 @@ describe("bounded candidate planning", () => { successfulPlans += 1; expect(isErr(bounded)).toBe(false); if (!isErr(bounded)) - expect(canonicalMergePlanJson(bounded.data)).toBe( + expect(canonicalMergePlanJson(bounded.data.plan)).toBe( canonicalMergePlanJson(unwrap(fullPlan)), ); } @@ -445,7 +445,7 @@ describe("bounded candidate planning", () => { successfulPlans += 1; expect(isErr(bounded)).toBe(false); if (!isErr(bounded)) - expect(canonicalMergePlanJson(bounded.data)).toBe( + expect(canonicalMergePlanJson(bounded.data.plan)).toBe( canonicalMergePlanJson(unwrap(fullPlan)), ); } diff --git a/packages/typegraph/tests/property/graph-merge/identity-laws.test.ts b/packages/typegraph/tests/property/graph-merge/identity-laws.test.ts index 08812bac5..5cb244809 100644 --- a/packages/typegraph/tests/property/graph-merge/identity-laws.test.ts +++ b/packages/typegraph/tests/property/graph-merge/identity-laws.test.ts @@ -64,7 +64,7 @@ import { defineNode, } from "@nicia-ai/typegraph"; import fc from "fast-check"; -import { afterAll, describe, expect, it } from "vitest"; +import { afterAll, describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { asEdgeId, asNodeId } from "../../../src/core/types"; @@ -80,7 +80,12 @@ import { BaseVersionMismatchError, IdentityMergeConflictError, } from "../../../src/graph-merge/errors"; -import { merge, mergeIncremental } from "../../../src/graph-merge/merge"; +import { + merge, + mergeIncremental, + planMerge, + planMergeIncremental, +} from "../../../src/graph-merge/merge"; import { assertionTruthKey, DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, @@ -98,6 +103,13 @@ import { canonicalizeDatabaseTimestamp } from "../../../src/utils/date"; import { requireDefined } from "../../../src/utils/presence"; import { createPgliteFixturePool } from "../../graph-merge/pglite-fixture-pool"; import { backendMatrix } from "../../graph-merge/test-utils"; +import { propertyRuns } from "../run-budget"; +import { + modelIdentityThreeWay, + type ModelRefusalRule, + type ModelRow, + type ModelVerdict, +} from "./identity-three-way-model"; const Agent = defineNode("Agent", { schema: z.object({ name: z.string() }), @@ -158,13 +170,26 @@ const BRANCH_B = asBranchId("identity-law-b"); const TARGET_CLONE = asBranchId("identity-law-target"); /** - * fast-check iterations PER LANE. Each iteration builds a fork point plus two - * branch stores (the incremental lane adds a target clone) and runs one - * merge. NOTE: fast-check consumes pinned `examples` OUT OF this budget, so - * the randomized count is `LAW_RUNS - examples.length`; the budget is sized - * with that in mind (the full SQLite matrix runs in a few seconds). + * RANDOMIZED fast-check iterations PER LANE. Each iteration builds a fork point + * plus two branch stores (the incremental lane adds a target clone) and runs + * one merge. fast-check consumes pinned `examples` out of `numRuns`, so every + * lane passes {@link withExamples} to keep this many randomized runs on top of + * its pinned ones. + */ +const LAW_RUNS = propertyRuns({ ci: 12, local: 24 }); +/** + * PGlite is the parity lane, not the coverage lane: each run costs several + * times a SQLite run, so it draws fewer randomized runs and SQLite carries the + * randomized search. Pinned examples run on both. */ -const LAW_RUNS = process.env["CI"] ? 12 : 24; +const PGLITE_LAW_RUNS = propertyRuns({ ci: 7, local: 24 }); +/** Randomized runs per reference-model lane; identity-only scenarios are cheap. */ +const MODEL_RUNS = propertyRuns({ ci: 30, local: 60 }); +const PGLITE_MODEL_RUNS = propertyRuns({ ci: 4, local: 20 }); + +function withExamples(examples: readonly T[], randomized: number) { + return { examples: [...examples], numRuns: randomized + examples.length }; +} type IdentityOp = | Readonly<{ op: "same"; left: number; right: number }> @@ -227,7 +252,12 @@ interface WeightedOp { arbitrary: fc.Arbitrary; } -const ASSERTION_OPS: readonly WeightedOp[] = [ +/** + * The identity-only alphabet: assertions, retractions and chosen-id imports. + * The reference-model lanes draw from this alone, because it is the scope the + * model in `identity-three-way-model.ts` decides. + */ +const IDENTITY_OPS: readonly WeightedOp[] = [ { weight: 3, arbitrary: pairArb.map( @@ -246,6 +276,29 @@ const ASSERTION_OPS: readonly WeightedOp[] = [ .nat({ max: 7 }) .map((pick) => ({ op: "retract", pick }) as const), }, + { + weight: 3, + arbitrary: fc + .tuple( + fc.nat({ max: IMPORT_ID_POOL.length - 1 }), + pairArb, + fc.nat({ max: IMPORT_VALID_FROMS.length - 1 }), + ) + .map( + ([idPick, [left, right], validFromPick]) => + ({ + op: "importAssertion", + idPick, + left, + right, + validFromPick, + }) as const, + ), + }, +]; + +const ASSERTION_OPS: readonly WeightedOp[] = [ + ...IDENTITY_OPS, { weight: 1, arbitrary: nodeIndexArb.map( @@ -269,25 +322,6 @@ const ASSERTION_OPS: readonly WeightedOp[] = [ ({ op: "endEdgeValidity", edge, endPick }) as const, ), }, - { - weight: 3, - arbitrary: fc - .tuple( - fc.nat({ max: IMPORT_ID_POOL.length - 1 }), - pairArb, - fc.nat({ max: IMPORT_VALID_FROMS.length - 1 }), - ) - .map( - ([idPick, [left, right], validFromPick]) => - ({ - op: "importAssertion", - idPick, - left, - right, - validFromPick, - }) as const, - ), - }, ]; const HARD_DELETE_OP: WeightedOp = { @@ -1280,6 +1314,280 @@ async function expectLawfulOutcome(args: { } } +/** + * A scenario the reference model decides: identity-only histories on every + * side. `targetOps` is empty on the snapshot lane, where the target IS the + * fork point. + */ +type ModelScenario = Readonly<{ + baseOps: readonly IdentityOp[]; + targetOps: readonly IdentityOp[]; + branchAOps: readonly IdentityOp[]; + branchBOps: readonly IdentityOp[]; +}>; + +const identityOpArb = fc.oneof(...IDENTITY_OPS); + +function modelScenarioArb(withTarget: boolean): fc.Arbitrary { + return fc.record({ + baseOps: fc.array(identityOpArb, { maxLength: 2 }), + targetOps: + withTarget ? fc.array(identityOpArb, { maxLength: 2 }) : fc.constant([]), + branchAOps: fc.array(identityOpArb, { minLength: 1, maxLength: 3 }), + branchBOps: fc.array(identityOpArb, { minLength: 1, maxLength: 3 }), + }); +} + +const NO_MODEL_OPS: readonly IdentityOp[] = []; + +/** + * Two branches import different ids for one pair at one `validFrom`: only the + * id order picks the survivor, and it must be the smaller id. + */ +const SURVIVOR_ID_TIE_EXAMPLE: ModelScenario = { + baseOps: NO_MODEL_OPS, + targetOps: NO_MODEL_OPS, + branchAOps: [ + { op: "importAssertion", idPick: 1, left: 0, right: 1, validFromPick: 0 }, + ], + branchBOps: [ + { op: "importAssertion", idPick: 0, left: 0, right: 1, validFromPick: 0 }, + ], +}; + +/** Both branches retract one inherited row; it ends at the earlier instant. */ +const EARLIEST_RETRACTION_EXAMPLE: ModelScenario = { + baseOps: [{ op: "same", left: 0, right: 1 }], + targetOps: NO_MODEL_OPS, + branchAOps: [{ op: "retract", pick: 0 }], + branchBOps: [{ op: "retract", pick: 0 }], +}; + +/** + * A `same` one branch asserted and retracted, against a `different` the other + * branch asserted after it ended: the windows never overlap, so this merges. + */ +const DISJOINT_WINDOWS_EXAMPLE: ModelScenario = { + baseOps: NO_MODEL_OPS, + targetOps: NO_MODEL_OPS, + branchAOps: [ + { op: "same", left: 0, right: 1 }, + { op: "retract", pick: 0 }, + ], + branchBOps: [{ op: "different", left: 0, right: 1 }], +}; + +/** One branch retracts an inherited row and reasserts its pair: convergent. */ +const CONVERGENT_REASSERT_EXAMPLE: ModelScenario = { + baseOps: [{ op: "same", left: 0, right: 1 }], + targetOps: NO_MODEL_OPS, + branchAOps: [ + { op: "retract", pick: 0 }, + { op: "same", left: 0, right: 1 }, + ], + branchBOps: [{ op: "same", left: 2, right: 3 }], +}; + +/** + * Both branches retract an inherited row; one also reasserted the pair and + * retracted that too. Nobody holds the pair now, so this is no race. + */ +const ENDED_REASSERTION_EXAMPLE: ModelScenario = { + baseOps: [{ op: "same", left: 0, right: 1 }], + targetOps: NO_MODEL_OPS, + branchAOps: [{ op: "retract", pick: 0 }], + branchBOps: [ + { op: "retract", pick: 0 }, + { op: "same", left: 0, right: 1 }, + { op: "retract", pick: 1 }, + ], +}; + +/** + * Both branches retract an inherited row and only one reasserts its pair: the + * branches disagree about whether the pair holds, a retract/reassert race. + */ +const RACE_EXAMPLE: ModelScenario = { + baseOps: [{ op: "same", left: 0, right: 1 }], + targetOps: NO_MODEL_OPS, + branchAOps: [{ op: "retract", pick: 0 }], + branchBOps: [ + { op: "retract", pick: 0 }, + { op: "same", left: 0, right: 1 }, + ], +}; + +/** + * The advanced target imported an id that one branch imports with the exact + * same truth while the other branch imports an EARLIER-valid id for the pair: + * the committed id survives regardless of the comparator. + */ +const COMMITTED_SURVIVOR_EXAMPLE: ModelScenario = { + baseOps: NO_MODEL_OPS, + targetOps: [ + { op: "importAssertion", idPick: 1, left: 0, right: 1, validFromPick: 1 }, + ], + branchAOps: [ + { op: "importAssertion", idPick: 1, left: 0, right: 1, validFromPick: 1 }, + ], + branchBOps: [ + { op: "importAssertion", idPick: 0, left: 0, right: 1, validFromPick: 0 }, + ], +}; + +async function modelRows( + store: Store, + ids: readonly string[], +): Promise> { + if (ids.length === 0) return new Map(); + return storeRuntime(store).identityAssertionRowsByIds(ids); +} + +function rowSummary(row: ModelRow): string { + return `${row.id} ${row.relation}(${row.a.id},${row.b.id}) from ${row.validFrom}${ + row.validTo === undefined ? "" : ` to ${row.validTo}` + }`; +} + +/** The identity part of a plan, for comparing two branch orders. */ +function identityPlanFingerprint( + planned: Awaited>, +): unknown { + if (isErr(planned)) { + return { refused: planned.error.constructor.name }; + } + const writes = planned.data.writes; + const byId = ( + left: Readonly<{ id: string }>, + right: Readonly<{ id: string }>, + ) => compareStrings(left.id, right.id); + return { + assertions: writes.identityAssertions.toSorted(byId), + retractions: writes.identityRetractions.toSorted(byId), + dropped: planned.data.review.dropped + .filter( + (item) => + typeof item === "object" && + item !== null && + !Array.isArray(item) && + (item as Readonly>)["kind"] === "identity", + ) + .map((item) => JSON.stringify(item)) + .toSorted(), + }; +} + +/** The refusal message each model rule must surface as. */ +const REFUSAL_MESSAGES: Readonly> = { + opposing: + "Branches asserted opposing identity relations for one endpoint pair.", + race: "Branches contain a retract/reassert race for one identity pair.", + "one-id-two-truths": + "One assertion id was staged for two different identity truths.", + contradiction: + "The merged identity ledger would assert one pair of nodes is both the same and different.", + "id-held-other-truth": + "A staged assertion id already identifies different truth in the target ledger.", +}; + +/** + * The model lanes' clock: faked, and moved forward before every operation, + * so every window is distinct and the outcome never depends on how many + * writes landed in one millisecond. + */ +const MODEL_CLOCK_START = Date.parse("2026-01-01T00:00:00.000Z"); +const MODEL_CLOCK_STEP_MS = 60_000; + +function tickModelClock(): void { + vi.setSystemTime(Date.now() + MODEL_CLOCK_STEP_MS); +} + +interface ModelTally { + merged: number; + refused: number; + undetermined: number; +} + +/** + * Compares a finished merge to the reference model's verdict: a refusal the + * model predicts must be a typed refusal planning already makes, and a merge + * the model accepts MUST + * succeed — the success floor a refuse-everything regression fails — and land + * exactly the ledger the model computed. + */ +async function expectModelOutcome(args: { + verdict: ModelVerdict; + planned: Awaited>; + result: Awaited>; + target: Store; + trackedIds: readonly string[]; + tally: ModelTally; +}): Promise { + const { verdict, planned, result, target, trackedIds, tally } = args; + if (verdict.kind === "undetermined") { + tally.undetermined += 1; + if (isErr(result)) expectTypedRefusal(result.error); + return; + } + if (verdict.kind === "refused") { + tally.refused += 1; + // Every refusal the model knows is a plan-time rule: review must already + // see it, never only the commit. + expect( + isErr(planned) && planned.error.message, + `the model refuses by ${verdict.rule} (${verdict.reason}); planning answered otherwise`, + ).toBe(REFUSAL_MESSAGES[verdict.rule]); + expect( + isErr(result) && result.error.message, + `the model refuses by ${verdict.rule} (${verdict.reason}); the merge answered otherwise`, + ).toBe(REFUSAL_MESSAGES[verdict.rule]); + if (isErr(result)) { + expect(result.error).toBeInstanceOf(IdentityMergeConflictError); + } + return; + } + tally.merged += 1; + if (isErr(result)) { + throw new Error( + `the model merges this scenario but the merge refused: ${result.error.constructor.name}: ${result.error.message}`, + { cause: result.error }, + ); + } + const after = await modelRows(target, trackedIds); + const currentAfter = [...after.values()] + .filter((row) => row.validTo === undefined) + .map((row) => rowSummary(row)) + .toSorted(); + expect(currentAfter, "current ledger after the merge").toEqual( + [...verdict.current.values()].map((row) => rowSummary(row)).toSorted(), + ); + for (const [id, end] of verdict.ended) { + expect( + after.get(id)?.validTo, + `retracted ${id} ends at the earliest claim`, + ).toBe(end); + } + for (const [id, row] of verdict.history) { + expect( + after.get(id) === undefined ? + undefined + : rowSummary(requireDefined(after.get(id))), + `bounded survivor ${id} is carried as history`, + ).toBe(rowSummary(row)); + } + const reportedDuplicates = result.data.dropped + .filter( + (item) => + item.kind === "identity" && + item.reason === DUPLICATE_IDENTITY_ASSERTION_DROP_REASON, + ) + .map((item) => item.id) + .toSorted(); + expect(reportedDuplicates, "ids reported dropped as duplicates").toEqual( + [...verdict.duplicates].toSorted(), + ); +} + // Each property iteration releases whole fixture schemas, including identity // ledgers. Live branches keep separate engines and independent transactions. const pglitePool = createPgliteFixturePool(); @@ -1288,6 +1596,7 @@ afterAll(async () => { }); describe.each(backendMatrix())("identity merge laws [$name]", (entry) => { + const lawRuns = entry.name === "PGlite" ? PGLITE_LAW_RUNS : LAW_RUNS; type Fixture = Readonly<{ forkPoint: Store; branchA: Awaited>["branchA"]; @@ -1426,16 +1735,16 @@ describe.each(backendMatrix())("identity merge laws [$name]", (entry) => { for (const cleanup of cleanups.reverse()) await cleanup(); } }), - { - examples: [ + withExamples( + [ [VALIDITY_COLLISION_EXAMPLE], [TRUTH_REPLACEMENT_EXAMPLE], [REPEATED_RESURRECTION_WINDOW_EXAMPLE], [NODE_END_TIE_BREAK_EXAMPLE], [EDGE_END_TIE_BREAK_EXAMPLE], ], - numRuns: LAW_RUNS, - }, + lawRuns, + ), ); // Coverage instrumentation and property shrinking can multiply the // per-iteration workload even with pooled engines. Keep the extended @@ -1542,14 +1851,179 @@ describe.each(backendMatrix())("identity merge laws [$name]", (entry) => { for (const cleanup of cleanups.reverse()) await cleanup(); } }), - { - examples: [ + withExamples( + [ [RETRACTION_MISMATCH_EXAMPLE], [TARGET_WINDOW_PRECEDENCE_EXAMPLE], [TARGET_EDGE_WINDOW_PRECEDENCE_EXAMPLE], ], - numRuns: LAW_RUNS, - }, + lawRuns, + ), ); }, 300_000); + + /** + * The reference-model lanes: identity-only histories, the merged ledger + * compared to `modelIdentityThreeWay`, and the identity plan required to be + * the same under both branch orders. + */ + const MODEL_LANES = [ + { + name: "snapshot", + incremental: false, + graph: defineLawGraph("identity-law-model", "ignore"), + examples: [ + SURVIVOR_ID_TIE_EXAMPLE, + EARLIEST_RETRACTION_EXAMPLE, + DISJOINT_WINDOWS_EXAMPLE, + CONVERGENT_REASSERT_EXAMPLE, + ENDED_REASSERTION_EXAMPLE, + RACE_EXAMPLE, + ], + }, + { + name: "incremental", + incremental: true, + graph: defineLawGraph("identity-law-model-incremental", "ignore"), + examples: [COMMITTED_SURVIVOR_EXAMPLE, EARLIEST_RETRACTION_EXAMPLE], + }, + ] as const; + + for (const lane of MODEL_LANES) { + it(`merges random identity-only ${lane.name} histories as the reference model predicts, whatever the branch order`, async () => { + const tally: ModelTally = { merged: 0, refused: 0, undetermined: 0 }; + await fc.assert( + fc.asyncProperty( + modelScenarioArb(lane.incremental), + async (scenario) => { + const cleanups: (() => Promise)[] = []; + async function makeBackend(): Promise { + const fixture = + entry.name === "PGlite" ? + await pglitePool.makeFixture() + : await entry.make(); + cleanups.push(fixture.cleanup); + return fixture.backend; + } + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(MODEL_CLOCK_START); + try { + const [forkPoint] = await createStoreWithSchema( + lane.graph, + await makeBackend(), + { revisionTracking: true }, + ); + await seedUniverse(forkPoint); + const baseHistory = emptyHistory(); + for (const op of scenario.baseOps) { + tickModelClock(); + await applyOp(forkPoint, op, baseHistory); + } + const target = + lane.incremental ? + unwrap( + await branch(forkPoint, () => makeBackend(), { + id: TARGET_CLONE, + }), + ).store + : forkPoint; + const targetHistory = copyHistory(baseHistory); + for (const op of scenario.targetOps) { + tickModelClock(); + await applyOp(target, op, targetHistory); + } + const { branchA, branchB } = await makeBranches( + forkPoint, + makeBackend, + ); + const branchAHistory = copyHistory(baseHistory); + const branchBHistory = copyHistory(baseHistory); + for (const op of scenario.branchAOps) { + tickModelClock(); + await applyOp(branchA.store, op, branchAHistory); + } + for (const op of scenario.branchBOps) { + tickModelClock(); + await applyOp(branchB.store, op, branchBHistory); + } + tickModelClock(); + const trackedIds = [ + ...new Set( + [branchAHistory, branchBHistory, targetHistory].flatMap( + (history) => history.assertionIds, + ), + ), + ]; + const verdict = modelIdentityThreeWay({ + fork: await modelRows(forkPoint, trackedIds), + target: await modelRows(target, trackedIds), + sides: [ + { + branchId: BRANCH_A, + rows: await modelRows(branchA.store, trackedIds), + }, + { + branchId: BRANCH_B, + rows: await modelRows(branchB.store, trackedIds), + }, + ...(lane.incremental ? + [ + { + branchId: TARGET_CLONE, + rows: await modelRows(target, trackedIds), + }, + ] + : []), + ], + }); + + const plan = (branchOrder: readonly BranchId[]) => + lane.incremental ? + planMergeIncremental({ + forkPoint, + target, + branches: [branchA, branchB], + options: { branchOrder }, + }) + : planMerge(forkPoint, [branchA, branchB], { branchOrder }); + const planned = await plan([BRANCH_A, BRANCH_B]); + expect( + identityPlanFingerprint(await plan([BRANCH_B, BRANCH_A])), + "the identity plan depends on the branch order", + ).toEqual(identityPlanFingerprint(planned)); + + const result = + lane.incremental ? + await mergeIncremental({ + forkPoint, + target, + branches: [branchA, branchB], + options: { branchOrder: [BRANCH_A, BRANCH_B] }, + }) + : await merge(forkPoint, [branchA, branchB], { + branchOrder: [BRANCH_A, BRANCH_B], + }); + await expectModelOutcome({ + verdict, + planned, + result, + target, + trackedIds, + tally, + }); + } finally { + vi.useRealTimers(); + for (const cleanup of cleanups.reverse()) await cleanup(); + } + }, + ), + withExamples( + lane.examples.map((example) => [example] as const), + entry.name === "PGlite" ? PGLITE_MODEL_RUNS : MODEL_RUNS, + ), + ); + console.info(`identity model lane ${lane.name} [${entry.name}]`, tally); + expect(tally.merged, "no scenario merged").toBeGreaterThan(0); + }, 300_000); + } }); diff --git a/packages/typegraph/tests/property/graph-merge/identity-three-way-model.ts b/packages/typegraph/tests/property/graph-merge/identity-three-way-model.ts new file mode 100644 index 000000000..7e015b67d --- /dev/null +++ b/packages/typegraph/tests/property/graph-merge/identity-three-way-model.ts @@ -0,0 +1,378 @@ +/** + * A reference model of the identity three-way merge, written from the + * documented rules rather than from `identity-three-way.ts`, so the law suite + * can compare the merged ledger to an independent expectation instead of only + * checking that whatever happened was self-consistent. + * + * Scope: identity-only branch histories (assert same/different, retract, + * import an assertion under a chosen id) over Agent-only endpoints, so no node + * deletion, canonicalization or same-id fold enters the picture. Inside that + * scope the model decides, from the ledgers each side holds at merge time: + * + * - STAGING: each side's change since the fork — on an incremental merge the + * advanced target is a side too, so its own new rows meet the branches'. + * - REFUSAL: opposing relations staged for one pair with overlapping windows; + * a retract/reassert race (the branches retracting a pair are not exactly + * the branches reasserting it with a current row); one id staged for two + * truths, or a surviving id the target holds with other truth; or a merged + * ledger — the target's own history included — that, at some instant, + * holds two nodes both same and different. + * - SURVIVOR: per dedupe group, an id the target already holds with the exact + * staged truth, else the earliest `validFrom`, ties to the smaller id. + * - RETRACTION: a target row several branches ended ends at the EARLIEST of + * their instants. + * - RESULT: the target's current rows minus the retracted ones, plus every + * current survivor; bounded survivors land as history rows. + */ + +type ModelRef = Readonly<{ kind: string; id: string }>; + +export type ModelRow = Readonly<{ + id: string; + relation: "same" | "different"; + a: ModelRef; + b: ModelRef; + validFrom: string; + validTo?: string | undefined; +}>; + +type ModelSide = Readonly<{ + branchId: string; + /** Every tracked row the side holds, ended rows included. */ + rows: ReadonlyMap; +}>; + +export type ModelInput = Readonly<{ + /** The fork point's rows — the ancestor every side is diffed against. */ + fork: ReadonlyMap; + /** The merge target's rows (the fork point itself on a snapshot merge). */ + target: ReadonlyMap; + /** + * The sides whose changes since the fork are staged: the branches, plus — + * on an incremental merge — the advanced target itself. + */ + sides: readonly ModelSide[]; +}>; + +/** + * The rule a refusal comes from, in the order the merge applies them: the + * three-way classifier's opposing-relations, race and one-id-one-truth checks, + * then the merged-ledger contradiction, then a surviving id the target already + * holds with other truth. + */ +export type ModelRefusalRule = + | "opposing" + | "race" + | "one-id-two-truths" + | "contradiction" + | "id-held-other-truth"; + +export type ModelVerdict = + | Readonly<{ kind: "refused"; rule: ModelRefusalRule; reason: string }> + | Readonly<{ + kind: "merged"; + /** The current rows after the merge, by id. */ + current: ReadonlyMap; + /** Target rows the merge ends, with the instant each must end at. */ + ended: ReadonlyMap; + /** Bounded survivors the merge must carry as history, by id. */ + history: ReadonlyMap; + /** Ids reported dropped as duplicates. */ + duplicates: ReadonlySet; + }> + /** The model makes no claim about this shape (see the reason). */ + | Readonly<{ kind: "undetermined"; reason: string }>; + +function refKey(ref: ModelRef): string { + return `${ref.kind}\u0000${ref.id}`; +} + +function pairKey(row: ModelRow): string { + const [first, second] = [refKey(row.a), refKey(row.b)].toSorted(); + return `${first}\u0001${second}`; +} + +function semanticKey(row: ModelRow): string { + return `${row.relation}\u0002${pairKey(row)}`; +} + +function dedupeKey(row: ModelRow): string { + return row.validTo === undefined ? + semanticKey(row) + : `${semanticKey(row)}\u0002${row.validFrom}\u0002${row.validTo}`; +} + +function truthKey(row: ModelRow): string { + return JSON.stringify([ + row.relation, + pairKey(row), + row.validFrom, + row.validTo ?? null, + ]); +} + +/** Everything but `validTo`: a retraction changes only the end. */ +function identityKey(row: ModelRow): string { + return JSON.stringify([row.relation, pairKey(row), row.validFrom]); +} + +function isCurrent(row: ModelRow): boolean { + return row.validTo === undefined; +} + +/** Half-open `[validFrom, validTo)` windows; an empty window overlaps nothing. */ +function windowsOverlap(left: ModelRow, right: ModelRow): boolean { + const leftEnd = left.validTo ?? "￿"; + const rightEnd = right.validTo ?? "￿"; + if (left.validFrom >= leftEnd || right.validFrom >= rightEnd) return false; + return left.validFrom < rightEnd && right.validFrom < leftEnd; +} + +/** The first `different` that some `same` chain joins at one instant, if any. */ +function contradiction(rows: readonly ModelRow[]): string | undefined { + for (const instant of new Set(rows.map((row) => row.validFrom))) { + const holding = rows.filter( + (row) => + row.validFrom <= instant && + (row.validTo === undefined || instant < row.validTo), + ); + const violated = contradictionAmong(holding); + if (violated !== undefined) return `${violated} at ${instant}`; + } + return undefined; +} + +function contradictionAmong(rows: readonly ModelRow[]): string | undefined { + const parent = new Map(); + const find = (key: string): string => { + let root = key; + while ((parent.get(root) ?? root) !== root) { + root = parent.get(root) ?? root; + } + return root; + }; + for (const row of rows) { + if (row.relation === "same") + parent.set(find(refKey(row.a)), find(refKey(row.b))); + } + const violated = rows.find( + (row) => + row.relation === "different" && + find(refKey(row.a)) === find(refKey(row.b)), + ); + return violated?.id; +} + +type Staged = Readonly<{ branchId: string; row: ModelRow }>; +type StagedEnd = Readonly<{ branchId: string; row: ModelRow; end: string }>; + +function stage(input: ModelInput): Readonly<{ + staged: readonly Staged[]; + ends: readonly StagedEnd[]; +}> { + const staged: Staged[] = []; + const ends: StagedEnd[] = []; + for (const side of input.sides) { + for (const row of side.rows.values()) { + const ancestor = input.fork.get(row.id); + if (ancestor !== undefined && truthKey(ancestor) === truthKey(row)) { + continue; + } + if ( + ancestor !== undefined && + isCurrent(ancestor) && + row.validTo !== undefined && + identityKey(ancestor) === identityKey(row) + ) { + ends.push({ branchId: side.branchId, row: ancestor, end: row.validTo }); + continue; + } + // A window that closed at the instant it opened never held: there is + // nothing for the merge to carry. + if (row.validTo === row.validFrom) continue; + // Anything else the branch changed — a fresh id, or an id the ancestor + // had already ended that the branch re-opened — is a staged assertion. + staged.push({ branchId: side.branchId, row }); + } + } + return { staged, ends }; +} + +type Refusal = Readonly<{ rule: ModelRefusalRule; reason: string }>; + +function opposingRefusal(staged: readonly Staged[]): Refusal | undefined { + for (const left of staged) { + for (const right of staged) { + if (left.row.relation !== "same" || right.row.relation !== "different") { + continue; + } + if (pairKey(left.row) !== pairKey(right.row)) continue; + if (windowsOverlap(left.row, right.row)) { + return { + rule: "opposing", + reason: `opposing ${left.row.id} / ${right.row.id}`, + }; + } + } + } + return undefined; +} + +function raceRefusal( + staged: readonly Staged[], + ends: readonly StagedEnd[], +): Refusal | undefined { + const keys = new Set(ends.map((entry) => semanticKey(entry.row))); + for (const key of keys) { + const retracting = new Set( + ends + .filter((entry) => semanticKey(entry.row) === key) + .map((entry) => entry.branchId), + ); + const reasserting = new Set( + staged + .filter( + (entry) => isCurrent(entry.row) && semanticKey(entry.row) === key, + ) + .map((entry) => entry.branchId), + ); + if (reasserting.size === 0) continue; + const agree = + retracting.size === reasserting.size && + [...retracting].every((branchId) => reasserting.has(branchId)); + if (!agree) return { rule: "race", reason: `race on ${key}` }; + } + return undefined; +} + +function oneIdOneTruthRefusal(staged: readonly Staged[]): Refusal | undefined { + const truthById = new Map(); + for (const { row } of staged) { + const previous = truthById.get(row.id); + if (previous !== undefined && previous !== truthKey(row)) { + return { + rule: "one-id-two-truths", + reason: `id ${row.id} staged for two truths`, + }; + } + truthById.set(row.id, truthKey(row)); + } + return undefined; +} + +export function modelIdentityThreeWay(input: ModelInput): ModelVerdict { + const { staged, ends } = stage(input); + const refusal = + opposingRefusal(staged) ?? + raceRefusal(staged, ends) ?? + oneIdOneTruthRefusal(staged); + if (refusal !== undefined) return { kind: "refused", ...refusal }; + + const committed = (row: ModelRow): boolean => { + const held = input.target.get(row.id); + return held !== undefined && truthKey(held) === truthKey(row); + }; + const groups = new Map(); + for (const { row } of staged) { + const group = groups.get(dedupeKey(row)) ?? []; + group.push(row); + groups.set(dedupeKey(row), group); + } + const survivors: ModelRow[] = []; + const duplicates = new Set(); + for (const group of groups.values()) { + const ranked = group.toSorted((left, right) => { + if (committed(left) !== committed(right)) return committed(left) ? -1 : 1; + if (left.validFrom !== right.validFrom) { + return left.validFrom < right.validFrom ? -1 : 1; + } + return ( + left.id < right.id ? -1 + : left.id > right.id ? 1 + : 0 + ); + }); + const survivor = ranked[0]; + if (survivor === undefined) continue; + survivors.push(survivor); + for (const loser of ranked.slice(1)) { + if (truthKey(loser) === truthKey(survivor) && loser.id === survivor.id) { + continue; + } + duplicates.add(loser.id); + } + } + const endedAt = new Map(); + for (const entry of ends) { + const held = input.target.get(entry.row.id); + if (held === undefined) { + return { + kind: "undetermined", + reason: `retraction of ${entry.row.id}, which the target does not hold`, + }; + } + // A retraction of a row the target already ended ends no current row: the + // row keeps the target's own end. + if (!isCurrent(held)) continue; + const previous = endedAt.get(entry.row.id); + if (previous === undefined || entry.end < previous) { + endedAt.set(entry.row.id, entry.end); + } + } + + const current = new Map(); + for (const row of input.target.values()) { + if (isCurrent(row) && !endedAt.has(row.id)) current.set(row.id, row); + } + const heldSemantics = new Set( + [...current.values()].map((row) => semanticKey(row)), + ); + const history = new Map(); + for (const survivor of survivors) { + if (!isCurrent(survivor)) { + history.set(survivor.id, survivor); + continue; + } + if (current.has(survivor.id)) continue; + // No side can restate a pair the target still holds without retracting + // it first (the store answers `existing`), so this arm only guards the + // model's own assumptions. + if (heldSemantics.has(semanticKey(survivor))) { + return { + kind: "undetermined", + reason: `survivor ${survivor.id} restates a pair the target holds under another id`, + }; + } + current.set(survivor.id, survivor); + } + // The target's history is part of the ledger the merge leaves behind: rows + // it had already ended, and the rows this merge ends, at their new end. + const targetHistory = [...input.target.values()].flatMap((row) => { + if (!isCurrent(row)) return [row]; + const end = endedAt.get(row.id); + return end === undefined ? [] : [{ ...row, validTo: end }]; + }); + const contradicted = contradiction([ + ...current.values(), + ...history.values(), + ...targetHistory, + ]); + if (contradicted !== undefined) { + return { + kind: "refused", + rule: "contradiction", + reason: `merged ledger contradicts itself at ${contradicted}`, + }; + } + for (const survivor of survivors) { + const held = input.target.get(survivor.id); + if (held !== undefined && truthKey(held) !== truthKey(survivor)) { + return { + kind: "refused", + rule: "id-held-other-truth", + reason: `survivor ${survivor.id} names other target truth`, + }; + } + } + return { kind: "merged", current, ended: endedAt, history, duplicates }; +} diff --git a/packages/typegraph/tests/property/graph-merge/merge-laws.test.ts b/packages/typegraph/tests/property/graph-merge/merge-laws.test.ts index b66fe6022..265a99613 100644 --- a/packages/typegraph/tests/property/graph-merge/merge-laws.test.ts +++ b/packages/typegraph/tests/property/graph-merge/merge-laws.test.ts @@ -52,6 +52,7 @@ import { setupSharedPgliteMergeEngine, type SharedPgliteMergeEngine, } from "../../graph-merge/test-utils"; +import { propertyRuns } from "../run-budget"; import type { MergeLawScenario } from "./arbitraries"; import { mergeLawScenarioArb } from "./arbitraries"; import { normalizeGraph, normalizeReport } from "./normalize"; @@ -97,7 +98,7 @@ const BRANCH_B = asBranchId("law-branch-b"); * PGlite engine with isolated tables for this file, matching the determinism * gate's rationale. */ -const LAW_RUNS = process.env["CI"] ? 8 : 16; +const LAW_RUNS = propertyRuns({ ci: 8, local: 16 }); /** In-memory Dice trigram over the `name` field (no embeddings). */ const nameSimilarity: SimilarityStrategy = { diff --git a/packages/typegraph/tests/property/identity-ledger-model.test.ts b/packages/typegraph/tests/property/identity-ledger-model.test.ts new file mode 100644 index 000000000..c5a1d7873 --- /dev/null +++ b/packages/typegraph/tests/property/identity-ledger-model.test.ts @@ -0,0 +1,504 @@ +/** + * Model-based property for the live identity ledger: random command sequences + * (assert same/different, bulk asserts, retractions, bulk retractions, node + * deletion, and same-id peers of another kind joining and leaving) run against + * a store and against a small union-find model side by side, under both + * same-id profiles. After every command the store must agree with the model + * on `areSame`, `areDifferent` and `membersOf` for every live pair, refuse an + * assertion exactly when the model says it would contradict the ledger, and + * pass `validateIdentity`. + * + * The model is written from the documented semantics, not from the store's + * code: a class is the transitive closure of current `same` assertions (and, + * under `"fold"`, of live nodes sharing an id across kinds); a `different` + * lifts to the two classes its endpoints sit in; deleting a node ends every + * assertion that touches it. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asNodeId, + createStoreWithSchema, + defineGraph, + defineNode, + IdentityContradictionError, + type Store, +} from "../../src"; +import { asIdentityAssertionId } from "../../src/identity/types"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { compareStrings } from "../../src/utils/compare"; +import { requireDefined } from "../../src/utils/presence"; +import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; + +const Person = defineNode("Person", { schema: z.object({}) }); +const Robot = defineNode("Robot", { schema: z.object({}) }); + +function defineLedgerGraph(profile: "fold" | "ignore") { + return defineGraph({ + id: `identity_ledger_model_${profile}`, + nodes: { Person: { type: Person }, Robot: { type: Robot } }, + edges: {}, + identity: { sameIdAcrossKinds: profile }, + }); +} + +type LedgerGraph = ReturnType; +type LedgerStore = Store; +type Kind = "Person" | "Robot"; +type Ref = Readonly<{ kind: Kind; id: string }>; +type Relation = "same" | "different"; + +const IDS = ["n0", "n1", "n2"] as const; +const REFS: readonly Ref[] = (["Person", "Robot"] as const).flatMap((kind) => + IDS.map((id) => ({ kind, id })), +); + +const RUNS = propertyRuns({ ci: 40, local: 80 }); +const MAX_COMMANDS = 25; + +function refKey(ref: Ref): string { + return `${ref.kind}:${ref.id}`; +} + +function pairKey(left: Ref, right: Ref): string { + return [refKey(left), refKey(right)].toSorted().join("|"); +} + +type ModelAssertion = Readonly<{ relation: Relation; a: Ref; b: Ref }>; + +class LedgerModel { + readonly live = new Set(); + /** Current assertions by the id the store minted for them. */ + readonly assertions = new Map(); + + constructor(readonly profile: "fold" | "ignore") {} + + liveRefs(): readonly Ref[] { + return REFS.filter((ref) => this.live.has(refKey(ref))); + } + + /** Each live ref's class root under `extra` hypothetical `same` links. */ + classes(extra: readonly ModelAssertion[] = []): (ref: Ref) => string { + const parent = new Map(); + const find = (key: string): string => { + let root = key; + while ((parent.get(root) ?? root) !== root) { + root = requireDefined(parent.get(root)); + } + return root; + }; + const union = (left: string, right: string) => { + const [first, second] = [find(left), find(right)].toSorted(); + if (first !== second) + parent.set(requireDefined(second), requireDefined(first)); + }; + for (const assertion of [...this.assertions.values(), ...extra]) { + if (assertion.relation === "same") { + union(refKey(assertion.a), refKey(assertion.b)); + } + } + if (this.profile === "fold") { + for (const id of IDS) { + const person: Ref = { kind: "Person", id }; + const robot: Ref = { kind: "Robot", id }; + if (this.live.has(refKey(person)) && this.live.has(refKey(robot))) { + union(refKey(person), refKey(robot)); + } + } + } + return (ref) => find(refKey(ref)); + } + + separated(classOf: (ref: Ref) => string, left: Ref, right: Ref): boolean { + const [first, second] = [classOf(left), classOf(right)]; + return [...this.assertions.values()].some( + (assertion) => + assertion.relation === "different" && + ((classOf(assertion.a) === first && classOf(assertion.b) === second) || + (classOf(assertion.a) === second && classOf(assertion.b) === first)), + ); + } + + /** Whether adding `candidates` (in order) leaves some class holding a `different`. */ + contradicts(candidates: readonly ModelAssertion[]): boolean { + const classOf = this.classes( + candidates.filter((candidate) => candidate.relation === "same"), + ); + return [...this.assertions.values(), ...candidates].some( + (assertion) => + assertion.relation === "different" && + classOf(assertion.a) === classOf(assertion.b), + ); + } + + directAssertion(candidate: ModelAssertion): string | undefined { + for (const [id, assertion] of this.assertions) { + if ( + assertion.relation === candidate.relation && + pairKey(assertion.a, assertion.b) === pairKey(candidate.a, candidate.b) + ) { + return id; + } + } + return undefined; + } + + removeNode(ref: Ref): void { + this.live.delete(refKey(ref)); + for (const [id, assertion] of this.assertions) { + if ( + refKey(assertion.a) === refKey(ref) || + refKey(assertion.b) === refKey(ref) + ) { + this.assertions.delete(id); + } + } + } + + /** The current assertion ids in a stable order, for index-based picks. */ + assertionIds(): readonly string[] { + return [...this.assertions.keys()].toSorted(); + } +} + +type Real = Readonly<{ store: LedgerStore }>; +type LedgerCommand = fc.AsyncCommand; + +function identityRef(ref: Ref) { + return { kind: ref.kind, id: ref.id } as const; +} + +async function verifyAgainstModel(model: LedgerModel, store: LedgerStore) { + const classOf = model.classes(); + const live = model.liveRefs(); + for (const ref of live) { + const stored = await store.identity.membersOf(identityRef(ref)); + const members = stored + .map((member) => `${member.kind}:${member.id}`) + .toSorted((left, right) => compareStrings(left, right)); + const expected = live + .filter((other) => classOf(other) === classOf(ref)) + .map((other) => refKey(other)) + .toSorted((left, right) => compareStrings(left, right)); + expect(members, `membersOf ${refKey(ref)}`).toEqual(expected); + } + for (const [index, left] of live.entries()) { + for (const right of live.slice(index + 1)) { + const label = `${refKey(left)} / ${refKey(right)}`; + expect( + await store.identity.areSame(identityRef(left), identityRef(right)), + `areSame ${label}`, + ).toBe(classOf(left) === classOf(right)); + expect( + await store.identity.areDifferent( + identityRef(left), + identityRef(right), + ), + `areDifferent ${label}`, + ).toBe(model.separated(classOf, left, right)); + } + } + await storeRuntime(store).validateIdentity(); +} + +/** + * Runs a write the model predicts refused (an `IdentityContradictionError`) + * or accepted, and fails on any other outcome. + */ +async function expectVerdict( + label: string, + refused: boolean, + write: () => Promise, +): Promise { + try { + const result = await write(); + expect( + refused, + `${label}: the model refuses it, the store accepted it`, + ).toBe(false); + return result; + } catch (error) { + if (!(error instanceof IdentityContradictionError)) throw error; + expect( + refused, + `${label}: the model accepts it, the store refused it`, + ).toBe(true); + return undefined; + } +} + +async function applyAssert( + relation: Relation, + left: Ref, + right: Ref, + model: LedgerModel, + real: Real, +): Promise { + const label = `assert ${relation}(${refKey(left)}, ${refKey(right)})`; + const candidate = { relation, a: left, b: right }; + if (model.live.has(refKey(left)) && model.live.has(refKey(right))) { + const existing = model.directAssertion(candidate); + const refused = existing === undefined && model.contradicts([candidate]); + const result = await expectVerdict(label, refused, () => + relation === "same" ? + real.store.identity.assertSame(identityRef(left), identityRef(right)) + : real.store.identity.assertDifferent( + identityRef(left), + identityRef(right), + ), + ); + if (result !== undefined) { + expect(result.action, `${label}: action`).toBe( + existing === undefined ? "created" : "existing", + ); + if (existing !== undefined) expect(result.assertion.id).toBe(existing); + model.assertions.set(result.assertion.id, candidate); + } + } + await verifyAgainstModel(model, real.store); +} + +async function applyBulkAssert( + relation: Relation, + requested: readonly (readonly [Ref, Ref])[], + label: string, + model: LedgerModel, + real: Real, +): Promise { + const pairs = requested.filter( + ([left, right]) => + model.live.has(refKey(left)) && model.live.has(refKey(right)), + ); + if (pairs.length > 0) { + const candidates = pairs.map(([a, b]) => ({ relation, a, b })); + const refused = model.contradicts( + candidates.filter( + (candidate) => model.directAssertion(candidate) === undefined, + ), + ); + const input = pairs.map(([a, b]) => ({ + a: identityRef(a), + b: identityRef(b), + })); + const results = await expectVerdict(label, refused, () => + relation === "same" ? + real.store.identity.bulkAssertSame(input) + : real.store.identity.bulkAssertDifferent(input), + ); + if (results !== undefined) { + expect(results).toHaveLength(candidates.length); + for (const [index, result] of results.entries()) { + model.assertions.set( + result.assertion.id, + requireDefined(candidates[index]), + ); + } + } + } + await verifyAgainstModel(model, real.store); +} + +async function applyRetract( + picks: readonly number[], + model: LedgerModel, + real: Real, +): Promise { + const ids = model.assertionIds(); + if (ids.length > 0) { + const chosen = [ + ...new Set(picks.map((pick) => requireDefined(ids[pick % ids.length]))), + ]; + if (chosen.length === 1) { + const id = requireDefined(chosen[0]); + const ended = await real.store.identity.retractAssertion( + asIdentityAssertionId(id), + ); + expect(ended?.id, `retract ${id}`).toBe(id); + } else { + const ended = await real.store.identity.bulkRetractAssertions( + chosen.map((id) => asIdentityAssertionId(id)), + ); + expect( + ended.map((row) => row.id).toSorted(), + "bulk retraction ends exactly the requested rows", + ).toEqual(chosen.toSorted()); + } + for (const id of chosen) model.assertions.delete(id); + } + await verifyAgainstModel(model, real.store); +} + +class AssertCommand implements LedgerCommand { + constructor( + readonly relation: Relation, + readonly left: Ref, + readonly right: Ref, + ) {} + + check(): true { + return true; + } + + run(model: LedgerModel, real: Real): Promise { + return applyAssert(this.relation, this.left, this.right, model, real); + } + + toString(): string { + return `assert ${this.relation}(${refKey(this.left)}, ${refKey(this.right)})`; + } +} + +class BulkAssertCommand implements LedgerCommand { + constructor( + readonly relation: Relation, + readonly pairs: readonly (readonly [Ref, Ref])[], + ) {} + + check(): true { + return true; + } + + run(model: LedgerModel, real: Real): Promise { + return applyBulkAssert( + this.relation, + this.pairs, + this.toString(), + model, + real, + ); + } + + toString(): string { + return `bulk ${this.relation} [${this.pairs + .map(([left, right]) => `${refKey(left)}~${refKey(right)}`) + .join(", ")}]`; + } +} + +class RetractCommand implements LedgerCommand { + constructor(readonly picks: readonly number[]) {} + + check(): true { + return true; + } + + run(model: LedgerModel, real: Real): Promise { + return applyRetract(this.picks, model, real); + } + + toString(): string { + return `retract #${this.picks.join(",#")}`; + } +} + +class DeleteNodeCommand implements LedgerCommand { + constructor(readonly ref: Ref) {} + + check(model: Readonly): boolean { + return model.live.has(refKey(this.ref)); + } + + async run(model: LedgerModel, real: Real): Promise { + if (this.ref.kind === "Person") { + await real.store.nodes.Person.delete( + asNodeId(this.ref.id), + ); + } else { + // A hard delete, so the same Robot id can be created again later. + const id = asNodeId(this.ref.id); + await real.store.nodes.Robot.delete(id); + await real.store.nodes.Robot.hardDelete(id); + } + model.removeNode(this.ref); + await verifyAgainstModel(model, real.store); + } + + toString(): string { + return `delete ${refKey(this.ref)}`; + } +} + +class CreateRobotCommand implements LedgerCommand { + constructor(readonly id: string) {} + + check(model: Readonly): boolean { + return !model.live.has(refKey({ kind: "Robot", id: this.id })); + } + + async run(model: LedgerModel, real: Real): Promise { + await real.store.nodes.Robot.create({}, { id: this.id }); + model.live.add(refKey({ kind: "Robot", id: this.id })); + await verifyAgainstModel(model, real.store); + } + + toString(): string { + return `create Robot:${this.id}`; + } +} + +const refArb = fc.constantFrom(...REFS); +const pairArb = fc + .tuple(refArb, refArb) + .filter(([left, right]) => refKey(left) !== refKey(right)); +const relationArb = fc.constantFrom("same", "different"); + +const commandArbs: fc.Arbitrary[] = [ + fc + .tuple(relationArb, pairArb) + .map( + ([relation, [left, right]]) => new AssertCommand(relation, left, right), + ), + fc + .tuple( + relationArb, + fc.uniqueArray(pairArb, { + minLength: 2, + maxLength: 3, + selector: ([left, right]) => pairKey(left, right), + }), + ) + .map(([relation, pairs]) => new BulkAssertCommand(relation, pairs)), + fc + .array(fc.nat({ max: 7 }), { minLength: 1, maxLength: 3 }) + .map((picks) => new RetractCommand(picks)), + refArb.map((ref) => new DeleteNodeCommand(ref)), + fc.constantFrom(...IDS).map((id) => new CreateRobotCommand(id)), +]; + +describe.each(["fold", "ignore"] as const)( + "identity ledger against a union-find model [%s]", + (profile) => { + const graph = defineLedgerGraph(profile); + + it("agrees on classes, separations, refusals and integrity after every command", async () => { + await fc.assert( + fc.asyncProperty( + fc.commands(commandArbs, { maxCommands: MAX_COMMANDS, size: "max" }), + async (commands) => { + const [store] = await createStoreWithSchema( + graph, + createTestBackend(), + ); + const model = new LedgerModel(profile); + for (const id of IDS) { + await store.nodes.Person.create({}, { id }); + model.live.add(refKey({ kind: "Person", id })); + } + // One Robot peer from the start, so the fold arm is live in + // every run rather than only after a create command. + await store.nodes.Robot.create({}, { id: "n0" }); + model.live.add(refKey({ kind: "Robot", id: "n0" })); + await fc.asyncModelRun( + () => ({ model, real: { store } }), + commands, + ); + }, + ), + { numRuns: RUNS }, + ); + }, 120_000); + }, +); diff --git a/packages/typegraph/tests/property/identity-replay-exhaustive.test.ts b/packages/typegraph/tests/property/identity-replay-exhaustive.test.ts new file mode 100644 index 000000000..5e790d472 --- /dev/null +++ b/packages/typegraph/tests/property/identity-replay-exhaustive.test.ts @@ -0,0 +1,550 @@ +/** + * The exhaustiveness property: under one schema the identity transition + * log's cause set is exhaustive, so no membership change falls between two + * consecutive `replay` boundaries. + * + * For a randomized sequence of identity-affecting operations, this asserts, + * for EVERY reference's lineage (from the revision the reference was first + * created at) and EVERY recorded revision the run produced: + * + * - membership changed at r IFF the transition log carries a row at r + * touching that reference's class lineage; + * - `replay(ref)`'s step boundaries equal exactly the revisions at which + * membership changed; + * - every step's `after` equals brute-force membership at that revision, + * and every step's `before` equals it at the previous boundary. + * + * Brute-force membership comes from `store.asOfRecorded(r).identity.membersOf` + * — a read path that never touches the transition log — so agreement between + * it and `replay` is the whole equivalence pin, not a tautology. Both same-id + * profiles run, each over a fixed-seed corpus (reproducible mutation checks) + * and over fresh random histories. Only typed semantic refusals of a random + * operation are skipped; any other error fails the run. + * + * Scope: assertSame, assertDifferent, retractAssertion, node create + * (including same-id cross-kind folds and resurrections), soft delete, hard + * delete, and window-end (narrowing a live member's `validTo`, the same + * `store.nodes[kind].update(id, {}, { validTo })` shape + * identity-transition-log.test.ts's dedicated window-end unit test uses). + * + * Half the runs open with the first reference joining another class and + * leaving it again before any generated operation runs, so the class it left + * goes on changing without it: its lineage keeps naming that class, and none + * of that class's later history may be reported as its own. + * + * `kind-drop` is the one cause this generator does NOT model, and cannot: + * it requires a SCHEMA EVOLUTION (dropping a node kind mid-run), which is a + * fixed-graph property outside what a per-operation generator over one + * static `graph` can express. Its own note site is guarded at unit level + * only (identity-transition-log.test.ts's "notes a kind-drop transition" + * test, with its own revert/mutation check) — an L2-style mutation of that + * note call would NOT be caught here, since this generator never reaches + * `Store.removeKinds()`. `kind-drop` is therefore an explicit, documented + * gap in this property's exhaustiveness claim, not an oversight. + */ +import * as fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createAdapterStoreWithSchema, + defineGraph, + defineNode, + IdentityContradictionError, + IdentityEndpointValidityError, + IdentityValidityWindowError, + NodeNotFoundError, + type Store, + type TransitionPageCursor, + ValidationError, +} from "../../src"; +import { + asRecordedInstant, + createRecordedInstant, + type RecordedInstant, + recordedInstantRevision, +} from "../../src/core/temporal"; +import { + identityReplay, + identityTransitionsOf, +} from "../../src/identity/replay"; +import { storeRuntime } from "../../src/store/runtime-port"; +import { nowIso } from "../../src/utils/date"; +import { requireDefined } from "../../src/utils/presence"; +import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; + +const Person = defineNode("Person", { schema: z.object({}) }); +const Company = defineNode("Company", { schema: z.object({}) }); +const Robot = defineNode("Robot", { schema: z.object({}) }); + +function defineExhaustiveGraph(sameIdAcrossKinds: "fold" | "ignore") { + return defineGraph({ + id: `identity_replay_exhaustive_${sameIdAcrossKinds}`, + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Robot: { type: Robot }, + }, + edges: {}, + identity: { sameIdAcrossKinds }, + }); +} + +type ExhaustiveGraph = ReturnType; + +/** + * The refusals a random operation may legitimately meet: a contradiction, a + * duplicate or missing node, an endpoint or window the store will not accept. + * Anything else — a crash, a generic wrapper — fails the run instead of being + * skipped as noise. + */ +function isSemanticRefusal(error: unknown): boolean { + return ( + error instanceof IdentityContradictionError || + error instanceof IdentityValidityWindowError || + error instanceof IdentityEndpointValidityError || + error instanceof NodeNotFoundError || + error instanceof ValidationError + ); +} + +// A 6-node, 3-kind universe: two bare ids, each realized under all three +// kinds — the shared bare id is what makes same-id folding reachable. +const BARE_IDS = ["x", "y"] as const; +const KINDS = ["Person", "Company", "Robot"] as const; +type Ref = Readonly<{ + kind: (typeof KINDS)[number]; + id: (typeof BARE_IDS)[number]; +}>; +const UNIVERSE: readonly Ref[] = KINDS.flatMap((kind) => + BARE_IDS.map((id) => ({ kind, id })), +); + +type OpIntent = + | Readonly<{ type: "create"; refIndex: number }> + | Readonly<{ type: "assertSame"; aIndex: number; bIndex: number }> + | Readonly<{ type: "assertDifferent"; aIndex: number; bIndex: number }> + | Readonly<{ type: "retract"; pick: number }> + | Readonly<{ type: "softDelete"; refIndex: number }> + | Readonly<{ type: "hardDelete"; refIndex: number }> + | Readonly<{ type: "windowEnd"; refIndex: number; daysFromNow: number }>; + +const opIntentArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + type: fc.constant("create" as const), + refIndex: fc.nat({ max: UNIVERSE.length - 1 }), + }), + fc.record({ + type: fc.constant("assertSame" as const), + aIndex: fc.nat({ max: UNIVERSE.length - 1 }), + bIndex: fc.nat({ max: UNIVERSE.length - 1 }), + }), + fc.record({ + type: fc.constant("assertDifferent" as const), + aIndex: fc.nat({ max: UNIVERSE.length - 1 }), + bIndex: fc.nat({ max: UNIVERSE.length - 1 }), + }), + fc.record({ + type: fc.constant("retract" as const), + pick: fc.nat({ max: 63 }), + }), + fc.record({ + type: fc.constant("softDelete" as const), + refIndex: fc.nat({ max: UNIVERSE.length - 1 }), + }), + fc.record({ + type: fc.constant("hardDelete" as const), + refIndex: fc.nat({ max: UNIVERSE.length - 1 }), + }), + fc.record({ + type: fc.constant("windowEnd" as const), + refIndex: fc.nat({ max: UNIVERSE.length - 1 }), + // Days from now, narrow enough to repeatedly re-narrow across a run — + // a WIDENING update (a later, larger offset than an earlier one already + // applied) is refused by `requireNodeValidityEndCompatible` and simply + // skipped by the blanket try/catch below, exactly like any other + // semantically-refused random operation. + daysFromNow: fc.nat({ max: 3650 }), + }), +); + +function refKeyOf(ref: Ref): string { + return `${ref.kind} ${ref.id}`; +} + +function normalizeMembers( + members: readonly Readonly<{ kind: string; id: string }>[], +): string { + return members + .map((member) => `${member.kind} ${member.id}`) + .toSorted() + .join(","); +} + +/** A page size small enough that most lineages span several pages. */ +const PAGE_LIMIT = 2; + +function revisionOf(recorded: string): number { + return recordedInstantRevision(asRecordedInstant(recorded)); +} + +function referencesEqual( + left: Readonly<{ kind: string; id: string }>, + right: Readonly<{ kind: string; id: string }>, +): boolean { + return left.kind === right.kind && left.id === right.id; +} + +type ExhaustiveStore = Store; + +/** What one generated run left behind, for the per-reference lineage checks. */ +type ExhaustiveRun = Readonly<{ + store: ExhaustiveStore; + recordedByRevision: ReadonlyMap; + /** The revision at which each reference first became live. */ + bornAt: ReadonlyMap; + /** + * Revisions at which a `windowEnd` narrowed a real (>=2 member) class, per + * member of that class — tracked through `membersOf`, a read path the + * transition log never touches. + */ + windowEndRevisions: ReadonlyMap; +}>; + +async function runExhaustiveScenario( + graph: ExhaustiveGraph, + intents: readonly OpIntent[], + seedLeavesAClass: boolean, +): Promise { + const [store] = await createAdapterStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const live = new Set(); + const assertionIds: string[] = []; + const recordedByRevision = new Map(); + const bornAt = new Map(); + const windowEndRevisions = new Map(); + + async function recordCheckpoint(): Promise { + const now = await store.recordedNow(); + if (now === undefined) return undefined; + const revision = recordedInstantRevision(now); + recordedByRevision.set(revision, now); + for (const key of live) { + if (!bornAt.has(key)) bornAt.set(key, revision); + } + return revision; + } + + // The first reference exists from the FIRST observed revision, created + // before any generated intent runs, so at least one lineage is checked over + // the whole run; every other reference is checked from the revision it was + // first created at. + const first = requireDefined(UNIVERSE[0]); + await store.nodes[first.kind].create({}, { id: first.id }); + live.add(refKeyOf(first)); + await recordCheckpoint(); + + if (seedLeavesAClass) { + const partner = requireDefined(UNIVERSE[1]); + await store.nodes[partner.kind].create({}, { id: partner.id }); + live.add(refKeyOf(partner)); + await recordCheckpoint(); + const joined = await store.identity.assertSame(first, partner); + await recordCheckpoint(); + await store.identity.retractAssertion(joined.assertion.id); + await recordCheckpoint(); + } + + for (const intent of intents) { + let narrowedClass: readonly string[] = []; + try { + switch (intent.type) { + case "create": { + const ref = requireDefined(UNIVERSE[intent.refIndex]); + await store.nodes[ref.kind].create({}, { id: ref.id }); + live.add(refKeyOf(ref)); + break; + } + case "assertSame": + case "assertDifferent": { + const a = requireDefined(UNIVERSE[intent.aIndex]); + const b = requireDefined(UNIVERSE[intent.bIndex]); + if (refKeyOf(a) === refKeyOf(b)) break; + if (!live.has(refKeyOf(a)) || !live.has(refKeyOf(b))) break; + const result = + intent.type === "assertSame" ? + await store.identity.assertSame(a, b) + : await store.identity.assertDifferent(a, b); + if (result.action === "created") { + assertionIds.push(result.assertion.id); + } + break; + } + case "retract": { + if (assertionIds.length === 0) break; + const id = requireDefined( + assertionIds[intent.pick % assertionIds.length], + ); + await store.identity.retractAssertion( + id as Parameters[0], + ); + break; + } + case "softDelete": { + const ref = requireDefined(UNIVERSE[intent.refIndex]); + if (!live.has(refKeyOf(ref))) break; + await store.nodes[ref.kind].delete(ref.id as never); + live.delete(refKeyOf(ref)); + break; + } + case "hardDelete": { + const ref = requireDefined(UNIVERSE[intent.refIndex]); + if (!live.has(refKeyOf(ref))) break; + await store.nodes[ref.kind].hardDelete(ref.id as never); + live.delete(refKeyOf(ref)); + break; + } + case "windowEnd": { + const ref = requireDefined(UNIVERSE[intent.refIndex]); + if (!live.has(refKeyOf(ref))) break; + const currentMembers = await store.identity.membersOf(ref); + const validTo = new Date( + Date.now() + intent.daysFromNow * 86_400_000, + ).toISOString(); + // A WIDENING update (a later end than one already applied) is + // refused by `requireNodeValidityEndCompatible` — a typed refusal. + await store.nodes[ref.kind].update(ref.id as never, {}, { validTo }); + if (currentMembers.length >= 2) { + narrowedClass = currentMembers.map( + (member) => `${member.kind} ${member.id}`, + ); + } + break; + } + } + } catch (error) { + if (!isSemanticRefusal(error)) throw error; + continue; + } + const revision = await recordCheckpoint(); + if (revision === undefined) continue; + for (const member of narrowedClass) { + const revisions = windowEndRevisions.get(member) ?? []; + revisions.push(revision); + windowEndRevisions.set(member, revisions); + } + } + return { store, recordedByRevision, bornAt, windowEndRevisions }; +} + +/** + * The exhaustiveness pins for ONE reference's lineage, from the revision the + * reference was first created at: membership changed at r IFF the log carries + * a row at r on that lineage; replay's boundaries are exactly the changed + * revisions; and every step's before/after match brute-force membership. + */ +async function expectExhaustiveLineage( + run: ExhaustiveRun, + ref: Ref, +): Promise { + const { store, recordedByRevision } = run; + const key = refKeyOf(ref); + const bornAt = run.bornAt.get(key); + if (bornAt === undefined) return; + const born: number = bornAt; + const ctx = storeRuntime(store).identityContext(); + const revisions = [...recordedByRevision.keys()] + .filter((revision) => revision >= born) + .toSorted((left, right) => left - right); + + // Brute-force membership at ANY revision, via a read path the transition + // log never touches — memoized, and able to answer for a revision this run + // never checkpointed (a replay step's `before` is read at `boundary - 1`). + // A missing captured instant is fabricated exactly as `reconstructAt` + // (replay.ts) fabricates its own: the recorded filter keys on the revision. + const bruteForce = new Map(); + async function bruteForceMembersAt( + revision: number, + ): Promise { + const cached = bruteForce.get(revision); + if (cached !== undefined) return cached; + if (revision < born) { + bruteForce.set(revision, []); + return []; + } + const instant = + recordedByRevision.get(revision) ?? + createRecordedInstant(revision, nowIso()); + const members = await store.asOfRecorded(instant).identity.membersOf(ref); + const normalized = members + .map((member) => `${member.kind} ${member.id}`) + .toSorted(); + bruteForce.set(revision, normalized); + return normalized; + } + + // A bare singleton coming into or out of existence is NOT an identity + // transition by design (the closure carries no row for a singleton), so + // "did membership change" excludes the reference's own id on both sides. + function withoutSelf(ids: readonly string[]): string { + return ids.filter((id) => id !== key).join(","); + } + const changedRevisions: number[] = []; + for (const [index, revision] of revisions.entries()) { + const previousRevision = + index === 0 ? revision - 1 : requireDefined(revisions[index - 1]); + if ( + withoutSelf(await bruteForceMembersAt(previousRevision)) !== + withoutSelf(await bruteForceMembersAt(revision)) + ) { + changedRevisions.push(revision); + } + } + const changedRevisionSet = new Set(changedRevisions); + + // `window-end` schedules a FUTURE valid-time boundary, so its row is + // SELF-REFERENTIAL (`class === priorClass`) and the diagonal `asOfRecorded` + // read cannot see the change yet: a revision touched ONLY by such rows is + // exempt from "changed", and the positive check below confirms the note + // still fires where it must. + const { transitions } = await identityTransitionsOf(ctx, ref, { + limit: 2000, + }); + // `transitionsOf` and `replay` answer ascending by recorded revision. + const lineageRevisions = transitions.map((transition) => + revisionOf(transition.recorded), + ); + expect(lineageRevisions, `${key}: transitions in revision order`).toEqual( + lineageRevisions.toSorted((left, right) => left - right), + ); + // Paging through the lineage yields the same transitions, in order. + const paged: string[] = []; + let cursor: TransitionPageCursor | undefined; + do { + const page = await store.identity.transitionsOf(ref, { + limit: PAGE_LIMIT, + ...(cursor === undefined ? {} : { cursor }), + }); + paged.push( + ...page.transitions.map((transition) => transition.transitionId), + ); + cursor = page.nextCursor; + } while (cursor !== undefined); + expect(paged, `${key}: paged lineage`).toEqual( + transitions.map((transition) => transition.transitionId), + ); + const ownTransitions = transitions.filter( + (transition) => revisionOf(transition.recorded) >= born, + ); + const selfReferentialRevisions = new Set( + ownTransitions + .filter( + (transition) => + transition.priorClass !== undefined && + referencesEqual(transition.priorClass, transition.class), + ) + .map((transition) => revisionOf(transition.recorded)), + ); + const touchedRevisions = new Set( + ownTransitions.map((transition) => revisionOf(transition.recorded)), + ); + for (const revision of revisions) { + const changed = changedRevisionSet.has(revision); + const touched = touchedRevisions.has(revision); + expect( + !changed || touched, + `${key}: membership changed at ${String(revision)} with no transition`, + ).toBe(true); + expect( + changed || !touched || selfReferentialRevisions.has(revision), + `${key}: a transition at ${String(revision)} changed nothing`, + ).toBe(true); + } + + for (const revision of run.windowEndRevisions.get(key) ?? []) { + expect( + ownTransitions.some( + (transition) => + transition.cause === "window-end" && + revisionOf(transition.recorded) === revision, + ), + `${key}: no window-end transition at ${String(revision)}`, + ).toBe(true); + } + + const replay = await identityReplay(ctx, ref, { limit: 2000 }); + const stepRevisions = replay.steps.map((step) => + revisionOf(step.transition.recorded), + ); + expect(stepRevisions, `${key}: replay steps in revision order`).toEqual( + stepRevisions.toSorted((left, right) => left - right), + ); + const ownSteps = replay.steps.filter( + (step) => revisionOf(step.transition.recorded) >= born, + ); + const replayBoundaries = [ + ...new Set(ownSteps.map((step) => revisionOf(step.transition.recorded))), + ].toSorted((left, right) => left - right); + const expectedBoundaries = [ + ...new Set([...changedRevisions, ...selfReferentialRevisions]), + ].toSorted((left, right) => left - right); + expect(replayBoundaries, `${key}: replay boundaries`).toEqual( + expectedBoundaries, + ); + + // Each step's `before` is the same read one revision earlier — not the + // previous boundary, since an unnoted write (a bare singleton coming back) + // can change membership in between. + for (const step of ownSteps) { + const revision = revisionOf(step.transition.recorded); + const previousRevision = revision - 1; + const expectedBefore = await bruteForceMembersAt(previousRevision); + const expectedAfter = await bruteForceMembersAt(revision); + expect( + normalizeMembers(step.before), + `${key}: before at ${String(revision)}`, + ).toBe(expectedBefore.join(",")); + expect( + normalizeMembers(step.after), + `${key}: after at ${String(revision)}`, + ).toBe(expectedAfter.join(",")); + } +} + +const scenarioArbitraries = [ + fc.array(opIntentArbitrary, { minLength: 1, maxLength: 40 }), + fc.boolean(), +] as const; + +describe.each(["fold", "ignore"] as const)( + "identity replay exhaustiveness property [%s]", + (profile) => { + const graph = defineExhaustiveGraph(profile); + const property = fc.asyncProperty( + ...scenarioArbitraries, + async (intents, seedLeavesAClass) => { + const run = await runExhaustiveScenario( + graph, + intents, + seedLeavesAClass, + ); + for (const ref of UNIVERSE) await expectExhaustiveLineage(run, ref); + }, + ); + + // A fixed corpus, so a mutation check fails the same way on every run. + it("membership changes iff the transition log carries a boundary, on every lineage of a fixed corpus", async () => { + await fc.assert(property, { numRuns: 100, seed: 20_260_908 }); + }, 120_000); + + it("membership changes iff the transition log carries a boundary, on every lineage of random histories", async () => { + await fc.assert(property, { + numRuns: propertyRuns({ ci: 50, local: 100 }), + }); + }, 120_000); + }, +); diff --git a/packages/typegraph/tests/property/import-isolation.test.ts b/packages/typegraph/tests/property/import-isolation.test.ts new file mode 100644 index 000000000..def33228f --- /dev/null +++ b/packages/typegraph/tests/property/import-isolation.test.ts @@ -0,0 +1,306 @@ +/** + * Metamorphic law for a validating import: a refused row leaves no trace. + * + * A random payload over the round-trip graph (see + * `interchange-composition-model.ts`) carries rows the import must refuse per + * row — required parts with no composition edge, a part's second whole, a + * section edge closing a cycle, a second `one`-cardinality edge, a second + * whole with a taken unique name, an edge naming a node the payload never + * defines — alongside rows it must accept. Importing it reports some errors + * `E`; importing the SAME payload minus the rows `E` names, into a fresh + * store, must then: + * + * - report no error at all (every refusal was the row's own, not collateral + * damage the reduced payload no longer triggers), + * - report the same created counts, equal to the reduced payload's row counts, + * - leave the same rows, windows, uniqueness reservations and standing claims, + * - and leave `verifyConstraintFences()` empty on both stores. + * + * Every payload also carries a conflict-free anchor fragment + * ({@link ANCHOR_ID}) that must be accepted outright. + * + * The oracle needs no model of which rows are refused: the store states + * that itself, and the law checks the statement is consistent with what the + * store kept. A refused row that leaked a partial write (a node row, a claim, + * an edge whose savepoint was released rather than rolled back) breaks the + * state equality; a refusal reported for a row that was in fact kept, or + * kept for a row that was reported, breaks the count or error equality. + * + * MUTATION CHECK (src/interchange/import.ts, each verified and reverted): + * - a refused edge's savepoint RELEASED instead of rolled back + * (`{ action: "release", value: guarded }`) — its partial writes survive; + * - the purge no longer subtracting a removed edge from `edges.created`; + * - `readUnattachableRequiredParts` ignoring payload edges that name a part + * (dropping `if (namedByPayloadEdge.has(key)) continue;`) — the anchor's + * required parts are refused before their edge is written. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { createStoreWithSchema } from "../../src"; +import { + FORMAT_VERSION, + type GraphData, + importGraph, +} from "../../src/interchange"; +import { dumpRoundTripState } from "../state-snapshot"; +import { createTestBackend } from "../test-utils"; +import { + BASE_INSTANT, + buildRoundTripGraph, + ID_POOL, + instantAt, + liveSlice, + PART_RELATIONS, + type RoundTripStore, + SECTION_KIND, + WHOLE_KIND, +} from "./interchange-composition-model"; +import { propertyRuns } from "./run-budget"; + +const NAMES = ["alpha", "beta", "gamma", "delta"] as const; +/** An id no payload defines, so an edge naming it is a dangling reference. */ +const MISSING_ID = "missing"; +/** + * The id of a conflict-free fragment every payload carries: a whole with a + * name no other row uses and one part of every kind attached to it by one + * current edge. Nothing else names these rows, so the import must accept all + * of them — the liveness half of the law, which a refusal reported + * consistently in both imports (over-refusal) would otherwise pass. + */ +const ANCHOR_ID = "anchor"; + +type NodeRow = GraphData["nodes"][number]; +type EdgeRow = GraphData["edges"][number]; + +const windowArb = fc.constantFrom( + { validFrom: instantAt(0) }, + { validFrom: instantAt(1) }, + { validFrom: instantAt(0), validTo: instantAt(1) }, +); + +const nodeIdArb = fc.constantFrom(...ID_POOL); +const endpointIdArb = fc.oneof( + { weight: 9, arbitrary: nodeIdArb }, + { weight: 1, arbitrary: fc.constant(MISSING_ID) }, +); + +const payloadArb: fc.Arbitrary = fc + .record({ + // Every pool id names a whole, so a dangling reference is a deliberate + // `MISSING_ID` or a whole refused for its name, not an accident. + wholes: fc.tuple( + ...ID_POOL.map((id) => + fc.record({ id: fc.constant(id), name: fc.constantFrom(...NAMES) }), + ), + ), + parts: fc.array( + fc.record({ + relation: fc.integer({ min: 0, max: PART_RELATIONS.length - 1 }), + id: nodeIdArb, + wholes: fc.array(fc.record({ id: endpointIdArb, window: windowArb }), { + maxLength: 2, + }), + }), + { maxLength: 8 }, + ), + links: fc.array( + fc.record({ + from: endpointIdArb, + to: endpointIdArb, + primary: fc.boolean(), + window: windowArb, + }), + { maxLength: 4 }, + ), + }) + .map(({ wholes, parts, links }) => { + const nodes = new Map(); + function addNode(kind: string, id: string, properties: object): void { + const key = `${kind}:${id}`; + if (nodes.has(key)) return; + nodes.set(key, { + kind, + id, + properties: { ...properties }, + validFrom: BASE_INSTANT, + }); + } + for (const whole of wholes) { + addNode(WHOLE_KIND, whole.id, { name: whole.name }); + } + const edges: EdgeRow[] = []; + function addEdge( + kind: string, + from: Readonly<{ kind: string; id: string }>, + to: Readonly<{ kind: string; id: string }>, + window: Readonly<{ validFrom: string; validTo?: string }>, + properties: object = {}, + ): void { + edges.push({ + kind, + id: `e${String(edges.length)}`, + from, + to, + properties: { ...properties }, + ...window, + }); + } + for (const part of parts) { + const relation = PART_RELATIONS[part.relation]; + if (relation === undefined) continue; + addNode(relation.part, part.id, {}); + for (const whole of part.wholes) { + addEdge( + relation.via, + { kind: relation.part, id: part.id }, + { kind: relation.whole, id: whole.id }, + whole.window, + ); + } + } + for (const [index, link] of links.entries()) { + addEdge( + link.primary ? "rtPrimary" : "rtLink", + { kind: WHOLE_KIND, id: link.from }, + { kind: WHOLE_KIND, id: link.to }, + link.window, + link.primary ? {} : { weight: index }, + ); + } + addNode(WHOLE_KIND, ANCHOR_ID, { name: ANCHOR_ID }); + for (const relation of PART_RELATIONS) { + if (relation.whole !== WHOLE_KIND) continue; + addNode(relation.part, ANCHOR_ID, {}); + addEdge( + relation.via, + { kind: relation.part, id: ANCHOR_ID }, + { kind: WHOLE_KIND, id: ANCHOR_ID }, + { validFrom: instantAt(0) }, + ); + } + return { + formatVersion: FORMAT_VERSION, + exportedAt: "2024-01-01T00:00:00.000Z", + source: { type: "external" as const }, + nodes: [...nodes.values()], + edges, + }; + }); + +/** Sections reference each other, so a random payload can close a cycle. */ +function withSectionCycles(data: GraphData): GraphData { + const sections = data.nodes.filter((node) => node.kind === SECTION_KIND); + return sections.length < 2 ? + data + : { + ...data, + edges: [ + ...data.edges, + ...sections.map((section, index): EdgeRow => ({ + kind: "rtSectionOf", + id: `cycle-${String(index)}`, + from: { kind: SECTION_KIND, id: section.id }, + to: { + kind: SECTION_KIND, + id: sections[(index + 1) % sections.length]?.id ?? section.id, + }, + properties: {}, + validFrom: instantAt(0), + })), + ], + }; +} + +async function createStore(): Promise { + const [store] = await createStoreWithSchema( + buildRoundTripGraph({ id: "import_isolation", identity: false }), + createTestBackend(), + ); + return store; +} + +function withoutRows( + data: GraphData, + errors: readonly Readonly<{ entityType: string; kind: string; id: string }>[], +): GraphData { + const refused = new Set( + errors.map((error) => `${error.entityType}:${error.kind}:${error.id}`), + ); + return { + ...data, + nodes: data.nodes.filter( + (node) => !refused.has(`node:${node.kind}:${node.id}`), + ), + edges: data.edges.filter( + (edge) => !refused.has(`edge:${edge.kind}:${edge.id}`), + ), + }; +} + +function anchorEdgeIds(data: GraphData): readonly string[] { + return data.edges + .filter((edge) => edge.from.id === ANCHOR_ID) + .map((edge) => edge.id); +} + +describe("validating import isolates every refused row", () => { + it("import(doc) leaves exactly what import(doc minus its refused rows) leaves", async () => { + let refusedRows = 0; + await fc.assert( + fc.asyncProperty( + payloadArb.map((data) => withSectionCycles(data)), + fc.constantFrom(1, 3, 1000), + async (data, batchSize) => { + const full = await createStore(); + const fullResult = await importGraph(full, data, { + onConflict: "error", + batchSize, + }); + refusedRows += fullResult.errors.length; + expect( + fullResult.errors.filter( + (error) => + error.id === ANCHOR_ID || + anchorEdgeIds(data).includes(error.id), + ), + ).toEqual([]); + + const reducedData = withoutRows(data, fullResult.errors); + const reduced = await createStore(); + const reducedResult = await importGraph(reduced, reducedData, { + onConflict: "error", + batchSize, + }); + + expect(reducedResult.errors).toEqual([]); + expect(fullResult.nodes).toEqual(reducedResult.nodes); + expect(fullResult.edges).toEqual(reducedResult.edges); + expect(reducedResult.nodes.created).toBe(reducedData.nodes.length); + expect(reducedResult.edges.created).toBe(reducedData.edges.length); + const dumpedAt = Date.now(); + expect( + liveSlice( + await dumpRoundTripState(full, { + transitions: "all", + at: dumpedAt, + }), + ), + ).toEqual( + liveSlice( + await dumpRoundTripState(reduced, { + transitions: "all", + at: dumpedAt, + }), + ), + ); + expect(await full.verifyConstraintFences()).toEqual([]); + expect(await reduced.verifyConstraintFences()).toEqual([]); + }, + ), + { numRuns: propertyRuns({ ci: 60, local: 100 }) }, + ); + // Payloads that never trip a refusal would make the law vacuous. + expect(refusedRows).toBeGreaterThan(0); + }, 120_000); +}); diff --git a/packages/typegraph/tests/property/interchange-composition-model.ts b/packages/typegraph/tests/property/interchange-composition-model.ts new file mode 100644 index 000000000..1e7b53932 --- /dev/null +++ b/packages/typegraph/tests/property/interchange-composition-model.ts @@ -0,0 +1,751 @@ +/** + * A composition + identity graph, a random history over it, and the readers + * whose answers an interchange round trip must preserve. + * + * The graph declares every population/existence pairing a composition axis + * can take — `one`/`oneActive` × `required`/optional — plus a reflexive + * `oneActive` pair (so a history can refuse cycles), a non-composition edge + * with ended windows, a `one` cardinality edge and a unique constraint. Every + * kind draws ids from one small pool, so equal ids across kinds are the norm. + * + * Histories run on fixed past instants: node rows open at {@link BASE_INSTANT} + * and step `n` acts at `instantAt(n)`, so reparents, ended windows and the + * `asOf` coordinates the readers sample never depend on the wall clock. + */ +import fc from "fast-check"; +import { expect } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineNode, + isTypeGraphError, + partOf, +} from "../../src"; +import { exportGraph, importGraph } from "../../src/interchange"; +import type { Store } from "../../src/store/store"; +import { compareStrings } from "../../src/utils/compare"; +import { dumpRoundTripState, type RoundTripState } from "../state-snapshot"; + +const Whole = defineNode("RtWhole", { schema: z.object({ name: z.string() }) }); +const PartOne = defineNode("RtPartOne", { schema: z.object({}) }); +const PartActive = defineNode("RtPartActive", { schema: z.object({}) }); +const PartOptional = defineNode("RtPartOptional", { schema: z.object({}) }); +const PartLoose = defineNode("RtPartLoose", { schema: z.object({}) }); +const Section = defineNode("RtSection", { schema: z.object({}) }); + +const partOneOf = defineEdge("rtPartOneOf", { schema: z.object({}) }); +const partActiveOf = defineEdge("rtPartActiveOf", { schema: z.object({}) }); +const partOptionalOf = defineEdge("rtPartOptionalOf", { schema: z.object({}) }); +const partLooseOf = defineEdge("rtPartLooseOf", { schema: z.object({}) }); +const sectionOf = defineEdge("rtSectionOf", { schema: z.object({}) }); +const link = defineEdge("rtLink", { schema: z.object({ weight: z.number() }) }); +const primary = defineEdge("rtPrimary", { schema: z.object({}) }); + +export function buildRoundTripGraph( + options: Readonly<{ id: string; identity: boolean }>, +) { + return defineGraph({ + id: options.id, + nodes: { + RtWhole: { + type: Whole, + onDelete: "cascade", + unique: [ + { + name: "rt_whole_name", + fields: ["name"], + scope: "kind", + collation: "binary", + }, + ], + }, + RtPartOne: { type: PartOne }, + RtPartActive: { type: PartActive }, + RtPartOptional: { type: PartOptional }, + RtPartLoose: { type: PartLoose }, + RtSection: { type: Section }, + }, + edges: { + rtPartOneOf: { + type: partOneOf, + from: [PartOne], + to: [Whole], + cardinality: "one", + }, + rtPartActiveOf: { + type: partActiveOf, + from: [PartActive], + to: [Whole], + cardinality: "oneActive", + }, + rtPartOptionalOf: { + type: partOptionalOf, + from: [PartOptional], + to: [Whole], + cardinality: "oneActive", + }, + rtPartLooseOf: { + type: partLooseOf, + from: [PartLoose], + to: [Whole], + cardinality: "one", + }, + rtSectionOf: { + type: sectionOf, + from: [Section], + to: [Section], + cardinality: "oneActive", + }, + rtLink: { type: link, from: [Whole], to: [Whole] }, + rtPrimary: { + type: primary, + from: [Whole], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [ + partOf(PartOne, Whole, { via: partOneOf, existence: "required" }), + partOf(PartActive, Whole, { via: partActiveOf, existence: "required" }), + partOf(PartOptional, Whole, { via: partOptionalOf }), + partOf(PartLoose, Whole, { via: partLooseOf }), + partOf(Section, Section, { via: sectionOf, partSide: "from" }), + ], + ...(options.identity ? { identity: { sameIdAcrossKinds: "ignore" } } : {}), + }); +} + +type RoundTripGraph = ReturnType; +export type RoundTripStore = Store; + +export const WHOLE_KIND = "RtWhole"; +export const SECTION_KIND = "RtSection"; + +/** Each part kind and the edge kind that binds it to its whole. */ +export const PART_RELATIONS = [ + { part: "RtPartOne", via: "rtPartOneOf", whole: WHOLE_KIND }, + { part: "RtPartActive", via: "rtPartActiveOf", whole: WHOLE_KIND }, + { part: "RtPartOptional", via: "rtPartOptionalOf", whole: WHOLE_KIND }, + { part: "RtPartLoose", via: "rtPartLooseOf", whole: WHOLE_KIND }, + { part: SECTION_KIND, via: "rtSectionOf", whole: SECTION_KIND }, +] as const; + +type PartRelation = (typeof PART_RELATIONS)[number]; +type CompositionEdgeKind = PartRelation["via"]; + +const NODE_KINDS = [ + WHOLE_KIND, + ...PART_RELATIONS.filter((relation) => relation.part !== SECTION_KIND).map( + (relation) => relation.part, + ), + SECTION_KIND, +] as const; + +export const ID_POOL = ["n0", "n1", "n2", "n3"] as const; +const NAME_POOL = ["alpha", "beta", "gamma"] as const; + +export const BASE_INSTANT = "2019-01-01T00:00:00.000Z"; +const DAY_MS = 24 * 60 * 60 * 1000; +const FIRST_STEP_MS = Date.parse("2020-01-01T00:00:00.000Z"); + +/** The instant step `step` of a history acts at: one day apart, all past. */ +export function instantAt(step: number): string { + return new Date(FIRST_STEP_MS + step * DAY_MS).toISOString(); +} + +/** Halfway between two steps: inside every window that spans the step. */ +function instantBetween(step: number): string { + return new Date(FIRST_STEP_MS + step * DAY_MS + DAY_MS / 2).toISOString(); +} + +type NodeRef = Readonly<{ kind: string; id: string }>; + +/** + * One random step. A `pick` names the `pick % n`-th of the n live nodes of + * the kind the step needs (sorted by id) at the moment it runs, so steps act + * on nodes that exist and shrink toward the first one; ids a step CREATES are + * drawn from {@link ID_POOL} instead, so a create can collide. + */ +export type HistoryOp = + | Readonly<{ type: "createWhole"; id: string; name: string }> + | Readonly<{ type: "createSection"; id: string }> + | Readonly<{ + type: "createPart"; + relation: number; + id: string; + whole: number; + }> + | Readonly<{ + type: "reparent"; + relation: number; + part: number; + whole: number; + }> + | Readonly<{ type: "endAttachment"; relation: number; part: number }> + | Readonly<{ type: "deleteAttachment"; relation: number; part: number }> + | Readonly<{ type: "deleteNode"; kind: string; pick: number }> + | Readonly<{ + type: "link"; + from: number; + to: number; + ended: boolean; + primary: boolean; + }> + | Readonly<{ type: "deleteLink"; from: number }> + | Readonly<{ + type: "assert"; + relation: "same" | "different"; + a: number; + b: number; + }> + | Readonly<{ type: "retract"; a: number }>; + +const idArb = fc.constantFrom(...ID_POOL); +const relationArb = fc.integer({ min: 0, max: PART_RELATIONS.length - 1 }); +const pickArb = fc.nat({ max: 15 }); + +function historyOpArb(identity: boolean): fc.Arbitrary { + const weighted = ( + weight: number, + arbitrary: fc.Arbitrary, + ): fc.WeightedArbitrary => ({ weight, arbitrary }); + return fc.oneof( + weighted( + 1, + fc.record({ + type: fc.constant("createWhole" as const), + id: idArb, + name: fc.constantFrom(...NAME_POOL), + }), + ), + weighted( + 1, + fc.record({ type: fc.constant("createSection" as const), id: idArb }), + ), + weighted( + 5, + fc.record({ + type: fc.constant("createPart" as const), + relation: relationArb, + id: idArb, + whole: pickArb, + }), + ), + weighted( + 4, + fc.record({ + type: fc.constant("reparent" as const), + relation: relationArb, + part: pickArb, + whole: pickArb, + }), + ), + weighted( + 2, + fc.record({ + type: fc.constant("endAttachment" as const), + relation: relationArb, + part: pickArb, + }), + ), + weighted( + 1, + fc.record({ + type: fc.constant("deleteAttachment" as const), + relation: relationArb, + part: pickArb, + }), + ), + weighted( + 1, + fc.record({ + type: fc.constant("deleteNode" as const), + kind: fc.constantFrom(...NODE_KINDS), + pick: pickArb, + }), + ), + weighted( + 2, + fc.record({ + type: fc.constant("link" as const), + from: pickArb, + to: pickArb, + ended: fc.boolean(), + primary: fc.boolean(), + }), + ), + weighted( + 1, + fc.record({ type: fc.constant("deleteLink" as const), from: pickArb }), + ), + ...(identity ? + [ + weighted( + 4, + fc.record({ + type: fc.constant("assert" as const), + relation: fc.constantFrom("same" as const, "different" as const), + a: pickArb, + b: pickArb, + }), + ), + weighted( + 1, + fc.record({ type: fc.constant("retract" as const), a: pickArb }), + ), + ] + : []), + ); +} + +/** + * Wholes for most of the id pool first, then one or two unattached sections + * as roots, so the random steps after them mostly act on nodes that exist. + */ +const seedArb: fc.Arbitrary = fc + .tuple( + fc.subarray([...ID_POOL], { minLength: 2 }), + fc.subarray([...ID_POOL], { minLength: 1, maxLength: 2 }), + ) + .map(([wholeIds, sectionIds]) => [ + ...wholeIds.map((id, index): HistoryOp => ({ + type: "createWhole", + id, + name: NAME_POOL[index % NAME_POOL.length] ?? "alpha", + })), + ...sectionIds.map((id): HistoryOp => ({ type: "createSection", id })), + ]); + +export function historyArb( + identity: boolean, +): fc.Arbitrary { + return fc + .tuple( + seedArb, + fc.array(historyOpArb(identity), { minLength: 4, maxLength: 20 }), + ) + .map(([seed, steps]) => [...seed, ...steps]); +} + +export type HistoryOutcome = Readonly<{ applied: number; refused: number }>; + +function relationAt(index: number): PartRelation { + const relation = PART_RELATIONS[index]; + if (relation === undefined) throw new RangeError(`relation ${index}`); + return relation; +} + +/** + * The untyped collection surface: the history picks kinds at random, so it + * addresses collections by name rather than through the typed facade. + */ +type DynamicNodes = Record< + string, + Readonly<{ + create: ( + props: Record, + options: Record, + ) => Promise; + reparent: ( + id: string, + options: Record, + ) => Promise; + delete: (id: string) => Promise; + find: () => Promise[]>; + planDelete: (id: string) => Promise; + }> +>; +type DynamicEdges = Record< + string, + Readonly<{ + create: ( + from: NodeRef, + to: NodeRef, + props: Record, + options?: Record, + ) => Promise; + update: ( + id: string, + props: Record, + options: Record, + ) => Promise; + delete: (id: string) => Promise; + findFrom: (from: NodeRef) => Promise[]>; + }> +>; + +function dynamicNodes(store: RoundTripStore): DynamicNodes { + return store.nodes as unknown as DynamicNodes; +} + +function dynamicEdges(store: RoundTripStore): DynamicEdges { + return store.edges as unknown as DynamicEdges; +} + +async function currentEdgeId( + store: RoundTripStore, + edgeKind: CompositionEdgeKind | "rtLink", + from: NodeRef, +): Promise { + const edges = await dynamicEdges(store)[edgeKind]?.findFrom(from); + return edges?.[0]?.id; +} + +/** The live nodes of `kinds`, in a stable order for {@link HistoryOp} picks. */ +async function liveNodes( + store: RoundTripStore, + kinds: readonly string[], +): Promise { + const found: NodeRef[] = []; + for (const kind of kinds) { + const rows = await dynamicNodes(store)[kind]?.find(); + for (const row of rows ?? []) found.push({ kind, id: row.id }); + } + return found.toSorted( + (left, right) => + compareStrings(left.kind, right.kind) || + compareStrings(left.id, right.id), + ); +} + +async function pickNode( + store: RoundTripStore, + kinds: readonly string[], + pick: number, +): Promise { + const nodes = await liveNodes(store, kinds); + return nodes.length === 0 ? undefined : nodes[pick % nodes.length]; +} + +async function applyOp( + store: RoundTripStore, + op: HistoryOp, + step: number, +): Promise { + const nodes = dynamicNodes(store); + const edges = dynamicEdges(store); + const at = instantAt(step); + switch (op.type) { + case "createWhole": { + await nodes[WHOLE_KIND]?.create( + { name: op.name }, + { id: op.id, validFrom: BASE_INSTANT }, + ); + return; + } + case "createSection": { + await nodes[SECTION_KIND]?.create( + {}, + { id: op.id, validFrom: BASE_INSTANT }, + ); + return; + } + case "createPart": { + const relation = relationAt(op.relation); + const whole = await pickNode(store, [relation.whole], op.whole); + if (whole === undefined) return; + await nodes[relation.part]?.create( + {}, + { + id: op.id, + validFrom: BASE_INSTANT, + partOf: { whole, validFrom: at }, + }, + ); + return; + } + case "reparent": { + const relation = relationAt(op.relation); + const part = await pickNode(store, [relation.part], op.part); + const whole = await pickNode(store, [relation.whole], op.whole); + if (part === undefined || whole === undefined) return; + await nodes[relation.part]?.reparent(part.id, { whole, at }); + return; + } + case "endAttachment": + case "deleteAttachment": { + const relation = relationAt(op.relation); + const part = await pickNode(store, [relation.part], op.part); + if (part === undefined) return; + const edgeId = await currentEdgeId(store, relation.via, part); + if (edgeId === undefined) return; + await (op.type === "endAttachment" ? + edges[relation.via]?.update(edgeId, {}, { validTo: at }) + : edges[relation.via]?.delete(edgeId)); + return; + } + case "deleteNode": { + const node = await pickNode(store, [op.kind], op.pick); + if (node !== undefined) await nodes[node.kind]?.delete(node.id); + return; + } + case "link": { + const from = await pickNode(store, [WHOLE_KIND], op.from); + const to = await pickNode(store, [WHOLE_KIND], op.to); + if (from === undefined || to === undefined) return; + await (op.primary ? + edges["rtPrimary"]?.create(from, to, {}, { validFrom: at }) + : edges["rtLink"]?.create( + from, + to, + { weight: step }, + { + validFrom: at, + ...(op.ended ? { validTo: instantAt(step + 1) } : {}), + }, + )); + return; + } + case "deleteLink": { + const from = await pickNode(store, [WHOLE_KIND], op.from); + if (from === undefined) return; + const edgeId = await currentEdgeId(store, "rtLink", from); + if (edgeId !== undefined) await edges["rtLink"]?.delete(edgeId); + return; + } + case "assert": { + const a = await pickNode(store, NODE_KINDS, op.a); + const b = await pickNode(store, NODE_KINDS, op.b); + if (a === undefined || b === undefined) return; + await (op.relation === "same" ? + store.identity.assertSame(a as never, b as never) + : store.identity.assertDifferent(a as never, b as never)); + return; + } + case "retract": { + const node = await pickNode(store, NODE_KINDS, op.a); + if (node === undefined) return; + const [assertion] = await store.identity.assertionsOf(node as never); + if (assertion !== undefined) { + await store.identity.retractAssertion(assertion.id); + } + return; + } + } +} + +/** + * Runs a history, one fixed instant per step. A typed refusal is a legal + * outcome of a random step (a second whole, a cycle, a contradiction) and is + * counted; anything else is a defect and propagates. + */ +async function applyHistory( + store: RoundTripStore, + history: readonly HistoryOp[], +): Promise { + let applied = 0; + let refused = 0; + for (const [step, op] of history.entries()) { + try { + await applyOp(store, op, step); + applied += 1; + } catch (error) { + if (!isTypeGraphError(error)) throw error; + refused += 1; + } + } + return { applied, refused }; +} + +function refLabel(node: NodeRef): string { + return `${node.kind}:${node.id}`; +} + +function sortedLabels(values: readonly string[]): readonly string[] { + return values.toSorted(compareStrings); +} + +/** + * What the composition readers answer at one valid-time coordinate: + * `parts()` of every whole and section, `wholes()` of every part kind, and + * `subgraph({ composition: true })` under each live whole. + */ +async function readCoordinate( + store: RoundTripStore, + asOf: string | undefined, + roots: readonly NodeRef[], +): Promise { + const view = asOf === undefined ? store : store.asOf(asOf); + const parts: string[] = []; + for (const kind of [WHOLE_KIND, SECTION_KIND]) { + const rows = await view + .query() + .from(kind as never, "w") + .parts("p") + .select((ctx) => ({ + whole: refLabel(ctx.w as NodeRef), + part: refLabel(ctx.p as NodeRef), + })) + .execute(); + parts.push(...rows.map((row) => `${row.whole} > ${row.part}`)); + } + const wholes: string[] = []; + for (const kind of NODE_KINDS) { + if (kind === WHOLE_KIND) continue; + const rows = await view + .query() + .from(kind as never, "p") + .wholes("w") + .select((ctx) => ({ + part: refLabel(ctx.p as NodeRef), + whole: refLabel(ctx.w as NodeRef), + })) + .execute(); + wholes.push(...rows.map((row) => `${row.part} < ${row.whole}`)); + } + const units: Record = {}; + for (const root of roots) { + const unit = await view.subgraph(root as never, { + edges: [], + composition: true, + }); + units[refLabel(root)] = { + nodes: sortedLabels(unit.nodes.map((node) => refLabel(node))), + edges: sortedLabels(unit.edges.map((edge) => edge.id)), + }; + } + return { + parts: sortedLabels(parts), + wholes: sortedLabels(wholes), + units, + }; +} + +/** + * The cascade plan of every live whole and section now, and the composition + * readers at `now` plus each sampled past coordinate — every answer a + * consumer reads through the composition surface. + */ +async function readCompositionAnswers( + store: RoundTripStore, + coordinates: readonly string[], +): Promise { + const roots: NodeRef[] = []; + const plans: Record = {}; + for (const kind of [WHOLE_KIND, SECTION_KIND]) { + for (const id of ID_POOL) { + const answer = await planDeleteAnswer(store, kind, id); + if (answer === undefined) continue; + roots.push({ kind, id }); + plans[`${kind}:${id}`] = answer; + } + } + const reads: Record = { + now: await readCoordinate(store, undefined, roots), + }; + for (const coordinate of coordinates) { + reads[coordinate] = await readCoordinate(store, coordinate, roots); + } + return { plans, reads }; +} + +/** The node's delete plan, or the code a refused plan names; `undefined` when absent. */ +async function planDeleteAnswer( + store: RoundTripStore, + kind: string, + id: string, +): Promise { + try { + const plan = await dynamicNodes(store)[kind]?.planDelete(id); + return plan === undefined ? undefined : normalizePlan(plan); + } catch (error) { + if (!isTypeGraphError(error)) throw error; + return { refused: error.code }; + } +} + +function normalizePlan(plan: unknown): unknown { + const { cascadedParts, removedEdgeIds } = plan as Readonly<{ + cascadedParts: readonly Readonly<{ + kind: string; + id: string; + whole: NodeRef; + viaEdgeKind: string; + }>[]; + removedEdgeIds: readonly string[]; + }>; + return { + cascadedParts: sortedLabels( + cascadedParts.map( + (part) => + `${refLabel(part)} < ${refLabel(part.whole)} via ${part.viaEdgeKind}`, + ), + ), + removedEdgeIds: sortedLabels(removedEdgeIds), + }; +} + +/** Valid-time coordinates worth reading after a history of `steps` steps. */ +function sampleCoordinates(steps: number): readonly string[] { + const picks = new Set([0, Math.floor(steps / 2), steps - 1]); + return [...picks].flatMap((step) => + step < 0 ? [] : [instantAt(step), instantBetween(step)], + ); +} + +/** + * The slice of a store an interchange document carries: the live graph — no + * tombstoned row, and no identity assertion naming one (an archival export + * omits those too). + */ +export function liveSlice(state: RoundTripState): RoundTripState { + const liveNodes = new Set( + state.nodes + .filter((node) => !node.deleted) + .map((node) => `${node.kind}:${node.id}`), + ); + return { + ...state, + nodes: state.nodes.filter((node) => !node.deleted), + edges: state.edges.filter((edge) => !edge.deleted), + identity: { + ...state.identity, + assertions: state.identity.assertions.filter( + (assertion) => liveNodes.has(assertion.a) && liveNodes.has(assertion.b), + ), + }, + }; +} + +/** Runs one round trip and checks every agreement; returns the source's outcome. */ +export async function checkRoundTrip( + history: readonly HistoryOp[], + options: Readonly<{ + identity: boolean; + createStore: (identity: boolean) => Promise; + }>, +): Promise { + const source = await options.createStore(options.identity); + const outcome = await applyHistory(source, history); + const document = await exportGraph( + source, + options.identity ? { identityMode: "archival" } : {}, + ); + + const target = await options.createStore(options.identity); + const result = await importGraph(target, document, { onConflict: "error" }); + expect(result.errors).toEqual([]); + + const dumpedAt = Date.now(); + expect( + liveSlice( + await dumpRoundTripState(target, { + transitions: "restored", + at: dumpedAt, + }), + ), + ).toEqual( + liveSlice( + await dumpRoundTripState(source, { transitions: "all", at: dumpedAt }), + ), + ); + + const coordinates = sampleCoordinates(history.length); + expect(await readCompositionAnswers(target, coordinates)).toEqual( + await readCompositionAnswers(source, coordinates), + ); + expect(await target.verifyConstraintFences()).toEqual([]); + return outcome; +} diff --git a/packages/typegraph/tests/property/interchange-composition-roundtrip.test.ts b/packages/typegraph/tests/property/interchange-composition-roundtrip.test.ts new file mode 100644 index 000000000..21a15e06a --- /dev/null +++ b/packages/typegraph/tests/property/interchange-composition-roundtrip.test.ts @@ -0,0 +1,80 @@ +/** + * `import(export(store))` over random composition + identity histories. + * + * The source runs a random history (see `interchange-composition-model.ts`): + * parts of every population/existence pairing created, reparented and + * detached at fixed past instants, ended and soft-deleted edges, cascading + * deletes, refused cycles and second wholes, and — on the identity graph — + * `assertSame` / `assertDifferent` / retractions with the transition log on. + * Its default export (`identityMode: "archival"` on the identity graph) is + * imported into a fresh store, and the two stores must agree on: + * + * - every live row WITH its validity window, uniqueness reservations, and + * edge claim rows (`dumpRoundTripState`); + * - the identity ledger — assertions, closure, separation — and the source's + * transition log, restored on the target; + * - `planDelete` of every whole, and `parts()` / `wholes()` / + * `subgraph({ composition: true })` now and at sampled past coordinates. + * + * The oracle is the source store itself: an independent model of composition + * would have to re-derive the cascade, the claim axes and the identity closure; + * the round trip only has to preserve whatever the source decided. + * + * MUTATION CHECK (each verified and reverted): + * - `requiredTemporalFields` (src/interchange/export.ts) answering + * `undefined` for an active-only graph — the default export drops every + * `validTo`, so an ended `oneActive` row re-imports as current. + * - skipping `produceIdentityTransitionChunks` in an archival export — the + * target restores no transition the source logged. + * - dropping `validFrom: window.validFrom` from the current-assertion insert + * in `importIdentityAssertionsIntoTarget` + * (src/identity/service-interchange-write.ts) — every imported assertion + * then opens at the import instant instead of its own. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; + +import { createStoreWithSchema } from "../../src"; +import { createTestBackend } from "../test-utils"; +import { + buildRoundTripGraph, + checkRoundTrip, + historyArb, + type RoundTripStore, +} from "./interchange-composition-model"; +import { propertyRuns } from "./run-budget"; + +async function createSqliteStore(identity: boolean): Promise { + const graph = buildRoundTripGraph({ + id: "interchange_composition_roundtrip", + identity, + }); + const [store] = await createStoreWithSchema( + graph, + createTestBackend(), + identity ? { history: true } : undefined, + ); + return store; +} + +describe("interchange round trip over composition and identity histories", () => { + for (const identity of [false, true]) { + it(`import(export(store)) preserves rows, windows, claims, ${identity ? "the identity ledger, " : ""}and composition reads`, async () => { + let applied = 0; + let refused = 0; + await fc.assert( + fc.asyncProperty(historyArb(identity), async (history) => { + const outcome = await checkRoundTrip(history, { + identity, + createStore: createSqliteStore, + }); + applied += outcome.applied; + refused += outcome.refused; + }), + { numRuns: propertyRuns({ ci: 25, local: 40 }) }, + ); + // A history that every store refused would round-trip trivially. + expect(applied).toBeGreaterThan(refused / 2); + }, 120_000); + } +}); diff --git a/packages/typegraph/tests/property/kind-registry.test.ts b/packages/typegraph/tests/property/kind-registry.test.ts index f3045c8fe..bbe538c4e 100644 --- a/packages/typegraph/tests/property/kind-registry.test.ts +++ b/packages/typegraph/tests/property/kind-registry.test.ts @@ -20,6 +20,8 @@ import { createEmptyClosures, KindRegistry, } from "../../src/registry/kind-registry"; +import { requireDefined } from "../../src/utils/presence"; +import { propertyRuns } from "./run-budget"; // ============================================================ // Helpers for Test Data Generation @@ -69,6 +71,26 @@ function createRelation( }; } +/** + * Creates an ontology relation with a RAW endpoint — a plain string used + * as-is rather than resolved to a registered `NodeType`. Needed to author an + * external-IRI endpoint (e.g. `"https://schema.org/Person"`): `getKindName` + * passes a string straight through, so this reaches the same closure code a + * graph-extension document's untyped `from`/`to` strings do, without routing + * an IRI through `defineNode`. + */ +function createRawRelation( + from: string, + metaEdgeName: string, + to: string, +): OntologyRelation { + const metaEdge = metaEdgeMap[metaEdgeName]; + if (!metaEdge) { + throw new Error(`Unknown meta-edge: ${metaEdgeName}`); + } + return { metaEdge, from, to }; +} + /** * Creates a KindRegistry from ontology relations. */ @@ -190,6 +212,73 @@ describe("KindRegistry Subsumption Properties", () => { { numRuns: 20 }, ); }); + + it("irreflexivity survives equivalentTo folding into a subsumption cycle", () => { + // The empty-ontology irreflexivity case above cannot catch a regression + // in `withoutSelfMembership`: with no relations, `subClassAncestors` has + // no cycles to strip in the first place. A plain `equivalentTo(A, B)` + // with no other subClassOf relation doesn't cycle either under the + // registry's collapse-to-one-representative construction (see + // `computeEquivalenceRepresentatives`) — the class collapses to ONE + // node with no self-edge. A genuine cross-class subClassOf CYCLE + // between two equivalence classes' representatives (`A ⊑ X` and + // `X ⊑ A`, with `A ≡ B` and `X ≡ Y`) DOES make each representative + // reach itself in the collapsed closure, which then propagates into + // every member of both classes — exactly where a missing + // self-membership strip would surface. (Such an ontology is separately + // rejected as `ONTOLOGY_CYCLE` by `validateOntologyRelations`, but + // `detectDisjointExpansionConflicts` still runs the registry's closure + // builder on a cyclic ontology before that rejection is enforced, per + // its own documented contract.) + const kindArb = fc.constantFrom("A", "B", "X", "Y"); + fc.assert( + fc.property(kindArb, (kind) => { + const relations = [ + createRelation("A", META_EDGE_EQUIVALENT_TO, "B"), + createRelation("X", META_EDGE_EQUIVALENT_TO, "Y"), + createRelation("A", META_EDGE_SUB_CLASS_OF, "X"), + createRelation("X", META_EDGE_SUB_CLASS_OF, "A"), + ]; + const registry = createRegistry(relations); + expect(registry.isSubClassOf(kind, kind)).toBe(false); + const expanded = registry.expandSubClasses(kind); + expect(new Set(expanded).size).toBe(expanded.length); + }), + { numRuns: 10 }, + ); + }); + + it("irreflexivity survives a LEGAL ontology whose fold creates a cycle", () => { + // Unlike the case above, this ontology is never rejected: + // `validateOntologyRelations` only inspects raw `subClassOf` edges + // (A subClassOf C, C subClassOf B — not a cycle, since B and A are + // different names at that layer), so this graph builds cleanly. The + // cycle only appears AFTER `equivalentTo(A, B)` collapses A and B to one + // representative: C's representative then reaches A's representative + // and back. This is the shape `withoutSelfMembership`'s docstring leads + // with; the sibling test above exercises the separately-rejected + // directly-cyclic shape instead. + const kindArb = fc.constantFrom("A", "B", "C"); + fc.assert( + fc.property(kindArb, (kind) => { + const relations = [ + createRelation("A", META_EDGE_EQUIVALENT_TO, "B"), + createRelation("A", META_EDGE_SUB_CLASS_OF, "C"), + createRelation("C", META_EDGE_SUB_CLASS_OF, "B"), + ]; + const registry = createRegistry(relations); + expect(registry.isSubClassOf(kind, kind)).toBe(false); + const expanded = registry.expandSubClasses(kind); + expect(new Set(expanded).size).toBe(expanded.length); + // The forced consequence: C sits strictly between two equivalents, + // so it collapses into the same class as A and B. + expect(registry.isAssignableTo("A", "C")).toBe(true); + expect(registry.isAssignableTo("C", "A")).toBe(true); + expect(registry.isAssignableTo("B", "C")).toBe(true); + }), + { numRuns: 10 }, + ); + }); }); describe("expandSubClasses", () => { @@ -243,6 +332,93 @@ describe("KindRegistry Subsumption Properties", () => { // Animal is in Dog's ancestors iff Dog is in Animal's descendants expect(registry.getDescendants("Animal").has("Dog")).toBe(true); }); + + // Regression coverage for a defect found reviewing the equivalence fold: an `equivalentTo` + // class routed through an external IRI that is ALSO a `subClassOf` + // endpoint made `subClassAncestors`/`subClassDescendants` stop being + // inverses of each other, so `isSubClassOf`/`isAssignableTo` disagreed + // with `expandSubClasses`/`getDescendants` about the very same pair. Both + // shapes below reproduced the divergence before `representativeOf` + // mapped an IRI to its class's representative (kind-registry.ts, + // `computeEquivalenceRepresentatives`): reverting that fix — dropping the + // IRI from `representativeOf` again — makes both cases below fail. + it("keeps ancestors/descendants exact inverses when a subClassOf endpoint is an equivalence-routed IRI", () => { + const externalIri = "https://schema.org/Person"; + + // Shape 1: Student subClassOf , with Person and Individual both + // equivalentTo the SAME IRI (so Person, Individual, and — transitively + // through the IRI — Student's ancestor chain all fold together). + const shapeOne = createRegistry([ + createRawRelation("Person", META_EDGE_EQUIVALENT_TO, externalIri), + createRawRelation("Individual", META_EDGE_EQUIVALENT_TO, externalIri), + createRawRelation("Student", META_EDGE_SUB_CLASS_OF, externalIri), + ]); + + expect(shapeOne.isSubClassOf("Student", "Person")).toBe(true); + expect(shapeOne.expandSubClasses("Person")).toContain("Student"); + expect(shapeOne.getDescendants("Person").has("Student")).toBe(true); + // The IRI itself is an inert reference — it must never surface as a + // member of any subsumption-reading result. + for (const kind of ["Student", "Person", "Individual"]) { + expect(shapeOne.getAncestors(kind).has(externalIri)).toBe(false); + expect(shapeOne.getDescendants(kind).has(externalIri)).toBe(false); + expect(shapeOne.expandSubClasses(kind)).not.toContain(externalIri); + } + + // Shape 2 (mirror): the IRI is the CHILD side of subClassOf instead of + // the parent, and is equivalentTo a kind that never subclasses + // anything else. + const shapeTwo = createRegistry([ + createRawRelation(externalIri, META_EDGE_SUB_CLASS_OF, "C"), + createRawRelation(externalIri, META_EDGE_EQUIVALENT_TO, "A"), + ]); + expect(shapeTwo.isAssignableTo("A", "C")).toBe(true); + expect(shapeTwo.expandSubClasses("C")).toContain("A"); + expect(shapeTwo.expandSubClasses("C")).not.toContain(externalIri); + }); + + it("ancestors and descendants stay exact inverses over random subClassOf/equivalentTo/IRI ontologies", () => { + const kindArb = fc.constantFrom("A", "B", "C", "D", "E"); + const endpointArb = fc.oneof( + kindArb, + fc.constant("https://example.com/shared"), + ); + const relationArb = fc + .tuple( + fc.constantFrom(META_EDGE_SUB_CLASS_OF, META_EDGE_EQUIVALENT_TO), + endpointArb, + endpointArb, + ) + .filter(([, from, to]) => from !== to); + const ontologyArb = fc.array(relationArb, { maxLength: 10 }); + + fc.assert( + fc.property(ontologyArb, (tuples) => { + const relations = tuples.map(([metaEdge, from, to]) => + createRawRelation(from, metaEdge, to), + ); + const registry = createRegistry(relations); + // The external IRI is deliberately EXCLUDED from this set: a + // `subClassOf` edge naming an IRI that belongs to no equivalence + // class at all is a separate, pre-existing gap (the IRI can still + // leak into one side's member set) that these confirmed findings + // do not cover. What this property pins is the one the findings + // DO cover: ancestors/descendants agree about every pair of real + // kinds, regardless of how an IRI-routed equivalence class + // reshuffles the closure around them. + const namedKinds = ["A", "B", "C", "D", "E"]; + + for (const left of namedKinds) { + for (const right of namedKinds) { + expect(registry.getAncestors(left).has(right)).toBe( + registry.getDescendants(right).has(left), + ); + } + } + }), + { numRuns: 500 }, + ); + }); }); }); @@ -732,3 +908,149 @@ describe("computeClosuresFromOntology Properties", () => { expect(registry.getSubClassComponent("Zeta")).toBe(component); }); }); + +// ============================================================ +// Closures against an independent reference model +// ============================================================ + +const MODEL_KINDS = ["K0", "K1", "K2", "K3", "K4", "K5"] as const; + +type ModelOntology = Readonly<{ + subClassOf: readonly (readonly [string, string])[]; + equivalentTo: readonly (readonly [string, string])[]; +}>; + +/** + * A `subClassOf` DAG (a child only ever names a parent declared before it) + * plus random `equivalentTo` pairs, which may fold the DAG into cycles. + */ +const modelOntologyArb: fc.Arbitrary = fc.record({ + subClassOf: fc.uniqueArray( + fc + .tuple( + fc.integer({ min: 0, max: MODEL_KINDS.length - 1 }), + fc.integer({ min: 0, max: MODEL_KINDS.length - 1 }), + ) + .filter(([child, parent]) => child > parent) + .map( + ([child, parent]) => + [ + requireDefined(MODEL_KINDS[child]), + requireDefined(MODEL_KINDS[parent]), + ] as const, + ), + { selector: ([child, parent]) => `${child}>${parent}`, maxLength: 8 }, + ), + equivalentTo: fc.uniqueArray( + fc + .tuple(fc.constantFrom(...MODEL_KINDS), fc.constantFrom(...MODEL_KINDS)) + .filter(([left, right]) => left !== right), + { selector: ([left, right]) => `${left}=${right}`, maxLength: 3 }, + ), +}); + +/** Equivalence classes by union-find over the declared pairs alone. */ +function referenceEquivalence( + ontology: ModelOntology, +): (left: string, right: string) => boolean { + const parentOf = new Map( + MODEL_KINDS.map((kind) => [kind, kind]), + ); + const find = (kind: string): string => { + const parent = requireDefined(parentOf.get(kind)); + if (parent === kind) return kind; + const root = find(parent); + parentOf.set(kind, root); + return root; + }; + for (const [left, right] of ontology.equivalentTo) { + parentOf.set(find(left), find(right)); + } + return (left, right) => left !== right && find(left) === find(right); +} + +/** + * Every kind reachable from `kind` in at least one step, where a + * `subClassOf` edge leads from child to parent and an `equivalentTo` pair + * leads both ways, by breadth-first search. `kind` itself is never its own + * ancestor. + */ +function referenceAncestors( + ontology: ModelOntology, + kind: string, + direction: "up" | "down", +): ReadonlySet { + const steps = new Map(); + const step = (from: string, to: string) => { + steps.set(from, [...(steps.get(from) ?? []), to]); + }; + for (const [child, parent] of ontology.subClassOf) { + if (direction === "up") step(child, parent); + else step(parent, child); + } + for (const [left, right] of ontology.equivalentTo) { + step(left, right); + step(right, left); + } + const reached = new Set(); + const queue = [...(steps.get(kind) ?? [])]; + while (queue.length > 0) { + const next = requireDefined(queue.shift()); + if (reached.has(next)) continue; + reached.add(next); + queue.push(...(steps.get(next) ?? [])); + } + reached.delete(kind); + return reached; +} + +describe("KindRegistry closures against a reference model", () => { + it("ancestors, descendants, equivalence and assignability match breadth-first search and union-find", () => { + fc.assert( + fc.property(modelOntologyArb, (ontology) => { + const registry = createRegistry([ + ...ontology.subClassOf.map(([child, parent]) => + createRelation(child, META_EDGE_SUB_CLASS_OF, parent), + ), + ...ontology.equivalentTo.map(([left, right]) => + createRelation(left, META_EDGE_EQUIVALENT_TO, right), + ), + ]); + const equivalent = referenceEquivalence(ontology); + + for (const kind of MODEL_KINDS) { + const ancestors = referenceAncestors(ontology, kind, "up"); + const descendants = referenceAncestors(ontology, kind, "down"); + expect({ + kind, + ancestors: [...registry.getAncestors(kind)].toSorted(), + }).toEqual({ kind, ancestors: [...ancestors].toSorted() }); + expect({ + kind, + descendants: [...registry.getDescendants(kind)].toSorted(), + }).toEqual({ kind, descendants: [...descendants].toSorted() }); + expect(registry.expandSubClasses(kind).toSorted()).toEqual( + [kind, ...descendants].toSorted(), + ); + for (const other of MODEL_KINDS) { + expect({ + kind, + other, + equivalent: registry.areEquivalent(kind, other), + }).toEqual({ kind, other, equivalent: equivalent(kind, other) }); + expect({ + kind, + other, + assignable: registry.isAssignableTo(kind, other), + }).toEqual({ + kind, + other, + assignable: kind === other || ancestors.has(other), + }); + } + } + }), + { numRuns: propertyRuns({ ci: 300, local: 1000 }) }, + ); + }); +}); diff --git a/packages/typegraph/tests/property/polymorphic-expansion.test.ts b/packages/typegraph/tests/property/polymorphic-expansion.test.ts new file mode 100644 index 000000000..f2c69cb5e --- /dev/null +++ b/packages/typegraph/tests/property/polymorphic-expansion.test.ts @@ -0,0 +1,198 @@ +/** + * A polymorphic `from(K)` / `to(K)` returns rows of exactly the kinds + * assignable to `K` — `K` itself plus everything below it through + * `subClassOf`, where an `equivalentTo` pair counts as a subclass edge both + * ways — over a random ontology. The reference closure is a plain BFS over the + * declared relations, sharing nothing with the registry's collapsed-closure + * build (equivalence representatives, transitive closure, inversion, + * re-expansion), which is where a wrong fold would hide. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + equivalentTo, + type OntologyRelation, + subClassOf, +} from "../../src"; +import { createLocalSqliteBackend } from "../../src/backend/sqlite/local"; +import { propertyRuns } from "./run-budget"; + +const KIND_NAMES = ["PeK0", "PeK1", "PeK2", "PeK3", "PeK4", "PeK5"] as const; +type KindName = (typeof KIND_NAMES)[number]; + +const KIND_TYPES = KIND_NAMES.map((name) => + defineNode(name, { schema: z.object({}) }), +); +const Anchor = defineNode("PeAnchor", { schema: z.object({}) }); +const peLinks = defineEdge("peLinks", { schema: z.object({}) }); + +function kindType(index: number) { + const type = KIND_TYPES[index]; + if (type === undefined) throw new Error(`no kind at index ${index}`); + return type; +} + +type OntologyCase = Readonly<{ + /** `[child, parent]` index pairs; child > parent keeps `subClassOf` acyclic. */ + subClass: readonly (readonly [number, number])[]; + equivalent: readonly (readonly [number, number])[]; +}>; + +const indexPair = fc + .tuple( + fc.integer({ min: 0, max: KIND_NAMES.length - 1 }), + fc.integer({ min: 0, max: KIND_NAMES.length - 1 }), + ) + .filter(([left, right]) => left !== right); + +const ontologyCaseArbitrary: fc.Arbitrary = fc.record({ + subClass: fc.uniqueArray( + indexPair.map( + ([left, right]) => + [Math.max(left, right), Math.min(left, right)] as const, + ), + { maxLength: 7, selector: ([child, parent]) => `${child}<${parent}` }, + ), + equivalent: fc.uniqueArray( + indexPair.map( + ([left, right]) => + [Math.min(left, right), Math.max(left, right)] as const, + ), + { maxLength: 2, selector: ([left, right]) => `${left}=${right}` }, + ), +}); + +function buildGraph(ontologyCase: OntologyCase, runId: number) { + const ontology: OntologyRelation[] = [ + ...ontologyCase.subClass.map(([child, parent]) => + subClassOf(kindType(child), kindType(parent)), + ), + ...ontologyCase.equivalent.map(([left, right]) => + equivalentTo(kindType(left), kindType(right)), + ), + ]; + return defineGraph({ + id: `property_polymorphic_expansion_${runId}`, + nodes: { + PeAnchor: { type: Anchor }, + PeK0: { type: kindType(0) }, + PeK1: { type: kindType(1) }, + PeK2: { type: kindType(2) }, + PeK3: { type: kindType(3) }, + PeK4: { type: kindType(4) }, + PeK5: { type: kindType(5) }, + }, + edges: { + peLinks: { type: peLinks, from: [Anchor], to: KIND_TYPES }, + }, + ontology, + }); +} + +/** Every kind reachable from `root` by stepping to a declared subclass or to either side of an equivalence. */ +function referenceClosure( + ontologyCase: OntologyCase, + root: number, +): readonly KindName[] { + const below = new Map(); + function link(from: number, to: number): void { + const targets = below.get(from) ?? []; + targets.push(to); + below.set(from, targets); + } + for (const [child, parent] of ontologyCase.subClass) link(parent, child); + for (const [left, right] of ontologyCase.equivalent) { + link(left, right); + link(right, left); + } + const reached = new Set([root]); + const queue = [root]; + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + for (const target of below.get(next) ?? []) { + if (reached.has(target)) continue; + reached.add(target); + queue.push(target); + } + } + return [...reached] + .map((index) => KIND_NAMES[index]) + .filter((name): name is KindName => name !== undefined) + .toSorted(); +} + +function sortedKinds(rows: readonly string[]): readonly string[] { + return rows.toSorted(); +} + +describe("property: polymorphic expansion follows the declared subsumption closure", () => { + it("from(K) and to(K) return exactly the kinds assignable to K, with and without a stated subclasses axis", async () => { + let runId = 0; + await fc.assert( + fc.asyncProperty(ontologyCaseArbitrary, async (ontologyCase) => { + runId += 1; + const { backend } = createLocalSqliteBackend(); + try { + const [store] = await createStoreWithSchema( + buildGraph(ontologyCase, runId), + backend, + ); + const anchor = await store.nodes.PeAnchor.create({}); + for (const name of KIND_NAMES) { + const node = await store.nodes[name].create({}); + await store.edges.peLinks.create(anchor, node, {}); + } + + for (const [index, name] of KIND_NAMES.entries()) { + const expected = referenceClosure(ontologyCase, index); + const reads = { + "from()": () => + store + .query() + .from(name, "n") + .select((ctx) => ctx.n.kind) + .execute(), + 'from() expansion: "subclasses"': () => + store + .query() + .from(name, "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n.kind) + .execute(), + "to()": () => + store + .query() + .from("PeAnchor", "a") + .traverse("peLinks", "e") + .to(name, "n") + .select((ctx) => ctx.n.kind) + .execute(), + 'to() expansion: "subclasses"': () => + store + .query() + .from("PeAnchor", "a") + .traverse("peLinks", "e") + .to(name, "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n.kind) + .execute(), + }; + for (const [surface, read] of Object.entries(reads)) { + expect({ + kind: name, + surface, + kinds: sortedKinds(await read()), + }).toEqual({ kind: name, surface, kinds: expected }); + } + } + } finally { + await backend.close(); + } + }), + { numRuns: propertyRuns({ ci: 25, local: 60 }) }, + ); + }); +}); diff --git a/packages/typegraph/tests/property/prepared-queries.test.ts b/packages/typegraph/tests/property/prepared-queries.test.ts index bd6c348b8..82ea7aa05 100644 --- a/packages/typegraph/tests/property/prepared-queries.test.ts +++ b/packages/typegraph/tests/property/prepared-queries.test.ts @@ -2,11 +2,20 @@ * Property Tests — Prepared Query Parameter System * * Tests invariants of parameter collection, substitution, and validation - * using fast-check to generate arbitrary predicate trees. + * using fast-check to generate arbitrary predicate trees, and that a prepared + * query executed over a store answers what the inline query does. */ import fc from "fast-check"; import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + createStore, + defineGraph, + defineNode, + param as parameter, +} from "../../src"; +import { projectBackendWithout } from "../../src/backend/derive-backend"; import type { BetweenPredicate, ComparisonPredicate, @@ -22,6 +31,10 @@ import { composableQueryHasParameterReferences, hasParameterReferences, } from "../../src/query/builder/prepared-query"; +import { type Predicate } from "../../src/query/predicates"; +import { compareStrings } from "../../src/utils/compare"; +import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; // ============================================================ // Arbitraries @@ -133,7 +146,7 @@ function makeAstFromPredicates( having?: PredicateExpression, ): QueryAst { return { - start: { alias: "p", kinds: ["Person"], includeSubClasses: false }, + start: { alias: "p", kinds: ["Person"], expansion: "exact" as const }, traversals: [], predicates, projection: { fields: [] }, @@ -357,3 +370,472 @@ describe("Parameter Name Collection Consistency", () => { ); }); }); + +// ============================================================ +// Prepared vs inline execution over a store +// ============================================================ + +/** + * A prepared query executed with a binding must return exactly what the same + * query with the values written inline returns, and both must match an + * in-memory filter of the rows. Each prepared query runs with two bindings + * in turn, so a value cached from the first execution shows up in the second. + * + * Names are lowercase ASCII, so the engines' string order and `LIKE` agree + * with JavaScript's code-point comparison. + */ +const PqPerson = defineNode("PqPerson", { + schema: z.object({ name: z.string(), age: z.number().int() }), +}); + +const storeGraph = defineGraph({ + id: "prop_prepared_store", + nodes: { PqPerson: { type: PqPerson } }, + edges: {}, +}); + +const PQ_NAMES = ["a", "ab", "b", "ba", "c"] as const; +const pqNameArb = fc.constantFrom(...PQ_NAMES); +const pqAgeArb = fc.integer({ min: 0, max: 5 }); + +type PqRow = Readonly<{ id: string; name: string; age: number }>; + +type PqLeaf = + | Readonly<{ + field: "name"; + op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte" | "startsWith"; + values: readonly [string, string]; + }> + | Readonly<{ + field: "name"; + op: "in" | "notIn"; + values: readonly [readonly string[], readonly string[]]; + }> + | Readonly<{ + field: "age"; + op: "eq" | "neq" | "gt" | "gte" | "lt" | "lte"; + values: readonly [number, number]; + }> + | Readonly<{ + field: "age"; + op: "in" | "notIn"; + values: readonly [readonly number[], readonly number[]]; + }> + | Readonly<{ + field: "age"; + op: "between"; + values: readonly [readonly [number, number], readonly [number, number]]; + }>; + +type PqTree = + | Readonly<{ type: "leaf"; leaf: PqLeaf }> + | Readonly<{ type: "and" | "or"; left: PqTree; right: PqTree }> + | Readonly<{ type: "not"; inner: PqTree }>; + +const pqPair = (arbitrary: fc.Arbitrary) => + fc.tuple(arbitrary, arbitrary); + +const pqLeafArb: fc.Arbitrary = fc.oneof( + fc.record({ + field: fc.constant("name" as const), + op: fc.constantFrom( + "eq" as const, + "neq" as const, + "gt" as const, + "gte" as const, + "lt" as const, + "lte" as const, + "startsWith" as const, + ), + values: pqPair(pqNameArb), + }), + fc.record({ + field: fc.constant("name" as const), + op: fc.constantFrom("in" as const, "notIn" as const), + values: pqPair(fc.array(pqNameArb, { minLength: 1, maxLength: 3 })), + }), + fc.record({ + field: fc.constant("age" as const), + op: fc.constantFrom( + "eq" as const, + "neq" as const, + "gt" as const, + "gte" as const, + "lt" as const, + "lte" as const, + ), + values: pqPair(pqAgeArb), + }), + fc.record({ + field: fc.constant("age" as const), + op: fc.constantFrom("in" as const, "notIn" as const), + values: pqPair(fc.array(pqAgeArb, { minLength: 1, maxLength: 3 })), + }), + fc.record({ + field: fc.constant("age" as const), + op: fc.constant("between" as const), + values: pqPair(fc.tuple(pqAgeArb, pqAgeArb)), + }), +); + +const pqTreeArb: fc.Arbitrary = fc.letrec<{ tree: PqTree }>((tie) => ({ + tree: fc.oneof( + { depthSize: "small", withCrossShrink: true }, + pqLeafArb.map((leaf) => ({ type: "leaf", leaf }) as const), + fc + .record({ + type: fc.constantFrom("and" as const, "or" as const), + left: tie("tree"), + right: tie("tree"), + }) + .map((node) => node as PqTree), + tie("tree").map((inner) => ({ type: "not", inner }) as const), + ), +})).tree; + +function pqCompare(op: "gt" | "gte" | "lt" | "lte", order: number): boolean { + switch (op) { + case "gt": { + return order > 0; + } + case "gte": { + return order >= 0; + } + case "lt": { + return order < 0; + } + case "lte": { + return order <= 0; + } + } +} + +/** The in-memory reference: does `row` satisfy `leaf` under binding `slot`? */ +function pqLeafHolds(leaf: PqLeaf, slot: 0 | 1, row: PqRow): boolean { + switch (leaf.op) { + case "between": { + const [lower, upper] = leaf.values[slot]; + return row.age >= lower && row.age <= upper; + } + case "in": + case "notIn": { + const listed = + leaf.field === "name" ? + leaf.values[slot].includes(row.name) + : leaf.values[slot].includes(row.age); + return leaf.op === "in" ? listed : !listed; + } + case "startsWith": { + return row.name.startsWith(leaf.values[slot]); + } + case "eq": + case "neq": { + const equal = + leaf.field === "name" ? + row.name === leaf.values[slot] + : row.age === leaf.values[slot]; + return leaf.op === "eq" ? equal : !equal; + } + case "gt": + case "gte": + case "lt": + case "lte": { + const order = + leaf.field === "name" ? + compareStrings(row.name, leaf.values[slot]) + : row.age - leaf.values[slot]; + return pqCompare(leaf.op, order); + } + } +} + +function pqTreeHolds(tree: PqTree, slot: 0 | 1, row: PqRow): boolean { + switch (tree.type) { + case "leaf": { + return pqLeafHolds(tree.leaf, slot, row); + } + case "and": { + return ( + pqTreeHolds(tree.left, slot, row) && pqTreeHolds(tree.right, slot, row) + ); + } + case "or": { + return ( + pqTreeHolds(tree.left, slot, row) || pqTreeHolds(tree.right, slot, row) + ); + } + case "not": { + return !pqTreeHolds(tree.inner, slot, row); + } + } +} + +/** + * Builds the predicate for `tree`. `operand` turns a leaf's value into what + * the field method takes: the literal for the inline query, a `param()` + * reference (named after the leaf's position) for the prepared one, while + * `bind` records that parameter's value for the binding. + */ +type PqOperands = Readonly<{ + name: ( + position: string, + value: string, + ) => string | ReturnType; + age: ( + position: string, + value: number, + ) => number | ReturnType; + names: ( + position: string, + value: readonly string[], + ) => readonly string[] | ReturnType; + ages: ( + position: string, + value: readonly number[], + ) => readonly number[] | ReturnType; +}>; + +type PqNodeFields = Readonly<{ + name: Readonly<{ + eq: (value: string | ReturnType) => Predicate; + neq: (value: string | ReturnType) => Predicate; + gt: (value: string | ReturnType) => Predicate; + gte: (value: string | ReturnType) => Predicate; + lt: (value: string | ReturnType) => Predicate; + lte: (value: string | ReturnType) => Predicate; + startsWith: (value: string | ReturnType) => Predicate; + in: (values: readonly string[] | ReturnType) => Predicate; + notIn: ( + values: readonly string[] | ReturnType, + ) => Predicate; + }>; + age: Readonly<{ + eq: (value: number | ReturnType) => Predicate; + neq: (value: number | ReturnType) => Predicate; + gt: (value: number | ReturnType) => Predicate; + gte: (value: number | ReturnType) => Predicate; + lt: (value: number | ReturnType) => Predicate; + lte: (value: number | ReturnType) => Predicate; + in: (values: readonly number[] | ReturnType) => Predicate; + notIn: ( + values: readonly number[] | ReturnType, + ) => Predicate; + between: ( + lower: number | ReturnType, + upper: number | ReturnType, + ) => Predicate; + }>; +}>; + +function pqLeafPredicate( + fields: PqNodeFields, + leaf: PqLeaf, + position: string, + operands: PqOperands, +): Predicate { + switch (leaf.op) { + case "between": { + const [lower, upper] = leaf.values[0]; + return fields.age.between( + operands.age(`${position}lo`, lower), + operands.age(`${position}hi`, upper), + ); + } + case "in": + case "notIn": { + return leaf.field === "name" ? + fields.name[leaf.op](operands.names(position, leaf.values[0])) + : fields.age[leaf.op](operands.ages(position, leaf.values[0])); + } + case "startsWith": { + return fields.name.startsWith(operands.name(position, leaf.values[0])); + } + case "eq": + case "neq": + case "gt": + case "gte": + case "lt": + case "lte": { + return leaf.field === "name" ? + fields.name[leaf.op](operands.name(position, leaf.values[0])) + : fields.age[leaf.op](operands.age(position, leaf.values[0])); + } + } +} + +function pqTreePredicate( + fields: PqNodeFields, + tree: PqTree, + position: string, + operands: PqOperands, +): Predicate { + switch (tree.type) { + case "leaf": { + return pqLeafPredicate(fields, tree.leaf, position, operands); + } + case "and": + case "or": { + const left = pqTreePredicate(fields, tree.left, `${position}l`, operands); + const right = pqTreePredicate( + fields, + tree.right, + `${position}r`, + operands, + ); + return tree.type === "and" ? left.and(right) : left.or(right); + } + case "not": { + return pqTreePredicate( + fields, + tree.inner, + `${position}n`, + operands, + ).not(); + } + } +} + +/** The binding that gives the prepared query the values in `slot`. */ +function pqBindings( + tree: PqTree, + slot: 0 | 1, + position = "p", +): Record { + switch (tree.type) { + case "leaf": { + const { leaf } = tree; + if (leaf.op === "between") { + const [lower, upper] = leaf.values[slot]; + return { [`${position}lo`]: lower, [`${position}hi`]: upper }; + } + return { [position]: leaf.values[slot] }; + } + case "and": + case "or": { + return { + ...pqBindings(tree.left, slot, `${position}l`), + ...pqBindings(tree.right, slot, `${position}r`), + }; + } + case "not": { + return pqBindings(tree.inner, slot, `${position}n`); + } + } +} + +/** Swaps a tree's two value slots, so slot 0 spells the second binding inline. */ +function pqSwapSlots(tree: PqTree): PqTree { + switch (tree.type) { + case "leaf": { + const [first, second] = tree.leaf.values; + return { + type: "leaf", + leaf: { ...tree.leaf, values: [second, first] } as PqLeaf, + }; + } + case "and": + case "or": { + return { + type: tree.type, + left: pqSwapSlots(tree.left), + right: pqSwapSlots(tree.right), + }; + } + case "not": { + return { type: "not", inner: pqSwapSlots(tree.inner) }; + } + } +} + +const INLINE_OPERANDS: PqOperands = { + name: (_, value) => value, + age: (_, value) => value, + names: (_, value) => value, + ages: (_, value) => value, +}; + +const PARAMETER_OPERANDS: PqOperands = { + name: (position) => parameter(position), + age: (position) => parameter(position), + names: (position) => parameter(position), + ages: (position) => parameter(position), +}; + +/** + * Prepared execution runs a cached SQL template through `executeRaw` when the + * backend has it, and substitutes the bindings into the AST and compiles + * afresh when it does not; each lane must answer the same. + */ +const PREPARED_EXECUTION_LANES = [ + { lane: "the executeRaw template", withoutExecuteRaw: false }, + { lane: "the substituted-AST fallback", withoutExecuteRaw: true }, +] as const; + +describe("prepared vs inline execution over a store", () => { + it.each(PREPARED_EXECUTION_LANES)( + "returns the inline query's rows and the in-memory filter's, for each of two bindings, through $lane", + async ({ withoutExecuteRaw }) => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.record({ name: pqNameArb, age: pqAgeArb }), { + minLength: 1, + maxLength: 10, + }), + fc.array(pqTreeArb, { minLength: 1, maxLength: 3 }), + async (people, trees) => { + const backend = createTestBackend(); + try { + const store = createStore( + storeGraph, + withoutExecuteRaw ? + projectBackendWithout(backend, ["executeRaw"]) + : backend, + ); + const rows: PqRow[] = []; + for (const person of people) { + const created = await store.nodes.PqPerson.create(person); + rows.push({ id: created.id, ...person }); + } + + const run = (tree: PqTree, operands: PqOperands) => + store + .query() + .from("PqPerson", "person") + .whereNode("person", (fields) => + pqTreePredicate(fields, tree, "p", operands), + ) + .orderBy("person", "id", "asc") + .select((ctx) => ctx.person.id); + + for (const tree of trees) { + const prepared = run(tree, PARAMETER_OPERANDS).prepare(); + for (const slot of [0, 1] as const) { + const inlineTree = slot === 0 ? tree : pqSwapSlots(tree); + const expected = rows + .filter((row) => pqTreeHolds(tree, slot, row)) + .map((row) => row.id) + .toSorted(compareStrings); + expect({ + tree, + slot, + prepared: await prepared.execute(pqBindings(tree, slot)), + inline: await run(inlineTree, INLINE_OPERANDS).execute(), + }).toEqual({ + tree, + slot, + prepared: expected, + inline: expected, + }); + } + } + } finally { + await backend.close(); + } + }, + ), + { numRuns: propertyRuns({ ci: 100, local: 250 }) }, + ); + }, + 120_000, + ); +}); diff --git a/packages/typegraph/tests/property/run-budget.ts b/packages/typegraph/tests/property/run-budget.ts new file mode 100644 index 000000000..07fb943bf --- /dev/null +++ b/packages/typegraph/tests/property/run-budget.ts @@ -0,0 +1,26 @@ +/** + * The run count a property test asks fast-check for: `ci` under CI, `local` + * otherwise, both multiplied by `TYPEGRAPH_PROPERTY_RUNS_SCALE` (a positive + * number, default 1) so a deep run raises every budget at once without + * editing tests. + */ +export function propertyRuns( + budget: Readonly<{ ci: number; local: number }>, +): number { + const base = process.env["CI"] ? budget.ci : budget.local; + return Math.max(1, Math.round(base * propertyRunScale())); +} + +const PROPERTY_RUNS_SCALE_ENV = "TYPEGRAPH_PROPERTY_RUNS_SCALE"; + +function propertyRunScale(): number { + const raw = process.env[PROPERTY_RUNS_SCALE_ENV]; + if (raw === undefined || raw === "") return 1; + const scale = Number(raw); + if (!Number.isFinite(scale) || scale <= 0) { + throw new Error( + `${PROPERTY_RUNS_SCALE_ENV} must be a positive number, got "${raw}".`, + ); + } + return scale; +} diff --git a/packages/typegraph/tests/property/schema-serialization.test.ts b/packages/typegraph/tests/property/schema-serialization.test.ts index 8543790d2..0a2903087 100644 --- a/packages/typegraph/tests/property/schema-serialization.test.ts +++ b/packages/typegraph/tests/property/schema-serialization.test.ts @@ -13,6 +13,7 @@ import { type KindAnnotations, type TemporalMode, } from "../../src/core/types"; +import { ConfigurationError } from "../../src/errors"; import { defineGraphExtension } from "../../src/graph-extension"; import { mergeGraphExtension } from "../../src/graph-extension/merge"; import { @@ -30,14 +31,17 @@ import { subClassOf, } from "../../src/ontology/core-meta-edges"; import { validateOntologyRelations } from "../../src/ontology/validation"; +import { buildKindRegistry } from "../../src/registry"; import { sortedReplacer } from "../../src/schema/canonical"; import { deserializeSchema } from "../../src/schema/deserializer"; +import { computeSchemaDiff } from "../../src/schema/migration"; import { computeSchemaHash, serializeSchema, } from "../../src/schema/serializer"; import { serializedSchemaZod } from "../../src/schema/types"; import { requireDefined } from "../../src/utils/presence"; +import { propertyRuns } from "./run-budget"; // ============================================================ // Arbitrary Generators @@ -384,7 +388,13 @@ const graphDefArb = fc nodeTypes.length >= 2 ? fc .array( - fc.integer({ min: 0, max: 5 }).chain((relationType) => { + // `partOf`/`hasPart` are deliberately not generated here: they + // now require a `via` edge kind whose registration (existence, + // endpoint compatibility, a constraining cardinality) this + // generic relation-shape fuzzer has no way to guarantee. + // Composition's own structural and registration-dependent rules + // are covered by `tests/ontology-composition-declaration.test.ts`. + fc.integer({ min: 0, max: 3 }).chain((relationType) => { // Pick two distinct nodes return fc .record({ @@ -408,12 +418,6 @@ const graphDefArb = fc case 3: { return relatedTo(from, to); } - case 4: { - return partOf(from, to); - } - case 5: { - return hasPart(from, to); - } default: { return equivalentTo(from, to); } @@ -487,6 +491,101 @@ const graphDefArb = fc }); }); +type GraphUnderTest = Parameters[0]; + +function buildsRegistry(graph: GraphUnderTest): boolean { + try { + buildKindRegistry(graph); + return true; + } catch (error) { + if (error instanceof ConfigurationError) return false; + throw error; + } +} +type DeclaredRelation = GraphUnderTest["ontology"][number]; + +/** A symmetric relation's factory, keyed by meta-edge name. */ +const SYMMETRIC_RELATION_FACTORIES: Readonly< + Record< + string, + ( + left: ReturnType, + right: ReturnType, + ) => DeclaredRelation + > +> = { + disjointWith: (left, right) => disjointWith(left, right), + relatedTo: (left, right) => relatedTo(left, right), + equivalentTo: (left, right) => equivalentTo(left, right), +}; + +function withSwappedArguments(relation: DeclaredRelation): DeclaredRelation { + const factory = SYMMETRIC_RELATION_FACTORIES[relation.metaEdge.name]; + if ( + factory === undefined || + typeof relation.from === "string" || + typeof relation.to === "string" + ) { + return relation; + } + return factory( + relation.to as ReturnType, + relation.from as ReturnType, + ); +} + +/** + * `graph` declared again with its node, edge and relation order shuffled + * and some symmetric relations' arguments swapped. + */ +function restatedGraphArb( + graph: GraphUnderTest, +): fc.Arbitrary< + Readonly<{ graph: GraphUnderTest; kindsOnly: GraphUnderTest }> +> { + const nodeEntries = Object.entries(graph.nodes); + const edgeEntries = Object.entries(graph.edges); + const relations = graph.ontology; + const declare = ( + nodes: typeof nodeEntries, + edges: typeof edgeEntries, + ontology: readonly DeclaredRelation[], + ) => + defineGraph({ + id: graph.id, + nodes: Object.fromEntries(nodes), + edges: Object.fromEntries(edges), + ontology, + defaults: graph.defaults, + }) as unknown as GraphUnderTest; + return fc + .record({ + nodeOrder: fc.shuffledSubarray(nodeEntries, { + minLength: nodeEntries.length, + }), + edgeOrder: fc.shuffledSubarray(edgeEntries, { + minLength: edgeEntries.length, + }), + relationOrder: fc.shuffledSubarray([...relations], { + minLength: relations.length, + }), + swaps: fc.array(fc.boolean(), { + minLength: relations.length, + maxLength: relations.length, + }), + }) + .map(({ nodeOrder, edgeOrder, relationOrder, swaps }) => ({ + graph: declare( + nodeOrder, + edgeOrder, + relationOrder.map((relation, index) => + swaps[index] === true ? withSwappedArguments(relation) : relation, + ), + ), + kindsOnly: declare(nodeOrder, edgeOrder, relations), + })); +} + /** * Generate a version number. */ @@ -562,12 +661,24 @@ describe("Schema Serialization Properties", () => { ); }); - it("serialize -> JSON -> Zod parse -> JSON is byte-identical", () => { + it("JSON -> Zod parse -> JSON is stable under a second round-trip", () => { fc.assert( fc.property(graphDefArb, versionArb, (graph, version) => { const serialized = serializeSchema(graph, version); - const canonicalBefore = JSON.stringify(serialized, sortedReplacer); + // The FIRST parse is deliberately not compared against the raw + // `serialized` object's own JSON: `targetCardinality` (see + // `SerializedEdgeDef`) is omitted at serialize time when it's the + // default `"many"`, and the zod schema backfills it with + // `.default("many")` on read — the same asymmetry a document + // stored before the option existed relies on to keep loading as + // unconstrained. That first parse is where the one intentional + // rewrite happens; what this test actually guards is that parsing + // is otherwise idempotent — a document already round-tripped once + // (as every document read back from storage has been) must not + // keep drifting on every subsequent parse. + const onceParsed = serializedSchemaZod.parse(serialized); + const canonicalBefore = JSON.stringify(onceParsed, sortedReplacer); const reparsed = serializedSchemaZod.parse( JSON.parse(canonicalBefore), ); @@ -581,18 +692,35 @@ describe("Schema Serialization Properties", () => { }); describe("hash properties", () => { - it("hash is deterministic (same input produces same hash)", async () => { + it("declaration order and a symmetric relation's argument order change nothing", async () => { await fc.assert( - fc.asyncProperty(graphDefArb, versionArb, async (graph, version) => { - const serialized1 = serializeSchema(graph, version); - const serialized2 = serializeSchema(graph, version); - - const hash1 = await computeSchemaHash(serialized1); - const hash2 = await computeSchemaHash(serialized2); - - expect(hash1).toBe(hash2); - }), - { numRuns: 50 }, + fc.asyncProperty( + graphDefArb.chain((graph) => + restatedGraphArb(graph).map( + (restated) => [graph, restated] as const, + ), + ), + async ([graph, restated]) => { + // A fuzzed subClassOf whose schemas do not nest is refused by the + // registry the diff builds; that refusal is covered below. + fc.pre(buildsRegistry(graph)); + const before = serializeSchema(graph, 1); + const after = serializeSchema(restated.graph, 2); + + expect(computeSchemaDiff(before, after)).toMatchObject({ + hasChanges: false, + nodes: [], + edges: [], + ontology: [], + }); + // Kind declaration order is not part of the document's content; + // relation order is, so only the kinds' reordering keeps the hash. + expect( + await computeSchemaHash(serializeSchema(restated.kindsOnly, 1)), + ).toBe(await computeSchemaHash(before)); + }, + ), + { numRuns: propertyRuns({ ci: 100, local: 200 }) }, ); }); @@ -1325,7 +1453,13 @@ describe("Schema Serialization Properties", () => { }); const serialized = serializeSchema(graph, 1); - const json = JSON.stringify(serialized, sortedReplacer); + // Parsed once before taking the baseline — see the comment on + // "JSON -> Zod parse -> JSON is stable under a second round-trip" + // above: `targetCardinality` is omitted at serialize time and + // backfilled by the zod default on the first parse, so the baseline + // here is the already-parsed form, not the raw serializer output. + const onceParsed = serializedSchemaZod.parse(serialized); + const json = JSON.stringify(onceParsed, sortedReplacer); const parsed = serializedSchemaZod.parse(JSON.parse(json)); const reSerialized = JSON.stringify(parsed, sortedReplacer); @@ -1495,20 +1629,180 @@ describe("Schema Serialization Properties", () => { }); describe("registry building", () => { - it("buildRegistry produces valid KindRegistry", () => { + it("buildRegistry produces a valid KindRegistry, or refuses a fuzzed structural mismatch by name", () => { fc.assert( fc.property(graphDefArb, versionArb, (graph, version) => { const serialized = serializeSchema(graph, version); const deserialized = deserializeSchema(serialized); - // Should not throw - const registry = deserialized.buildRegistry(); + // `graphDefArb` picks a fuzzed `subClassOf`/`equivalentTo` pair + // with no regard for schema compatibility, so a genuinely + // incompatible pair is a REACHABLE, correct outcome here + // — the property is that buildRegistry() only ever fails with + // this one typed, well-formed refusal, never an unrelated crash. + const ALLOWED_STRUCTURAL_REFUSAL_CODES = new Set([ + "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + "ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE", + "ONTOLOGY_EQUIVALENCE_SCHEMA_INCOMPARABLE", + ]); + + function buildRegistryOrAllowedRefusalCode(): unknown { + try { + return deserialized.buildRegistry(); + } catch (error) { + if ( + error instanceof ConfigurationError && + ALLOWED_STRUCTURAL_REFUSAL_CODES.has( + error.details["code"] as string, + ) + ) { + return error.details["code"]; + } + throw error; + } + } - // Registry should exist - expect(registry).toBeDefined(); + expect(buildRegistryOrAllowedRefusalCode()).toBeDefined(); }), { numRuns: 30 }, ); }); }); }); + +/** + * A focused round-trip property over a fixed composition shape. + * + * `partOf`/`hasPart` are deliberately excluded from the generic + * `ontologyArb` above (see its comment): a fuzzed `via` edge cannot be + * guaranteed endpoint-compatible or cardinality-constrained enough to + * build. This property fuzzes what two always-valid composition shapes can + * safely vary — the node/edge kind names, the declaration form (`partOf` + * vs. the mirrored `hasPart`), and the whole-side population: + * + * - a cross-kind pair, where orientation is unambiguous and `partSide` is + * INFERRED (never present on the wire) — this is what exercises `via` + * surviving the round trip; + * - a reflexive pair, where the edge admits both orientations and + * `partSide` is REQUIRED and DECLARED — this is what exercises + * `partSide` itself, the one field that cannot be re-derived on load + * A property that only ever generated the unambiguous shape + * would never put a `partSide` on the wire to lose. + */ +describe("composition relation round-trip", () => { + it("serializes and deserializes a fuzzed cross-kind partOf/hasPart pair's via and inferred orientation", () => { + fc.assert( + fc.property( + identifierArb, + identifierArb, + edgeIdentifierArb, + fc.constantFrom<"one" | "oneActive">("one", "oneActive"), + fc.boolean(), + versionArb, + ( + partKindName, + wholeKindName, + edgeKindName, + population, + declareAsHasPart, + version, + ) => { + fc.pre(partKindName !== wholeKindName); + + const Part = defineNode(partKindName, { schema: z.object({}) }); + const Whole = defineNode(wholeKindName, { schema: z.object({}) }); + const viaEdge = defineEdge(edgeKindName, { schema: z.object({}) }); + + const graph = defineGraph({ + id: "composition-roundtrip-property", + nodes: { + [partKindName]: { type: Part }, + [wholeKindName]: { type: Whole }, + }, + edges: { + [edgeKindName]: { + type: viaEdge, + from: [Part], + to: [Whole], + cardinality: population, + }, + }, + ontology: [ + declareAsHasPart ? + hasPart(Whole, Part, { via: viaEdge }) + : partOf(Part, Whole, { via: viaEdge }), + ], + }); + + const serialized = serializeSchema(graph, version); + const registry = deserializeSchema(serialized).buildRegistry(); + + expect(registry.compositionPartSide(edgeKindName)).toBe("from"); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: partKindName, + wholeKind: wholeKindName, + viaEdgeKind: edgeKindName, + partSide: "from", + population, + existence: "optional", + }, + ]); + }, + ), + { numRuns: 25 }, + ); + }); + + it("serializes and deserializes a fuzzed reflexive pair's declared partSide", () => { + fc.assert( + fc.property( + identifierArb, + edgeIdentifierArb, + fc.constantFrom<"one" | "oneActive">("one", "oneActive"), + fc.constantFrom<"from" | "to">("from", "to"), + versionArb, + (kindName, edgeKindName, population, partSide, version) => { + const Kind = defineNode(kindName, { schema: z.object({}) }); + const viaEdge = defineEdge(edgeKindName, { schema: z.object({}) }); + + const graph = defineGraph({ + id: "composition-roundtrip-reflexive-property", + nodes: { [kindName]: { type: Kind } }, + edges: { + [edgeKindName]: { + type: viaEdge, + from: [Kind], + to: [Kind], + // Set on both sides: the whole-side fence reads + // `cardinality` when `partSide` is "from" and + // `targetCardinality` when it is "to", and this property + // fuzzes `partSide` itself. + cardinality: population, + targetCardinality: population, + }, + }, + ontology: [partOf(Kind, Kind, { via: viaEdge, partSide })], + }); + + const serialized = serializeSchema(graph, version); + const registry = deserializeSchema(serialized).buildRegistry(); + + expect(registry.compositionPartSide(edgeKindName)).toBe(partSide); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: kindName, + wholeKind: kindName, + viaEdgeKind: edgeKindName, + partSide, + population, + existence: "optional", + }, + ]); + }, + ), + { numRuns: 25 }, + ); + }); +}); diff --git a/packages/typegraph/tests/property/structural-subtype-soundness.test.ts b/packages/typegraph/tests/property/structural-subtype-soundness.test.ts new file mode 100644 index 000000000..2590310ff --- /dev/null +++ b/packages/typegraph/tests/property/structural-subtype-soundness.test.ts @@ -0,0 +1,1250 @@ +/** + * Soundness of the live structural subtype check against Zod itself. + * + * The oracle is independent of the predicate: a pair the check accepts must + * have every value the CHILD's Zod schema stores accepted by the PARENT's + * Zod `safeParse`. Pairs are drawn as plain specs, built into Zod, and + * compared through `structuralSubtypeProjection` exactly as a live graph's + * registry compares them. A child is the parent spec under a few random + * edits (a near miss is where an unsound accept hides) or an unrelated spec. + * + * Sample inputs are read the way JSON reads them (an `undefined` member is + * absent): a non-JSON input such as `{ x: undefined }` passes an exhaustive + * enum-key record and fails its parent for a reason no stored row can + * reach. Dates (an invalid one included) and bigints are kept as values, + * so the nodes that parse them are judged on them too. Fallbacks + * (`.default()`, `.catch()`, `.prefault()`), pipes, `z.preprocess()` and + * records with optional values are drawn around any node. + * + * Unconvertible constructs (transforms, custom checks, dates, embeddings) + * are drawn in variants that share a Zod node type, and each draws a slot: + * the two sides of a pair reuse one instance per variant and slot, the way a + * field shared through `.extend()` does, so both the same-instance and the + * independently-declared cases are judged. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { embedding } from "../../src"; +import { isStructuralSubtype } from "../../src/schema/structural-subtype"; +import { structuralSubtypeProjection } from "../../src/schema/structural-subtype-projection"; +import { propertyRuns } from "./run-budget"; + +// ============================================================ +// Specs +// ============================================================ + +type StringCheck = + | Readonly<{ check: "min" | "max" | "length"; value: number }> + | Readonly<{ check: "regex"; source: string; flags: string }> + | Readonly<{ check: "startsWith" | "endsWith" | "includes"; value: string }> + | Readonly<{ + check: "lowercase" | "uppercase" | "trim" | "toLowerCase" | "toUpperCase"; + }>; + +type NumberCheck = Readonly<{ + check: "min" | "max" | "gt" | "lt" | "multipleOf"; + value: number; +}>; + +type ObjectMode = "strip" | "loose" | "strict" | "catchall"; + +type UnprojectableVariant = + | "transform" + | "transformMin3" + | "transformUpper" + | "date" + | "dateMin" + | "customString" + | "customShortString" + | "embedding2" + | "embedding3" + | "embedding2L2" + | "bigint" + | "int64" + | "uint64"; + +type FallbackWrapper = "default" | "catch" | "prefault"; +type PreprocessFunction = "trim" | "upper" | "identity"; + +type Spec = + | Readonly<{ + kind: "string"; + base: "string" | "email" | "uuid" | "url"; + checks: readonly StringCheck[]; + }> + | Readonly<{ kind: "number"; int: boolean; checks: readonly NumberCheck[] }> + | Readonly<{ kind: "boolean" | "null" | "unknown" }> + | Readonly<{ kind: "literal"; values: readonly (string | number)[] }> + | Readonly<{ kind: "array"; item: Spec; min?: number; max?: number }> + | Readonly<{ kind: "tuple"; items: readonly Spec[]; rest?: Spec }> + | Readonly<{ + kind: "object"; + fields: readonly Readonly<{ + name: string; + spec: Spec; + optional: boolean; + }>[]; + mode: ObjectMode; + catchall?: Spec; + }> + | Readonly<{ + kind: "record"; + keys: readonly string[]; + partial: boolean; + optionalValues: boolean; + value: Spec; + }> + | Readonly<{ + kind: "fallback"; + wrapper: FallbackWrapper; + value: string | number; + inner: Spec; + }> + | Readonly<{ kind: "pipe"; from: Spec; to: Spec }> + | Readonly<{ kind: "codec"; from: Spec; to: Spec }> + | Readonly<{ kind: "optional"; inner: Spec }> + | Readonly<{ kind: "preprocess"; fn: PreprocessFunction; inner: Spec }> + | Readonly<{ kind: "union"; members: readonly Spec[] }> + | Readonly<{ kind: "nullable"; inner: Spec }> + | Readonly<{ + kind: "unprojectable"; + variant: UnprojectableVariant; + slot: number; + }>; + +const UNPROJECTABLE_VARIANTS: readonly UnprojectableVariant[] = [ + "transform", + "transformMin3", + "transformUpper", + "date", + "dateMin", + "customString", + "customShortString", + "embedding2", + "embedding3", + "embedding2L2", + "bigint", + "int64", + "uint64", +]; + +const STRING_POOL = [ + "", + "a", + "ab", + "abc", + "abcd", + "ABC", + "Ab", + " a", + " ab ", + "ß", + "é", + "😀", + "a😀", + "1", + "b", + "ba", + "x@y.co", + "https://example.com", + "http://e.io", + "123e4567-e89b-42d3-a456-426614174000", + "aaaaaa", +]; + +const NUMBER_POOL = [ + -5, + -1, + -0.5, + 0, + 0.1, + 0.25, + 0.3, + 1, + 1.5, + 2, + 3, + 4, + 5, + 6, + 10, + 0.1 + 0.2, + 1e21, +]; + +/** + * Values no JSON row holds, for the nodes that admit them: dates (one of + * them invalid) and bigints on both sides of the 64-bit ranges. + */ +const NATIVE_VALUE_POOL: readonly unknown[] = [ + new Date(0), + new Date(-86_400_000), + new Date(Number.NaN), + new Date("2026-01-01T00:00:00.000Z"), + -1n, + 0n, + 5n, + 2n ** 64n, + -(2n ** 63n) - 1n, +]; + +const DATE_VALUES = NATIVE_VALUE_POOL.filter((value) => value instanceof Date); +const BIGINT_VALUES = NATIVE_VALUE_POOL.filter( + (value) => typeof value === "bigint", +); + +/** The Zod-native values each date and bigint variant is sampled with. */ +const NATIVE_VALUES_BY_VARIANT: Readonly< + Partial> +> = { + date: DATE_VALUES, + dateMin: DATE_VALUES, + bigint: BIGINT_VALUES, + int64: BIGINT_VALUES, + uint64: BIGINT_VALUES, +}; + +/** Variants that share a Zod node type, the near misses for one another. */ +const SIBLING_VARIANTS: readonly (readonly UnprojectableVariant[])[] = [ + ["bigint", "int64", "uint64"], + ["date", "dateMin"], + ["transform", "transformMin3", "transformUpper"], + ["customString", "customShortString"], +]; + +const KEY_POOL = ["a", "b", "c", "A", ""]; + +const PATTERN_POOL = ["^a", "b$", "^[a-c]+$", "^.{2}$", String.raw`\d`, "^.$"]; + +// ============================================================ +// Building Zod schemas from specs +// ============================================================ + +function applyStringCheck( + schema: z.ZodString, + check: StringCheck, +): z.ZodString { + switch (check.check) { + case "min": { + return schema.min(check.value); + } + case "max": { + return schema.max(check.value); + } + case "length": { + return schema.length(check.value); + } + case "regex": { + return schema.regex(new RegExp(check.source, check.flags)); + } + case "startsWith": { + return schema.startsWith(check.value); + } + case "endsWith": { + return schema.endsWith(check.value); + } + case "includes": { + return schema.includes(check.value); + } + case "lowercase": { + return schema.lowercase(); + } + case "uppercase": { + return schema.uppercase(); + } + case "trim": { + return schema.trim(); + } + case "toLowerCase": { + return schema.toLowerCase(); + } + case "toUpperCase": { + return schema.toUpperCase(); + } + } +} + +function stringBase(base: Extract["base"]) { + switch (base) { + case "string": { + return z.string(); + } + case "email": { + return z.email(); + } + case "uuid": { + return z.uuid(); + } + case "url": { + return z.url(); + } + } +} + +function applyNumberCheck( + schema: z.ZodNumber, + check: NumberCheck, +): z.ZodNumber { + switch (check.check) { + case "min": { + return schema.min(check.value); + } + case "max": { + return schema.max(check.value); + } + case "gt": { + return schema.gt(check.value); + } + case "lt": { + return schema.lt(check.value); + } + case "multipleOf": { + return schema.multipleOf(check.value); + } + } +} + +function identity(value: string): string { + return value; +} + +function upper(value: string): string { + return value.toUpperCase(); +} + +function buildUnprojectable(variant: UnprojectableVariant): z.ZodType { + switch (variant) { + case "transform": { + return z.string().transform((value) => identity(value)); + } + case "transformMin3": { + return z + .string() + .min(3) + .transform((value) => identity(value)); + } + case "transformUpper": { + return z.string().transform((value) => upper(value)); + } + case "date": { + return z.date(); + } + case "dateMin": { + return z.date().min(new Date(0)); + } + case "bigint": { + return z.bigint(); + } + case "int64": { + return z.int64(); + } + case "uint64": { + return z.uint64(); + } + case "customString": { + return z.custom((value) => typeof value === "string"); + } + case "customShortString": { + return z.custom( + (value) => typeof value === "string" && value.length < 2, + ); + } + case "embedding2": { + return embedding(2); + } + case "embedding3": { + return embedding(3); + } + case "embedding2L2": { + return embedding(2, { metric: "l2" }); + } + } +} + +/** + * One Zod instance per unconvertible variant and slot, shared by both sides + * of the pair being built. + */ +type InstanceCache = Map; + +function sharedUnprojectable( + spec: Extract, + cache: InstanceCache, +): z.ZodType { + const key = `${spec.variant}:${spec.slot}`; + const existing = cache.get(key); + if (existing !== undefined) return existing; + const built = buildUnprojectable(spec.variant); + cache.set(key, built); + return built; +} + +function buildFallback( + spec: Extract, + inner: z.ZodType, +): z.ZodType { + switch (spec.wrapper) { + case "default": { + return inner.default(spec.value as never); + } + case "catch": { + return inner.catch(spec.value as never); + } + case "prefault": { + return inner.prefault(spec.value as never); + } + } +} + +/** A decode step that rewrites a string, as a codec may. */ +function dropFirst(value: unknown): unknown { + return typeof value === "string" ? value.slice(1) : value; +} + +function preprocessValue(fn: PreprocessFunction, value: unknown): unknown { + if (typeof value !== "string") return value; + switch (fn) { + case "trim": { + return value.trim(); + } + case "upper": { + return value.toUpperCase(); + } + case "identity": { + return value; + } + } +} + +function buildObject( + spec: Extract, + cache: InstanceCache, +): z.ZodType { + const shape: Record = {}; + for (const field of spec.fields) { + const built = build(field.spec, cache); + shape[field.name] = field.optional ? built.optional() : built; + } + switch (spec.mode) { + case "strip": { + return z.object(shape); + } + case "loose": { + return z.looseObject(shape); + } + case "strict": { + return z.strictObject(shape); + } + case "catchall": { + return z + .object(shape) + .catchall(build(spec.catchall ?? { kind: "unknown" }, cache)); + } + } +} + +function build(spec: Spec, cache: InstanceCache): z.ZodType { + switch (spec.kind) { + case "string": { + let schema = stringBase(spec.base) as z.ZodString; + for (const check of spec.checks) schema = applyStringCheck(schema, check); + return schema; + } + case "number": { + let schema: z.ZodNumber = spec.int ? z.int() : z.number(); + for (const check of spec.checks) schema = applyNumberCheck(schema, check); + return schema; + } + case "boolean": { + return z.boolean(); + } + case "null": { + return z.null(); + } + case "unknown": { + return z.unknown(); + } + case "literal": { + return z.literal(spec.values); + } + case "array": { + const array = z.array(build(spec.item, cache)); + const withMin = spec.min === undefined ? array : array.min(spec.min); + return spec.max === undefined ? withMin : withMin.max(spec.max); + } + case "tuple": { + const items = spec.items.map((item) => build(item, cache)) as [ + z.ZodType, + ...z.ZodType[], + ]; + return spec.rest === undefined ? + z.tuple(items) + : z.tuple(items, build(spec.rest, cache)); + } + case "object": { + return buildObject(spec, cache); + } + case "record": { + const keys = z.enum(spec.keys as [string, ...string[]]); + const built = build(spec.value, cache); + const value = spec.optionalValues ? built.optional() : built; + return spec.partial ? + z.partialRecord(keys, value) + : z.record(keys, value); + } + case "fallback": { + return buildFallback(spec, build(spec.inner, cache)); + } + case "pipe": { + return build(spec.from, cache).pipe(build(spec.to, cache)); + } + case "codec": { + return z.codec(build(spec.from, cache), build(spec.to, cache), { + decode: (value) => dropFirst(value) as never, + encode: (value) => value as never, + }); + } + case "optional": { + return build(spec.inner, cache).optional(); + } + case "preprocess": { + return z.preprocess( + (value) => preprocessValue(spec.fn, value), + build(spec.inner, cache), + ); + } + case "union": { + return z.union( + spec.members.map((member) => build(member, cache)) as [ + z.ZodType, + z.ZodType, + ...z.ZodType[], + ], + ); + } + case "nullable": { + return build(spec.inner, cache).nullable(); + } + case "unprojectable": { + return sharedUnprojectable(spec, cache); + } + } +} + +// ============================================================ +// Spec arbitraries +// ============================================================ + +const stringCheckArb: fc.Arbitrary = fc.oneof( + fc.record({ + check: fc.constantFrom("min", "max", "length"), + value: fc.nat({ max: 4 }), + }), + fc.record({ + check: fc.constant("regex"), + source: fc.constantFrom(...PATTERN_POOL), + flags: fc.constantFrom("", "", "i", "u", "g"), + }), + fc.record({ + check: fc.constantFrom("startsWith", "endsWith", "includes"), + value: fc.constantFrom("a", "b", "A"), + }), + fc.record({ + check: fc.constantFrom( + "lowercase", + "uppercase", + "trim", + "toLowerCase", + "toUpperCase", + ), + }), +); + +/** At most one pattern-bearing check: two project as `allOf`, refused outright. */ +function hasAtMostOnePattern(checks: readonly StringCheck[]): boolean { + const patternChecks = new Set([ + "regex", + "startsWith", + "endsWith", + "includes", + "lowercase", + "uppercase", + ]); + return checks.filter((check) => patternChecks.has(check.check)).length <= 1; +} + +const stringSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant("string"), + base: fc.constantFrom("string", "string", "string", "email", "uuid", "url"), + checks: fc + .array(stringCheckArb, { maxLength: 3 }) + .filter((checks) => hasAtMostOnePattern(checks)), +}); + +const numberCheckArb: fc.Arbitrary = fc.oneof( + fc.record({ + check: fc.constantFrom("min", "max", "gt", "lt"), + value: fc.integer({ min: -2, max: 5 }), + }), + fc.record({ + check: fc.constant("multipleOf"), + value: fc.constantFrom(2, 3, 0.1, 0.25), + }), +); + +const numberSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant("number"), + int: fc.boolean(), + checks: fc + .array(numberCheckArb, { maxLength: 3 }) + .filter( + (checks) => + checks.filter((check) => check.check === "multipleOf").length <= 1, + ), +}); + +const literalSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant("literal"), + values: fc.uniqueArray( + fc.oneof(fc.constantFrom(...STRING_POOL), fc.constantFrom(...NUMBER_POOL)), + { minLength: 1, maxLength: 3 }, + ), +}); + +const leafSpecArb: fc.Arbitrary = fc.oneof( + { weight: 4, arbitrary: stringSpecArb }, + { weight: 3, arbitrary: numberSpecArb }, + { weight: 2, arbitrary: literalSpecArb }, + { + weight: 1, + arbitrary: fc.constantFrom( + { kind: "boolean" }, + { kind: "null" }, + { kind: "unknown" }, + ), + }, + { + weight: 2, + arbitrary: fc.record({ + kind: fc.constant("unprojectable"), + variant: fc.constantFrom(...UNPROJECTABLE_VARIANTS), + slot: fc.nat({ max: 1 }), + }), + }, + { + weight: 1, + arbitrary: fc.constantFrom(2, 3).chain((length) => fixedArrayArb(length)), + }, +); + +/** The dimension count of each `embedding()` variant. */ +const EMBEDDING_DIMENSIONS: Readonly< + Partial> +> = { embedding2: 2, embedding3: 3, embedding2L2: 2 }; + +const embeddingSpecArb: fc.Arbitrary = fc.record({ + kind: fc.constant("unprojectable"), + variant: fc.constantFrom( + "embedding2", + "embedding3", + "embedding2L2", + ), + slot: fc.nat({ max: 1 }), +}); + +/** + * An array of `length` numbers, bounded numbers or strings, around an + * `embedding()`'s length: the plain arrays an embedding is compared with + * in both directions. + */ +function fixedArrayArb(length: number): fc.Arbitrary { + return fc.record({ + kind: fc.constant("array"), + item: fc.oneof(numberSpecArb, stringSpecArb), + min: fc.constantFrom(length - 1, length), + max: fc.constantFrom(length, length + 1), + }); +} + +const fallbackValueArb: fc.Arbitrary = fc.oneof( + fc.constantFrom(...STRING_POOL), + fc.constantFrom(...NUMBER_POOL), +); + +const FIELD_NAMES = ["f", "g", "h"] as const; + +const specLetrec = fc.letrec<{ spec: Spec; container: Spec }>((tie) => ({ + spec: fc.oneof( + { maxDepth: 2, depthIdentifier: "spec" }, + { weight: 3, arbitrary: leafSpecArb }, + { weight: 2, arbitrary: tie("container") }, + ), + container: fc.oneof( + fc.record( + { + kind: fc.constant("array"), + item: tie("spec"), + min: fc.nat({ max: 2 }), + max: fc.integer({ min: 1, max: 4 }), + }, + { requiredKeys: ["kind", "item"] }, + ), + fc.record( + { + kind: fc.constant("tuple"), + items: fc.array(tie("spec"), { minLength: 1, maxLength: 2 }), + rest: tie("spec"), + }, + { requiredKeys: ["kind", "items"] }, + ), + fc.record( + { + kind: fc.constant("object"), + fields: fc.uniqueArray( + fc.record({ + name: fc.constantFrom(...FIELD_NAMES), + spec: tie("spec"), + optional: fc.boolean(), + }), + { selector: (field) => field.name, maxLength: 2 }, + ), + mode: fc.constantFrom( + "strip", + "loose", + "strict", + "catchall", + ), + catchall: tie("spec"), + }, + { requiredKeys: ["kind", "fields", "mode"] }, + ), + fc.record({ + kind: fc.constant("record"), + keys: fc.uniqueArray(fc.constantFrom(...KEY_POOL), { + minLength: 1, + maxLength: 3, + }), + partial: fc.boolean(), + optionalValues: fc.boolean(), + value: tie("spec"), + }), + fc.record({ + kind: fc.constant("fallback"), + wrapper: fc.constantFrom("default", "catch", "prefault"), + value: fallbackValueArb, + inner: tie("spec"), + }), + fc.record({ + kind: fc.constant("pipe"), + from: tie("spec"), + to: tie("spec"), + }), + fc.record({ + kind: fc.constant("codec"), + from: tie("spec"), + to: tie("spec"), + }), + fc.record({ kind: fc.constant("optional"), inner: tie("spec") }), + fc.record({ + kind: fc.constant("preprocess"), + fn: fc.constantFrom("trim", "upper", "identity"), + inner: tie("spec"), + }), + fc.record({ + kind: fc.constant("union"), + members: fc.array(tie("spec"), { minLength: 2, maxLength: 2 }), + }), + fc.record({ kind: fc.constant("nullable"), inner: tie("spec") }), + ), +})); + +const specArb: fc.Arbitrary = specLetrec.spec; + +// ============================================================ +// Near-miss children +// ============================================================ + +function replaceAt(items: readonly T[], index: number, item: T): T[] { + return items.map((existing, position) => + position === index ? item : existing, + ); +} + +/** `spec` with one random edit applied somewhere in its tree. */ +function editArb(spec: Spec): fc.Arbitrary { + const local: fc.Arbitrary[] = [ + specArb, + fc.constant({ kind: "nullable", inner: spec }), + fc.record({ + kind: fc.constant("fallback"), + wrapper: fc.constantFrom("default", "catch", "prefault"), + value: fallbackValueArb, + inner: fc.constant(spec), + }), + fc.record({ + kind: fc.constant("preprocess"), + fn: fc.constantFrom("trim", "upper", "identity"), + inner: fc.constant(spec), + }), + specArb.map((from): Spec => ({ kind: "pipe", from, to: spec })), + fc.constant({ kind: "pipe", from: { kind: "unknown" }, to: spec }), + fc.constant({ + kind: "pipe", + from: { kind: "unknown" }, + to: { kind: "optional", inner: spec }, + }), + fc.constant({ kind: "codec", from: spec, to: spec }), + fc.constant({ kind: "optional", inner: spec }), + ]; + switch (spec.kind) { + case "string": { + local.push( + fc + .tuple(stringCheckArb, fc.nat({ max: spec.checks.length })) + .map(([check, at]) => ({ + ...spec, + checks: [ + ...spec.checks.slice(0, at), + check, + ...spec.checks.slice(at), + ], + })) + .filter((edited) => hasAtMostOnePattern(edited.checks)), + ); + if (spec.checks.length > 0) { + local.push( + fc.nat({ max: spec.checks.length - 1 }).map((at) => ({ + ...spec, + checks: spec.checks.filter((_check, index) => index !== at), + })), + ); + } + break; + } + case "number": { + local.push( + numberCheckArb.map((check) => ({ + ...spec, + checks: [...spec.checks, check], + })), + fc.constant({ ...spec, int: !spec.int }), + ); + if (spec.checks.length > 0) { + local.push( + fc.nat({ max: spec.checks.length - 1 }).map((at) => ({ + ...spec, + checks: spec.checks.filter((_check, index) => index !== at), + })), + ); + } + break; + } + case "array": { + local.push( + embeddingSpecArb, + editArb(spec.item).map((item) => ({ ...spec, item })), + fc.record({ min: fc.nat({ max: 3 }) }).map(({ min }) => ({ + ...spec, + min, + })), + ); + break; + } + case "tuple": { + local.push( + fc.nat({ max: spec.items.length - 1 }).chain((at) => + editArb(spec.items[at] ?? { kind: "unknown" }).map((item) => ({ + ...spec, + items: replaceAt(spec.items, at, item), + })), + ), + fc.constant({ kind: "array", item: spec.items[0] ?? spec }), + ); + break; + } + case "object": { + local.push( + fc + .constantFrom("strip", "loose", "strict", "catchall") + .map((mode) => ({ ...spec, mode })), + ); + if (spec.fields.length > 0) { + local.push( + fc.nat({ max: spec.fields.length - 1 }).chain((at) => { + const field = spec.fields[at]; + if (field === undefined) return fc.constant(spec); + return fc.oneof( + editArb(field.spec).map((edited) => ({ + ...spec, + fields: replaceAt(spec.fields, at, { ...field, spec: edited }), + })), + fc.constant({ + ...spec, + fields: replaceAt(spec.fields, at, { + ...field, + optional: !field.optional, + }), + }), + fc.constant({ + ...spec, + fields: spec.fields.filter((_field, index) => index !== at), + }), + ); + }), + ); + } + break; + } + case "record": { + local.push( + editArb(spec.value).map((value) => ({ ...spec, value })), + fc.constant({ ...spec, partial: !spec.partial }), + fc.constant({ ...spec, optionalValues: !spec.optionalValues }), + fc.constant({ ...spec, keys: spec.keys.slice(0, 1) }), + ); + break; + } + case "union": { + local.push( + fc.nat({ max: spec.members.length - 1 }).chain((at) => + editArb(spec.members[at] ?? { kind: "unknown" }).map((member) => ({ + ...spec, + members: replaceAt(spec.members, at, member), + })), + ), + fc.constant(spec.members[0] ?? spec), + ); + break; + } + case "nullable": { + local.push( + fc.constant(spec.inner), + editArb(spec.inner).map((inner) => ({ ...spec, inner })), + ); + break; + } + case "unprojectable": { + local.push( + fc + .constantFrom(...UNPROJECTABLE_VARIANTS) + .map((variant) => ({ ...spec, variant })), + fc.constant({ ...spec, slot: 1 - spec.slot }), + ); + const siblings = SIBLING_VARIANTS.find((family) => + family.includes(spec.variant), + ); + if (siblings !== undefined) { + local.push( + fc.constantFrom(...siblings).map((variant) => ({ ...spec, variant })), + ); + } + const dimensions = EMBEDDING_DIMENSIONS[spec.variant]; + if (dimensions !== undefined) local.push(fixedArrayArb(dimensions)); + break; + } + case "fallback": + case "preprocess": { + local.push( + fc.constant(spec.inner), + editArb(spec.inner).map((inner) => ({ ...spec, inner })), + ); + break; + } + case "pipe": + case "codec": { + local.push( + fc.constant(spec.to), + fc.constant(spec.from), + editArb(spec.from).map((from) => ({ ...spec, from })), + ); + break; + } + case "optional": { + local.push( + fc.constant(spec.inner), + editArb(spec.inner).map((inner) => ({ ...spec, inner })), + ); + break; + } + case "boolean": + case "null": + case "unknown": + case "literal": { + break; + } + } + return fc.oneof(...local); +} + +const pairArb: fc.Arbitrary> = + specArb.chain((parent) => + fc + .oneof( + { weight: 6, arbitrary: editArb(parent) }, + { + weight: 3, + arbitrary: editArb(parent).chain((edited) => editArb(edited)), + }, + { weight: 1, arbitrary: specArb }, + ) + .map((child) => ({ child, parent })), + ); + +// ============================================================ +// Candidate values a child may store +// ============================================================ + +const scalarPoolArb: fc.Arbitrary = fc.oneof( + fc.constantFrom(...STRING_POOL), + fc.constantFrom(...NUMBER_POOL), + // eslint-disable-next-line unicorn/no-null -- JSON null is a stored value + fc.constantFrom(true, false, null), +); + +/** JSON values shaped roughly like `spec`, plus pool noise. */ +function candidateArb(spec: Spec): fc.Arbitrary { + return fc.oneof( + { weight: 1, arbitrary: scalarPoolArb }, + { weight: 4, arbitrary: shapedCandidateArb(spec) }, + ); +} + +function shapedCandidateArb(spec: Spec): fc.Arbitrary { + switch (spec.kind) { + case "string": { + return fc.constantFrom(...STRING_POOL); + } + case "unprojectable": { + const native = NATIVE_VALUES_BY_VARIANT[spec.variant]; + if (native !== undefined) { + return fc.oneof( + { weight: 4, arbitrary: fc.constantFrom(...native) }, + { weight: 1, arbitrary: scalarPoolArb }, + ); + } + return fc.oneof( + fc.constantFrom(...STRING_POOL), + fc.array(fc.constantFrom(...NUMBER_POOL), { + minLength: 2, + maxLength: 3, + }), + fc.constantFrom(...NATIVE_VALUE_POOL), + ); + } + case "fallback": + case "preprocess": { + return fc.oneof( + fc.constant(undefined), + fc.constantFrom(...STRING_POOL), + candidateArb(spec.inner), + ); + } + case "pipe": + case "codec": { + return candidateArb(spec.from); + } + case "optional": { + return fc.oneof(fc.constant(undefined), candidateArb(spec.inner)); + } + case "number": { + return fc.constantFrom(...NUMBER_POOL); + } + case "literal": { + return fc.constantFrom(...spec.values); + } + case "array": { + return fc.oneof( + fc.array(candidateArb(spec.item), { maxLength: 5 }), + // Lengths the bounds admit, where a near-miss bound shows up. + fc.array(candidateArb(spec.item), { + minLength: spec.min ?? 0, + maxLength: Math.max(spec.min ?? 0, spec.max ?? 5), + }), + ); + } + case "tuple": { + return fc + .tuple( + fc.tuple(...spec.items.map((item) => candidateArb(item))), + fc.array(candidateArb(spec.rest ?? { kind: "unknown" }), { + maxLength: 2, + }), + ) + .map(([prefix, rest]) => [...prefix, ...rest]); + } + case "object": { + const fields: Record> = {}; + for (const field of spec.fields) { + fields[field.name] = candidateArb(field.spec); + } + return fc + .tuple( + fc.record(fields, { requiredKeys: [] }), + fc.option(candidateArb(spec.catchall ?? { kind: "unknown" }), { + nil: undefined, + }), + ) + .map(([declared, extra]) => + extra === undefined ? declared : { ...declared, extra }, + ); + } + case "record": { + return fc.dictionary( + fc.constantFrom(...spec.keys, ...KEY_POOL), + candidateArb(spec.value), + { maxKeys: 4 }, + ); + } + case "union": { + return fc.oneof(...spec.members.map((member) => candidateArb(member))); + } + case "nullable": { + // eslint-disable-next-line unicorn/no-null -- the value `.nullable()` adds + return fc.oneof(fc.constant(null), candidateArb(spec.inner)); + } + case "boolean": + case "null": + case "unknown": { + return scalarPoolArb; + } + } +} + +/** + * `value` as an input reaches a node: JSON's reading of every plain value + * (an absent `undefined` member, `null` for a missing array slot or a + * non-finite number), with dates and bigints kept for the nodes that parse + * them. + */ +function asInputValue(value: unknown): unknown { + if (value instanceof Date || typeof value === "bigint") return value; + if (typeof value === "number") { + // eslint-disable-next-line unicorn/no-null -- JSON writes a non-finite number as null + return Number.isFinite(value) ? value : null; + } + if (Array.isArray(value)) { + // eslint-disable-next-line unicorn/no-null -- JSON writes a missing array slot as null + return value.map((member) => asInputValue(member) ?? null); + } + if (value !== null && typeof value === "object") { + return Object.fromEntries( + Object.entries(value) + .filter(([, member]) => member !== undefined) + .map(([key, member]) => [key, asInputValue(member)]), + ); + } + return value; +} + +// ============================================================ +// The property +// ============================================================ + +const pairWithSamplesArb = pairArb.chain((pair) => + fc + .array(candidateArb(pair.child), { minLength: 4, maxLength: 12 }) + .map((samples) => ({ ...pair, samples })), +); + +/** + * Whether `row` is one the parent's schema could store itself: it accepts + * the row as input, or it stores exactly the row from some input — an + * absent field its own default fills, a value its own catch replaces. The + * check's soundness is that the child stores nothing the parent could not. + */ +function parentCouldStore( + parentSchema: z.ZodType, + row: unknown, + parentInputs: readonly unknown[], +): boolean { + if (parentSchema.safeParse(row).success) return true; + const target = storedForm(row); + return parentInputs.some((input) => { + const stored = parentSchema.safeParse(input); + return stored.success && storedForm(asInputValue(stored.data)) === target; + }); +} + +/** A stable rendering of a row, dates and bigints included. */ +function storedForm(value: unknown): string { + return JSON.stringify(value, (_key, member: unknown) => + typeof member === "bigint" ? `${member}n` + : member instanceof Date ? `date:${member.getTime()}` + : member, + ); +} + +function expectSoundVerdict( + child: Spec, + parent: Spec, + samples: readonly unknown[], +): void { + const cache: InstanceCache = new Map(); + const childSchema = z.object({ field: build(child, cache) }); + const parentSchema = z.object({ field: build(parent, cache) }); + const verdict = isStructuralSubtype( + structuralSubtypeProjection(childSchema), + structuralSubtypeProjection(parentSchema), + ); + if (verdict.verdict !== "subtype") return; + const parentInputs = [ + {}, + ...samples.map((sample) => asInputValue({ field: sample })), + ]; + for (const sample of samples) { + const input = asInputValue({ field: sample }); + const stored = childSchema.safeParse(input); + if (!stored.success) continue; + // What a row holds is the stored output as storage reads it back. + const row = asInputValue(stored.data); + expect({ + child, + parent, + input, + row, + parentCouldStore: parentCouldStore(parentSchema, row, parentInputs), + }).toMatchObject({ parentCouldStore: true }); + } +} + +/** An `embedding()` and a plain array near its length, in either order. */ +const embeddingPairArb = embeddingSpecArb.chain((embeddingSpec) => { + const dimensions = + embeddingSpec.kind === "unprojectable" ? + (EMBEDDING_DIMENSIONS[embeddingSpec.variant] ?? 2) + : 2; + return fc + .record({ + array: fixedArrayArb(dimensions), + embeddingIsChild: fc.boolean(), + }) + .chain(({ array, embeddingIsChild }) => { + const child = embeddingIsChild ? embeddingSpec : array; + const parent = embeddingIsChild ? array : embeddingSpec; + return fc + .array(candidateArb(child), { minLength: 4, maxLength: 12 }) + .map((samples) => ({ child, parent, samples })); + }); +}); + +describe("structural subtyping soundness against Zod", () => { + it("an accepted pair admits no stored child value the parent's Zod schema refuses", () => { + fc.assert( + fc.property(pairWithSamplesArb, ({ child, parent, samples }) => { + expectSoundVerdict(child, parent, samples); + }), + { numRuns: propertyRuns({ ci: 3000, local: 5000 }) }, + ); + }); + + it("an embedding and a plain array admit nothing the other refuses, in either direction", () => { + fc.assert( + fc.property(embeddingPairArb, ({ child, parent, samples }) => { + expectSoundVerdict(child, parent, samples); + }), + { numRuns: propertyRuns({ ci: 1000, local: 2000 }) }, + ); + }); +}); diff --git a/packages/typegraph/tests/property/structural-subtype.test.ts b/packages/typegraph/tests/property/structural-subtype.test.ts new file mode 100644 index 000000000..e01c7fb29 --- /dev/null +++ b/packages/typegraph/tests/property/structural-subtype.test.ts @@ -0,0 +1,515 @@ +/** + * Property-based tests for the structural subtyping predicate. + * + * Uses `comparableSchemaArb` / `tightenArb` from `./arbitraries.ts` — shared + * there so the child/parent-agreement + * property test can reuse the same generators rather than re-deriving them. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { isObjectSchema } from "../../src/schema/migration"; +import { serializeSchemaProperties } from "../../src/schema/serializer"; +import { isStructuralSubtype } from "../../src/schema/structural-subtype"; +import { structuralSubtypeProjection } from "../../src/schema/structural-subtype-projection"; +import { type JsonSchema } from "../../src/schema/types"; +import { comparableSchemaArb, tightenArb } from "./arbitraries"; + +// A property name guaranteed absent from every schema `comparableSchemaArb` +// generates (its object fields are drawn only from "fieldA"/"fieldB"/"fieldC"). +const FRESH_PROPERTY_NAME = "freshRequiredField"; + +function objectSchemaArb(): fc.Arbitrary { + return comparableSchemaArb.filter((schema) => isObjectSchema(schema)); +} + +function objectSchemaWithRequiredPropertyArb(): fc.Arbitrary { + return objectSchemaArb().filter( + (schema) => (schema.required?.length ?? 0) > 0, + ); +} + +describe("structural subtyping properties", () => { + it("P1: reflexivity — every comparable schema is a subtype of itself", () => { + fc.assert( + fc.property(comparableSchemaArb, (schema) => { + expect(isStructuralSubtype(schema, schema)).toEqual({ + verdict: "subtype", + }); + }), + ); + }); + + it("P2: tightening is subtyping — tighten(p) is always a subtype of p", () => { + fc.assert( + fc.property( + comparableSchemaArb.chain((parent) => + tightenArb(parent).map((child) => [child, parent] as const), + ), + ([child, parent]) => { + expect(isStructuralSubtype(child, parent)).toEqual({ + verdict: "subtype", + }); + }, + ), + ); + }); + + it("P3: transitivity over tightening chains — tighten(tighten(p)) is still a subtype of p", () => { + fc.assert( + fc.property( + comparableSchemaArb.chain((parent) => + tightenArb(parent).chain((intermediate) => + tightenArb(intermediate).map((child) => [child, parent] as const), + ), + ), + ([child, parent]) => { + const result = isStructuralSubtype(child, parent); + if (result.verdict !== "subtype") { + // Per the plan: a P3 counterexample is a design defect in the + // rule set, not a test problem — surface the triple rather than + // weakening the property. + throw new Error( + `Transitivity failed for child=${JSON.stringify(child)} parent=${JSON.stringify( + parent, + )}: got ${JSON.stringify(result)}`, + ); + } + expect(result).toEqual({ verdict: "subtype" }); + }, + ), + ); + }); + + it("P4: a child adding a required field is a subtype, and the reverse direction is not", () => { + fc.assert( + fc.property( + objectSchemaArb(), + comparableSchemaArb, + (parent, newPropertySchema) => { + const parentProps = parent.properties ?? {}; + fc.pre(!Object.hasOwn(parentProps, FRESH_PROPERTY_NAME)); + // A typed catchall or a record constrains the added key too. + fc.pre( + parent.additionalProperties === false || + (typeof parent.additionalProperties === "object" && + Object.keys(parent.additionalProperties).length === 0), + ); + + const child: JsonSchema = { + ...parent, + properties: { + ...parentProps, + [FRESH_PROPERTY_NAME]: newPropertySchema, + }, + required: [...(parent.required ?? []), FRESH_PROPERTY_NAME], + }; + + expect(isStructuralSubtype(child, parent)).toEqual({ + verdict: "subtype", + }); + + const reverse = isStructuralSubtype(parent, child); + expect(reverse).toMatchObject({ + verdict: "not-subtype", + reason: "missing-required-property", + }); + }, + ), + ); + }); + + it("P5: relaxing a required field to optional is not subtyping", () => { + fc.assert( + fc.property(objectSchemaWithRequiredPropertyArb(), (parent) => { + const parentRequired = parent.required ?? []; + fc.pre(parentRequired.length > 0); + const [droppedName] = parentRequired; + + const child: JsonSchema = { + ...parent, + required: parentRequired.filter((name) => name !== droppedName), + }; + + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "optional-in-child-required-in-parent", + }); + }), + ); + }); + + it("P6: the verdict is always total and well-shaped, and never throws", () => { + fc.assert( + fc.property( + comparableSchemaArb, + comparableSchemaArb, + (schemaA, schemaB) => { + const result = isStructuralSubtype(schemaA, schemaB); + const isWellShaped = + result.verdict === "subtype" ? + Object.keys(result).length === 1 + : typeof result.reason === "string" && Array.isArray(result.path); + expect(isWellShaped).toBe(true); + }, + ), + ); + }); +}); + +// ============================================================ +// Finite value sets under bounded parents +// ============================================================ + +type StringCheck = (schema: z._ZodString) => z._ZodString; +type NumberCheck = (schema: z.ZodNumber) => z.ZodNumber; + +function withChecks(schema: S, checks: readonly ((schema: S) => S)[]): S { + let checked = schema; + for (const check of checks) checked = check(checked); + return checked; +} + +/** + * Patterns whose flagless reading (how Zod tests them) and Unicode reading + * (how a JSON Schema validator compiles them) agree on every string in + * {@link AGREEING_STRING_POOL}. + */ +const AGREEING_PATTERN_POOL = [ + "^a", + "b$", + "^[a-c]+$", + "^.{2}$", + String.raw`\d`, +]; + +/** + * Patterns whose two readings disagree on some string in + * {@link STRING_VALUE_POOL}, or whose Unicode reading does not compile. + */ +const DIVERGENT_PATTERN_POOL = [ + "^.$", + String.raw`^\p{L}+$`, + String.raw`^[\w-.]+$`, + String.raw`^\-`, +]; + +/** Strings without an astral character, so code points equal UTF-16 units. */ +const AGREEING_STRING_POOL = ["", "a", "b", "ab", "abc", "ba", "A", "1", "é"]; + +const STRING_VALUE_POOL = [ + ...AGREEING_STRING_POOL, + "-", + "p{L}", + "😀", + "a😀", + "\n", + "aaaaa", +]; + +/** Numbers whose shortest decimal has at most two fraction digits. */ +const AGREEING_NUMBER_POOL = [ + -3, -1, 0, 1, 2, 3, 4, 6, 10, 0.07, 0.1, 0.2, 0.25, 0.3, 0.5, 1.5, 2.03, +]; + +const NUMBER_VALUE_POOL = [ + ...AGREEING_NUMBER_POOL, + -0, + 0.1 + 0.2, + 1e-7, + 1e21, + 5e-324, + Number.MAX_SAFE_INTEGER, + 2 ** 53, +]; + +const MULTIPLE_OF_POOL = [2, 3, 0.01, 0.07, 0.1, 0.25]; + +/** + * Bases whose own format check the projection loses once a pattern-stated + * format follows it. + */ +const FORMAT_BASES: readonly (() => z._ZodString)[] = [ + () => z.url(), + () => z.httpUrl(), + () => z.jwt(), + () => z.stringFormat("even-length", (value) => value.length % 2 === 0), +]; + +const OVERWRITE_CHECKS: readonly StringCheck[] = [ + (s) => s.trim(), + (s) => s.toLowerCase(), + (s) => s.toUpperCase(), + (s) => s.normalize(), +]; + +/** + * A bounded string parent. A lossy one also draws a format base and + * overwrite checks, which the JSON Schema projection does not state. + */ +function stringParentArb( + patternPool: readonly string[], + options: Readonly<{ lossy: boolean }>, +): fc.Arbitrary { + const boundArb = fc.oneof( + fc.nat({ max: 3 }).map( + (length): StringCheck => + (s) => + s.min(length), + ), + fc.integer({ min: 1, max: 4 }).map( + (length): StringCheck => + (s) => + s.max(length), + ), + ); + // At most one pattern-bearing check: two project as `allOf`, which the + // predicate refuses as incomparable. + const patternCheckArb = fc.oneof( + fc.constantFrom(...patternPool).map( + (pattern): StringCheck => + (s) => + s.regex(new RegExp(pattern)), + ), + fc.constantFrom("a", "b").map( + (prefix): StringCheck => + (s) => + s.startsWith(prefix), + ), + fc.constant((s) => s.lowercase()), + fc.constant((s) => s.includes("a", { position: 1 })), + ); + const checkArb = + options.lossy ? + fc.oneof(boundArb, fc.constantFrom(...OVERWRITE_CHECKS)) + : boundArb; + const baseArb = + options.lossy ? + fc.constantFrom<() => z._ZodString>(() => z.string(), ...FORMAT_BASES) + : fc.constant<() => z._ZodString>(() => z.string()); + return fc + .record({ + base: baseArb, + checks: fc.array(checkArb, { maxLength: 3 }), + patternCheck: fc.option(patternCheckArb, { nil: undefined }), + patternFirst: fc.boolean(), + }) + .map(({ base, checks, patternCheck, patternFirst }) => { + const pattern = patternCheck === undefined ? [] : [patternCheck]; + return withChecks( + base(), + patternFirst ? [...pattern, ...checks] : [...checks, ...pattern], + ); + }); +} + +function numberParentArb(): fc.Arbitrary { + const boundArb = fc.oneof( + fc.integer({ min: -2, max: 5 }).map( + (bound): NumberCheck => + (s) => + s.min(bound), + ), + fc.integer({ min: -2, max: 5 }).map( + (bound): NumberCheck => + (s) => + s.max(bound), + ), + fc.integer({ min: -2, max: 5 }).map( + (bound): NumberCheck => + (s) => + s.gt(bound), + ), + fc.integer({ min: -2, max: 5 }).map( + (bound): NumberCheck => + (s) => + s.lt(bound), + ), + fc.constant((s) => s.int()), + ); + // At most one `multipleOf`: two project as `allOf`, which the predicate + // refuses as incomparable. + const multipleOfArb = fc.constantFrom(...MULTIPLE_OF_POOL).map( + (step): NumberCheck => + (s) => + s.multipleOf(step), + ); + return fc + .tuple( + fc.array(boundArb, { maxLength: 3 }), + fc.option(multipleOfArb, { nil: undefined }), + ) + .map(([bounds, multipleOf]) => + withChecks(z.number(), [ + ...bounds, + ...(multipleOf === undefined ? [] : [multipleOf]), + ]), + ); +} + +function valueSubsetArb(pool: readonly T[]): fc.Arbitrary { + return fc.uniqueArray(fc.constantFrom(...pool), { + minLength: 1, + maxLength: 4, + }); +} + +function pinnedStrings( + values: readonly string[], +): z.ZodEnum>> { + return z.enum(values as [string, ...string[]]); +} + +function pinnedNumbers(values: readonly number[]): z.ZodType { + return z.literal(values); +} + +/** Values JSON cannot carry: the projection writes each as `null`. */ +const NON_JSON_NUMBER_POOL = [ + Number.NaN, + Number.POSITIVE_INFINITY, + Number.NEGATIVE_INFINITY, +]; + +/** Strings an overwrite or a lost format check treats differently. */ +const LOSSY_STRING_POOL = [ + " a", + " ab", + "ß", + "\u0958", + "https://", + "eyX", + "ABC", +]; + +const LITERAL_POOL = [ + ...STRING_VALUE_POOL, + ...LOSSY_STRING_POOL, + ...NUMBER_VALUE_POOL, + ...NON_JSON_NUMBER_POOL, + // eslint-disable-next-line unicorn/no-null -- JSON null is a literal value + null, + true, +]; + +type PinnedChild = Readonly<{ + schema: z.ZodType; + samples: readonly unknown[]; +}>; + +/** + * A child pinned to a finite value set, bare or under the wrappers the + * comparison walks: nullable, a union of two pinned members, and a record + * whose keys are pinned. + */ +function pinnedChildArb(): fc.Arbitrary { + const literalsArb = valueSubsetArb(LITERAL_POOL); + const keysArb = valueSubsetArb([...STRING_VALUE_POOL, ...LOSSY_STRING_POOL]); + return fc.oneof( + literalsArb.map((values) => ({ + schema: z.literal(values), + samples: values, + })), + literalsArb.map((values) => ({ + schema: z.literal(values).nullable(), + // eslint-disable-next-line unicorn/no-null -- the value `.nullable()` adds + samples: [...values, null], + })), + fc.tuple(literalsArb, literalsArb).map(([first, second]) => ({ + schema: z.union([z.literal(first), z.literal(second)]), + samples: [...first, ...second], + })), + keysArb.map((keys) => ({ + schema: z.partialRecord(pinnedStrings(keys), z.string()), + samples: keys.map((key) => ({ [key]: "value" })), + })), + ); +} + +function parentArb(): fc.Arbitrary { + const stringArb = stringParentArb( + [...AGREEING_PATTERN_POOL, ...DIVERGENT_PATTERN_POOL], + { lossy: true }, + ); + return fc.oneof( + stringArb, + stringArb.map((parent) => parent.nullable()), + stringArb.map((parent) => z.record(parent, z.string())), + numberParentArb(), + numberParentArb().map((parent) => parent.nullable()), + fc.constantFrom( + z.boolean(), + z.null(), + z.object({}), + z.enum(["a"]).or(z.boolean()), + ), + ); +} + +function finiteVerdict(child: z.ZodType, parent: z.ZodType) { + return isStructuralSubtype( + serializeSchemaProperties(child), + serializeSchemaProperties(parent), + ); +} + +function admittedByParent( + parent: z.ZodType, + values: readonly unknown[], +): readonly unknown[] { + return values.filter((value) => parent.safeParse(value).success); +} + +describe("structural subtyping properties — finite value sets", () => { + it("P7: a finite child the parent admits in full is a subtype, wherever the readings agree", () => { + fc.assert( + fc.property( + stringParentArb(AGREEING_PATTERN_POOL, { lossy: false }), + valueSubsetArb(AGREEING_STRING_POOL), + (parent, candidates) => { + const values = admittedByParent(parent, candidates) as string[]; + fc.pre(values.length > 0); + expect(finiteVerdict(pinnedStrings(values), parent)).toEqual({ + verdict: "subtype", + }); + }, + ), + ); + fc.assert( + fc.property( + numberParentArb(), + valueSubsetArb(AGREEING_NUMBER_POOL), + (parent, candidates) => { + const values = admittedByParent(parent, candidates) as number[]; + fc.pre(values.length > 0); + expect(finiteVerdict(pinnedNumbers(values), parent)).toEqual({ + verdict: "subtype", + }); + }, + ), + ); + }); + + it("P8: over the live projection, an accepted finite child admits no value the parent refuses", () => { + fc.assert( + fc.property(pinnedChildArb(), parentArb(), (child, parent) => { + const result = isStructuralSubtype( + structuralSubtypeProjection(z.object({ field: child.schema })), + structuralSubtypeProjection(z.object({ field: parent })), + ); + if (result.verdict !== "subtype") return; + for (const sample of child.samples) { + const stored = child.schema.safeParse(sample); + if (!stored.success) continue; + expect({ + sample, + admitted: parent.safeParse(stored.data).success, + }).toEqual({ sample, admitted: true }); + } + }), + { numRuns: 10_000 }, + ); + }); +}); diff --git a/packages/typegraph/tests/property/subgraph.test.ts b/packages/typegraph/tests/property/subgraph.test.ts index 8610dd975..10bcb54a6 100644 --- a/packages/typegraph/tests/property/subgraph.test.ts +++ b/packages/typegraph/tests/property/subgraph.test.ts @@ -9,16 +9,22 @@ import { beforeEach, describe, expect, it } from "vitest"; import { z } from "zod"; import { + asEdgeId, asNodeId, defineEdge, defineGraph, defineNode, + hasPart, indexSubgraph, + partOf, + type TemporalMode, } from "../../src"; import type { GraphBackend } from "../../src/backend/types"; import { createStore, type Store } from "../../src/store"; +import { compareStrings } from "../../src/utils/compare"; import { requireDefined } from "../../src/utils/presence"; import { createTestBackend } from "../test-utils"; +import { propertyRuns } from "./run-budget"; // ============================================================ // Test Schema: simple chain/tree graph @@ -309,3 +315,421 @@ describe("subgraph property tests", () => { }); }); }); + +// ============================================================ +// subgraph({ composition: true }) at temporal coordinates +// ============================================================ + +/** + * The composition closure against a reference walk over a plain model of the + * rows the store accepted. Folders and documents share ids, so a walk that + * joins on id alone wanders into the other kind's node. Three realizing + * edges: `sgSubfolderOf` (Folder part of Folder, population `one`, so an + * ended row still binds), `sgInFolder` (Doc part `from`, `oneActive`) and + * `sgHolds` (Folder -> Doc, part `to`: the reversed member, `oneActive`). + * The non-composition `sgSeeAlso` must never be walked by the closure. + * + * At a coordinate (now, or `asOf` a fixed past instant) a composition row + * binds when it is not deleted, has started by the instant (asOf), and — for + * an `oneActive` kind — has not ended by it. A node is returned only while + * visible at the coordinate, and nothing reachable only through a hidden part + * is returned either. + */ +const SgFolder = defineNode("SgFolder", { schema: z.object({}) }); +const SgDocument = defineNode("SgDoc", { schema: z.object({}) }); +const sgSubfolderOf = defineEdge("sgSubfolderOf", { schema: z.object({}) }); +const sgInFolder = defineEdge("sgInFolder", { schema: z.object({}) }); +const sgHolds = defineEdge("sgHolds", { schema: z.object({}) }); +const sgSeeAlso = defineEdge("sgSeeAlso", { schema: z.object({}) }); + +const compositionGraph = defineGraph({ + id: "prop_subgraph_composition", + nodes: { SgFolder: { type: SgFolder }, SgDoc: { type: SgDocument } }, + edges: { + sgSubfolderOf: { + type: sgSubfolderOf, + from: [SgFolder], + to: [SgFolder], + cardinality: "one", + }, + sgInFolder: { + type: sgInFolder, + from: [SgDocument], + to: [SgFolder], + cardinality: "oneActive", + }, + sgHolds: { + type: sgHolds, + from: [SgFolder], + to: [SgDocument], + targetCardinality: "oneActive", + }, + sgSeeAlso: { + type: sgSeeAlso, + from: [SgFolder], + to: [SgFolder, SgDocument], + }, + }, + ontology: [ + partOf(SgFolder, SgFolder, { via: sgSubfolderOf, partSide: "from" }), + partOf(SgDocument, SgFolder, { via: sgInFolder }), + hasPart(SgFolder, SgDocument, { via: sgHolds }), + ], +}); + +type CompositionGraph = typeof compositionGraph; +type SgNodeKind = "SgFolder" | "SgDoc"; +type SgEdgeKind = "sgSubfolderOf" | "sgInFolder" | "sgHolds" | "sgSeeAlso"; + +const SG_NODE_IDS = ["n0", "n1", "n2", "n3"] as const; +/** Every node exists from here, so every asOf instant below can see it. */ +const SG_NODE_FROM = "1980-01-01T00:00:00.000Z"; +/** A hidden node's window ends here: visible at the 1995 instant, hidden after. */ +const SG_NODE_ENDED = "2005-01-01T00:00:00.000Z"; +const SG_EDGE_FROM = ["1990-01-01T00:00:00.000Z", "2000-01-01T00:00:00.000Z"]; +const SG_EDGE_TO = [undefined, "2010-01-01T00:00:00.000Z"] as const; +const SG_INSTANTS = [ + "1995-01-01T00:00:00.000Z", + "2005-06-01T00:00:00.000Z", + "2015-01-01T00:00:00.000Z", +] as const; + +const SG_OPEN_ROWS_KINDS: ReadonlySet = new Set([ + "sgInFolder", + "sgHolds", +]); + +/** Each realizing edge kind's stored (from, to) kinds and which end is the whole. */ +const SG_EDGE_SHAPES: Readonly< + Record< + SgEdgeKind, + Readonly<{ from: SgNodeKind; to: SgNodeKind; wholeSide: "from" | "to" }> + > +> = { + sgSubfolderOf: { from: "SgFolder", to: "SgFolder", wholeSide: "to" }, + sgInFolder: { from: "SgDoc", to: "SgFolder", wholeSide: "to" }, + sgHolds: { from: "SgFolder", to: "SgDoc", wholeSide: "from" }, + sgSeeAlso: { from: "SgFolder", to: "SgDoc", wholeSide: "from" }, +}; + +type SgNodeSpec = Readonly<{ kind: SgNodeKind; id: string; hidden: boolean }>; +/** `whole` and `part` index a node of the kind the edge kind places on each side. */ +type SgEdgeSpec = Readonly<{ + kind: SgEdgeKind; + whole: number; + part: number; + validFrom: number; + validTo: number; + deleted: boolean; +}>; +type SgCoordinate = + Readonly<{ mode: "current" }> | Readonly<{ mode: "asOf"; asOf: string }>; + +type SgModelEdge = Readonly<{ + id: string; + kind: SgEdgeKind; + fromKey: string; + toKey: string; + validFrom: string; + validTo: string | undefined; + deleted: boolean; +}>; + +function sgKey(kind: SgNodeKind, id: string): string { + return `${kind}:${id}`; +} + +function sgNodeVisible(hidden: boolean, coordinate: SgCoordinate): boolean { + if (!hidden) return true; + return coordinate.mode === "asOf" && coordinate.asOf < SG_NODE_ENDED; +} + +function sgEdgeBinds(edge: SgModelEdge, coordinate: SgCoordinate): boolean { + if (edge.kind === "sgSeeAlso" || edge.deleted) return false; + if (coordinate.mode === "asOf" && edge.validFrom > coordinate.asOf) { + return false; + } + if (!SG_OPEN_ROWS_KINDS.has(edge.kind)) return true; + if (edge.validTo === undefined) return true; + return coordinate.mode === "asOf" && edge.validTo > coordinate.asOf; +} + +/** The reference closure: nodes and edges the root's owned unit returns. */ +function sgExpectedClosure( + rootKey: string, + hiddenKeys: ReadonlySet, + edges: readonly SgModelEdge[], + coordinate: SgCoordinate, +): Readonly<{ nodes: readonly string[]; edges: readonly string[] }> { + const visible = (key: string) => + sgNodeVisible(hiddenKeys.has(key), coordinate); + const nodes = new Set([rootKey]); + const edgeIds = new Set(); + const frontier = [rootKey]; + while (frontier.length > 0) { + const whole = requireDefined(frontier.pop()); + for (const edge of edges) { + if (!sgEdgeBinds(edge, coordinate)) continue; + const wholeSide = SG_EDGE_SHAPES[edge.kind].wholeSide; + const [wholeKey, partKey] = + wholeSide === "to" ? + [edge.toKey, edge.fromKey] + : [edge.fromKey, edge.toKey]; + if (wholeKey !== whole || !visible(partKey)) continue; + edgeIds.add(edge.id); + if (nodes.has(partKey)) continue; + nodes.add(partKey); + frontier.push(partKey); + } + } + return { + nodes: [...nodes].toSorted(compareStrings), + edges: [...edgeIds].toSorted(compareStrings), + }; +} + +const sgEdgeSpecArbitrary: fc.Arbitrary = fc + .constantFrom( + "sgSubfolderOf", + "sgInFolder", + "sgHolds", + "sgSeeAlso", + ) + .chain((kind) => + fc.record({ + kind: fc.constant(kind), + // A subfolder hangs under a lower-numbered folder most of the time, so + // the trees under `n0` and `n1` grow deep; the rest may close a cycle. + ...(kind === "sgSubfolderOf" ? + { + part: fc.integer({ min: 1, max: SG_NODE_IDS.length - 1 }), + whole: fc.nat({ max: SG_NODE_IDS.length - 1 }), + } + : { + part: fc.nat({ max: SG_NODE_IDS.length - 1 }), + whole: fc.nat({ max: SG_NODE_IDS.length - 2 }), + }), + // Most rows bind at every coordinate; the rest start or end in between. + validFrom: fc + .integer({ min: 0, max: 3 }) + .map((roll) => (roll === 0 ? 1 : 0)), + validTo: fc + .integer({ min: 0, max: 3 }) + .map((roll) => (roll === 0 ? 1 : 0)), + deleted: fc.integer({ min: 0, max: 4 }).map((roll) => roll === 0), + }), + ); + +const sgCoordinateArbitrary: fc.Arbitrary = fc.oneof( + fc.constant({ mode: "current" } as const), + fc + .constantFrom(...SG_INSTANTS) + .map((asOf) => ({ mode: "asOf", asOf }) as const), +); + +const sgScenarioArbitrary = fc.record({ + hidden: fc.array( + fc.integer({ min: 0, max: 3 }).map((roll) => roll === 0), + { + minLength: 2 * SG_NODE_IDS.length, + maxLength: 2 * SG_NODE_IDS.length, + }, + ), + edges: fc.array(sgEdgeSpecArbitrary, { minLength: 1, maxLength: 16 }), + root: fc.nat({ max: 1 }), + coordinates: fc.array(sgCoordinateArbitrary, { + minLength: 1, + maxLength: 3, + }), +}); + +/** The refusals a random composition write may earn; the property only reads what was accepted. */ +const SG_EXPECTED_REFUSALS: ReadonlySet = new Set([ + "CardinalityError", + "CompositionError", + "EdgeAcyclicityError", +]); + +describe("subgraph({ composition: true }) property: the owned unit at a temporal coordinate", () => { + it("returns exactly the reference closure of the rows the store accepted", async () => { + await fc.assert( + fc.asyncProperty(sgScenarioArbitrary, async (scenario) => { + const backend = createTestBackend(); + try { + const store = createStore(compositionGraph, backend); + const nodes: SgNodeSpec[] = (["SgFolder", "SgDoc"] as const).flatMap( + (kind, kindIndex) => + SG_NODE_IDS.map((id, index) => ({ + kind, + id, + hidden: requireDefined( + scenario.hidden[kindIndex * SG_NODE_IDS.length + index], + ), + })), + ); + for (const node of nodes) { + const window = { + id: node.id, + validFrom: SG_NODE_FROM, + ...(node.hidden && { validTo: SG_NODE_ENDED }), + }; + await (node.kind === "SgFolder" ? + store.nodes.SgFolder.create({}, window) + : store.nodes.SgDoc.create({}, window)); + } + const hiddenKeys = new Set( + nodes + .filter((node) => node.hidden) + .map((node) => sgKey(node.kind, node.id)), + ); + + const edges = await writeSgEdges(store, scenario.edges); + const rootId = requireDefined(SG_NODE_IDS[scenario.root]); + const rootKey = sgKey("SgFolder", rootId); + + for (const coordinate of scenario.coordinates) { + if (!sgNodeVisible(hiddenKeys.has(rootKey), coordinate)) continue; + const result = await store.subgraph( + { kind: "SgFolder", id: asNodeId(rootId) }, + { + edges: [], + composition: true, + temporalMode: coordinate.mode satisfies TemporalMode, + ...(coordinate.mode === "asOf" && { asOf: coordinate.asOf }), + }, + ); + expect({ + coordinate, + nodes: result.nodes + .map((node) => sgKey(node.kind, node.id)) + .toSorted(compareStrings), + edges: result.edges + .map((edge) => edge.id) + .toSorted(compareStrings), + }).toEqual({ + coordinate, + ...sgExpectedClosure(rootKey, hiddenKeys, edges, coordinate), + }); + } + } finally { + await backend.close(); + } + }), + { numRuns: propertyRuns({ ci: 100, local: 250 }) }, + ); + }, 120_000); +}); + +/** Writes `specs` in order, keeping the rows the store accepts; soft-deletes the ones marked deleted. */ +async function writeSgEdges( + store: Store, + specs: readonly SgEdgeSpec[], +): Promise { + const accepted: SgModelEdge[] = []; + for (const spec of specs) { + const shape = SG_EDGE_SHAPES[spec.kind]; + const [fromIndex, toIndex] = + shape.wholeSide === "to" ? + [spec.part, spec.whole] + : [spec.whole, spec.part]; + const fromId = requireDefined(SG_NODE_IDS[fromIndex]); + const toId = requireDefined(SG_NODE_IDS[toIndex]); + const validFrom = requireDefined(SG_EDGE_FROM[spec.validFrom]); + const validTo = SG_EDGE_TO[spec.validTo]; + const window = { validFrom, ...(validTo !== undefined && { validTo }) }; + const from = { kind: shape.from, id: fromId } as const; + const to = { kind: shape.to, id: toId } as const; + let created: Readonly<{ id: string }>; + try { + created = await createSgEdge(store, spec.kind, from, to, window); + } catch (error) { + if (error instanceof Error && SG_EXPECTED_REFUSALS.has(error.name)) { + continue; + } + throw error; + } + if (spec.deleted) await deleteSgEdge(store, spec.kind, created.id); + accepted.push({ + id: created.id, + kind: spec.kind, + fromKey: sgKey(shape.from, fromId), + toKey: sgKey(shape.to, toId), + validFrom, + validTo, + deleted: spec.deleted, + }); + } + return accepted; +} + +type SgEndpoint = Readonly<{ kind: SgNodeKind; id: string }>; +type SgWindow = Readonly<{ validFrom: string; validTo?: string }>; + +function createSgEdge( + store: Store, + kind: SgEdgeKind, + from: SgEndpoint, + to: SgEndpoint, + window: SgWindow, +): Promise> { + const folder = (endpoint: SgEndpoint) => ({ + kind: "SgFolder" as const, + id: endpoint.id, + }); + const document = (endpoint: SgEndpoint) => ({ + kind: "SgDoc" as const, + id: endpoint.id, + }); + switch (kind) { + case "sgSubfolderOf": { + return store.edges.sgSubfolderOf.create( + folder(from), + folder(to), + {}, + window, + ); + } + case "sgInFolder": { + return store.edges.sgInFolder.create( + document(from), + folder(to), + {}, + window, + ); + } + case "sgHolds": { + return store.edges.sgHolds.create(folder(from), document(to), {}, window); + } + case "sgSeeAlso": { + return store.edges.sgSeeAlso.create( + folder(from), + document(to), + {}, + window, + ); + } + } +} + +function deleteSgEdge( + store: Store, + kind: SgEdgeKind, + id: string, +): Promise { + switch (kind) { + case "sgSubfolderOf": { + return store.edges.sgSubfolderOf.delete( + asEdgeId(id), + ); + } + case "sgInFolder": { + return store.edges.sgInFolder.delete(asEdgeId(id)); + } + case "sgHolds": { + return store.edges.sgHolds.delete(asEdgeId(id)); + } + case "sgSeeAlso": { + return store.edges.sgSeeAlso.delete(asEdgeId(id)); + } + } +} diff --git a/packages/typegraph/tests/property/typed-subsumption-agreement.test.ts b/packages/typegraph/tests/property/typed-subsumption-agreement.test.ts new file mode 100644 index 000000000..88859fb5b --- /dev/null +++ b/packages/typegraph/tests/property/typed-subsumption-agreement.test.ts @@ -0,0 +1,224 @@ +/** + * The compile-time ↔ registry-build agreement. + * + * The compile-time rule is the `SubClassOfCheck` conditional type + * (`src/ontology/types.ts`); `isTypeLevelSubtype` is its runtime + * prediction, `isStructuralSubtype` over each side's `projectTypeVisible` + * projection. Two things are pinned here: + * + * 1. A table of node pairs whose expected verdict the TYPE CHECKER holds to + * the real conditional type (`agreementRow`), and the runtime holds to + * `isTypeLevelSubtype`. A row whose prediction drifts from the type fails + * one side or the other, and a registry-accepted row must compile. + * 2. A property of the comparison engine itself, not of TypeScript: + * projecting value-level keywords away never turns a `subtype` verdict + * into a refusal, so whatever the registry accepts, the prediction + * accepts too. It compares one engine over two views of the same pair, + * so it cannot detect a prediction that disagrees with the type checker; + * that is what the table is for. + * + * The converse does not hold — TypeScript cannot see value-level + * constraints — and is asserted below as a named counterexample. + */ +import fc from "fast-check"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineNode } from "../../src/core/node"; +import { type NodeType } from "../../src/core/types"; +import { type SubClassOfCheck } from "../../src/ontology/types"; +import { serializeSchemaProperties } from "../../src/schema/serializer"; +import { + isStructuralSubtype, + isTypeLevelSubtype, +} from "../../src/schema/structural-subtype"; +import { comparableSchemaArb, tightenArb } from "./arbitraries"; + +/** Whether `subClassOf(C, P)` compiles: the check resolves to `unknown`. */ +type CompilesAsSubclass = + unknown extends SubClassOfCheck ? true : false; + +type AgreementRow = Readonly<{ + name: string; + child: NodeType; + parent: NodeType; + compiles: boolean; +}>; + +/** + * A row whose `compiles` the type checker verifies: passing `true` where + * `subClassOf(child, parent)` does not compile, or `false` where it does, is + * a type error. + */ +function agreementRow( + child: C, + parent: P, + compiles: CompilesAsSubclass, +): AgreementRow { + return { name: `${child.kind} ⊑ ${parent.kind}`, child, parent, compiles }; +} + +describe("compile-time ↔ registry-build agreement", () => { + it("erasing the value-level keywords never turns an accepted pair into a refused one", () => { + fc.assert( + fc.property( + comparableSchemaArb.chain((parent) => + tightenArb(parent).map((child) => [child, parent] as const), + ), + ([child, parent]) => { + const structuralResult = isStructuralSubtype(child, parent); + if (structuralResult.verdict !== "subtype") return; + expect(isTypeLevelSubtype(child, parent)).toEqual({ + verdict: "subtype", + }); + }, + ), + ); + }); + + it("named counterexample: the converse does not hold — the compile-time check accepts strictly more than the registry", () => { + // A bare `z.string()` child under a `z.string().min(3)` parent: both + // erase to the SAME type-visible projection (`{ type: "string" }`), so + // isTypeLevelSubtype says "subtype" — but the child does not actually + // guarantee the parent's minLength constraint, so isStructuralSubtype + // (the authority) correctly refuses it. + const looseChild = serializeSchemaProperties(z.string()); + const tightParent = serializeSchemaProperties(z.string().min(3)); + + expect(isTypeLevelSubtype(looseChild, tightParent)).toEqual({ + verdict: "subtype", + }); + expect(isStructuralSubtype(looseChild, tightParent).verdict).toBe( + "not-subtype", + ); + }); + + it("agrees with the compile-time check itself on a table of node pairs", () => { + // Each row's `compiles` is checked by the type checker against the real + // `SubClassOfCheck` conditional type (`agreementRow`'s third parameter), + // and at runtime against `isTypeLevelSubtype` over the same pair's + // projection — the two predictions this file exists to keep in step. + const rows: readonly AgreementRow[] = [ + agreementRow( + defineNode("AgreeSameChild", { schema: z.object({ a: z.string() }) }), + defineNode("AgreeSameParent", { schema: z.object({ a: z.string() }) }), + true, + ), + agreementRow( + defineNode("AgreeWidthChild", { + schema: z.object({ a: z.string(), b: z.number() }), + }), + defineNode("AgreeWidthParent", { schema: z.object({ a: z.string() }) }), + true, + ), + agreementRow( + defineNode("AgreeMissingChild", { + schema: z.object({ a: z.string() }), + }), + defineNode("AgreeMissingParent", { + schema: z.object({ a: z.string(), b: z.string() }), + }), + false, + ), + agreementRow( + defineNode("AgreeTypeChild", { schema: z.object({ a: z.number() }) }), + defineNode("AgreeTypeParent", { schema: z.object({ a: z.string() }) }), + false, + ), + agreementRow( + defineNode("AgreeOptionalChild", { + schema: z.object({ a: z.string().optional() }), + }), + defineNode("AgreeOptionalParent", { + schema: z.object({ a: z.string() }), + }), + false, + ), + agreementRow( + defineNode("AgreeRequiredChild", { + schema: z.object({ a: z.string() }), + }), + defineNode("AgreeRequiredParent", { + schema: z.object({ a: z.string().optional() }), + }), + true, + ), + agreementRow( + defineNode("AgreeLiteralChild", { + schema: z.object({ a: z.literal("active") }), + }), + defineNode("AgreeLiteralParent", { + schema: z.object({ a: z.enum(["active", "inactive"]) }), + }), + true, + ), + agreementRow( + defineNode("AgreeEnumChild", { + schema: z.object({ a: z.enum(["active", "inactive"]) }), + }), + defineNode("AgreeEnumParent", { + schema: z.object({ a: z.literal("active") }), + }), + false, + ), + agreementRow( + defineNode("AgreeBoundChild", { schema: z.object({ a: z.string() }) }), + defineNode("AgreeBoundParent", { + schema: z.object({ a: z.string().min(3) }), + }), + true, + ), + agreementRow( + defineNode("AgreeNullableChild", { + schema: z.object({ a: z.string().nullable() }), + }), + defineNode("AgreeNullableParent", { + schema: z.object({ a: z.string() }), + }), + false, + ), + agreementRow( + defineNode("AgreeArrayChild", { + schema: z.object({ a: z.array(z.number()) }), + }), + defineNode("AgreeArrayParent", { + schema: z.object({ a: z.array(z.string()) }), + }), + false, + ), + agreementRow( + defineNode("AgreeNestedChild", { + schema: z.object({ a: z.object({ b: z.string(), c: z.number() }) }), + }), + defineNode("AgreeNestedParent", { + schema: z.object({ a: z.object({ b: z.string() }) }), + }), + true, + ), + ]; + + for (const row of rows) { + const child = serializeSchemaProperties(row.child.schema); + const parent = serializeSchemaProperties(row.parent.schema); + expect({ + row: row.name, + compiles: isTypeLevelSubtype(child, parent).verdict === "subtype", + }).toEqual({ row: row.name, compiles: row.compiles }); + const registryAccepts = + isStructuralSubtype(child, parent).verdict === "subtype"; + expect({ + row: row.name, + compiles: row.compiles || !registryAccepts, + }).toEqual({ row: row.name, compiles: true }); + } + }); + + /** + * MUTATION CHECK (verified): adding `"minLength"` to + * `TYPE_VISIBLE_KEYWORDS` in `src/schema/structural-subtype.ts` (so the + * projection stops erasing it) fails the named counterexample and the + * table's bounded-string row; dropping `"required"` from it fails the + * table's optional-field row. Flipping any row's `compiles` literal is a + * type error in this file. + */ +}); diff --git a/packages/typegraph/tests/provenance-composition.test.ts b/packages/typegraph/tests/provenance-composition.test.ts new file mode 100644 index 000000000..cc92e3346 --- /dev/null +++ b/packages/typegraph/tests/provenance-composition.test.ts @@ -0,0 +1,961 @@ +/** + * A belief-status close reaches the REQUIRED composition parts of the whole it + * closes, and a reopen brings back the ones that are otherwise supported. + * + * The support computation — not a cascade — is what carries this: a required + * part cannot exist without a live whole, so it is support-dependent on it. + * Each case states the mutation that must make it fail. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type HistoryStore, + partOf, + subClassOf, +} from "../src"; +import { type GraphBackend } from "../src/backend/types"; +import { createRetractionCapability } from "../src/provenance"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const PcSource = defineNode("PcSource", { + schema: z.object({ + label: z.string(), + retracted: z.boolean().default(false), + }), +}); +const PcJustification = defineNode("PcJustification", { + schema: z.object({ label: z.string() }), +}); +const PcReport = defineNode("PcReport", { schema: z.object({}) }); +const PcSection = defineNode("PcSection", { schema: z.object({}) }); +const PcParagraph = defineNode("PcParagraph", { schema: z.object({}) }); +const PcAnnex = defineNode("PcAnnex", { schema: z.object({}) }); +/** A whole that carries no belief status of its own: not a fact kind. */ +const PcDossier = defineNode("PcDossier", { schema: z.object({}) }); +const PcExhibit = defineNode("PcExhibit", { schema: z.object({}) }); + +const pcPremiseOf = defineEdge("pcPremiseOf"); +const pcDerives = defineEdge("pcDerives"); +const pcSectionOf = defineEdge("pcSectionOf"); +const pcParagraphOf = defineEdge("pcParagraphOf"); +const pcAnnexOf = defineEdge("pcAnnexOf"); +const pcExhibitOf = defineEdge("pcExhibitOf"); + +const FACT_TYPES = [PcReport, PcSection, PcParagraph, PcAnnex, PcExhibit]; + +function buildGraph(id: string) { + return defineGraph({ + id, + nodes: { + PcSource: { type: PcSource }, + PcJustification: { type: PcJustification }, + PcReport: { type: PcReport }, + PcSection: { type: PcSection }, + PcParagraph: { type: PcParagraph }, + PcAnnex: { type: PcAnnex }, + PcDossier: { type: PcDossier }, + PcExhibit: { type: PcExhibit }, + }, + edges: { + pcPremiseOf: { + type: pcPremiseOf, + from: [PcSource, ...FACT_TYPES], + to: [PcJustification], + }, + pcDerives: { + type: pcDerives, + from: [PcJustification], + to: FACT_TYPES, + }, + pcSectionOf: { + type: pcSectionOf, + from: [PcSection], + to: [PcReport], + cardinality: "one", + }, + pcParagraphOf: { + type: pcParagraphOf, + from: [PcParagraph], + to: [PcSection], + cardinality: "one", + }, + pcAnnexOf: { + type: pcAnnexOf, + from: [PcAnnex], + to: [PcReport], + cardinality: "one", + }, + pcExhibitOf: { + type: pcExhibitOf, + from: [PcExhibit], + to: [PcDossier], + cardinality: "one", + }, + }, + ontology: [ + partOf(PcSection, PcReport, { + via: pcSectionOf, + existence: "required", + }), + partOf(PcParagraph, PcSection, { + via: pcParagraphOf, + existence: "required", + }), + // The contrast case: an optional part can exist with no whole, so its + // belief status owes the whole nothing. + partOf(PcAnnex, PcReport, { via: pcAnnexOf }), + partOf(PcExhibit, PcDossier, { + via: pcExhibitOf, + existence: "required", + }), + ], + }); +} + +/** A whole base kind no configuration names, with a fact kind beneath it. */ +const PcDocument = defineNode("PcDocument", { schema: z.object({}) }); +const PcDocumentReport = defineNode("PcDocReport", { schema: z.object({}) }); +/** A declared required part kind, and a subclass that inherits that existence. */ +const PcClause = defineNode("PcClause", { schema: z.object({}) }); +const PcSubClause = defineNode("PcSubClause", { schema: z.object({}) }); +const pcClauseOf = defineEdge("pcClauseOf"); + +const SUBSUMPTION_FACT_TYPES = [PcDocumentReport, PcClause, PcSubClause]; + +/** + * Both subsumption arms of the configuration refusal in one graph: the fact + * kind is a SUBCLASS of the kind the pair declared as its whole, and the + * declared part kind has a subclass that is required purely by inheritance. + */ +function buildSubsumptionGraph(id: string) { + return defineGraph({ + id, + nodes: { + PcSource: { type: PcSource }, + PcJustification: { type: PcJustification }, + PcDocument: { type: PcDocument }, + PcDocReport: { type: PcDocumentReport }, + PcClause: { type: PcClause }, + PcSubClause: { type: PcSubClause }, + }, + edges: { + pcPremiseOf: { + type: pcPremiseOf, + from: [PcSource, ...SUBSUMPTION_FACT_TYPES], + to: [PcJustification], + }, + pcDerives: { + type: pcDerives, + from: [PcJustification], + to: SUBSUMPTION_FACT_TYPES, + }, + pcClauseOf: { + type: pcClauseOf, + from: [PcClause], + to: [PcDocument], + cardinality: "one", + }, + }, + ontology: [ + subClassOf(PcDocumentReport, PcDocument), + subClassOf(PcSubClause, PcClause), + partOf(PcClause, PcDocument, { + via: pcClauseOf, + existence: "required", + }), + ], + }); +} + +const config = { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { + kinds: ["PcReport", "PcSection", "PcParagraph", "PcAnnex", "PcExhibit"], + }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, +} as const; + +type CompositionGraph = ReturnType; + +async function createCompositionStore( + id: string, +): Promise< + Readonly<{ store: HistoryStore; backend: GraphBackend }> +> { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(buildGraph(id), backend, { + history: true, + }); + return { store, backend }; +} + +/** + * Two independent sources. `report-source` supports the whole; `part-source` + * supports every part on its own, so a part that closes can only have closed + * because of its whole. + */ +async function seedComposedReport(store: HistoryStore) { + const reportSource = await store.nodes.PcSource.create( + { label: "report", retracted: false }, + { id: "report-source" }, + ); + const partSource = await store.nodes.PcSource.create( + { label: "part", retracted: false }, + { id: "part-source" }, + ); + const report = await store.nodes.PcReport.create({}, { id: "report-1" }); + const section = await store.nodes.PcSection.create( + {}, + { id: "section-1", partOf: { whole: { kind: "PcReport", id: report.id } } }, + ); + const paragraph = await store.nodes.PcParagraph.create( + {}, + { + id: "paragraph-1", + partOf: { whole: { kind: "PcSection", id: section.id } }, + }, + ); + const annex = await store.nodes.PcAnnex.create( + {}, + { id: "annex-1", partOf: { whole: { kind: "PcReport", id: report.id } } }, + ); + + const reportJustification = await store.nodes.PcJustification.create( + { label: "report" }, + { id: "report-justification" }, + ); + const partJustification = await store.nodes.PcJustification.create( + { label: "parts" }, + { id: "part-justification" }, + ); + await store.edges.pcPremiseOf.create(reportSource, reportJustification); + await store.edges.pcPremiseOf.create(partSource, partJustification); + await store.edges.pcDerives.create(reportJustification, report); + for (const fact of [section, paragraph, annex]) { + await store.edges.pcDerives.create(partJustification, fact); + } + + return { reportSource, partSource, report, section, paragraph, annex }; +} + +/** + * The non-fact-whole shape: a dossier that carries no belief status, a required + * exhibit under it, and TWO independent sources supporting that exhibit, so a + * transition can reach the exhibit without removing its own grounding. + */ +async function seedExhibitUnderDossier( + store: HistoryStore, + dossierWindow?: Readonly<{ validFrom: string; validTo: string }>, +) { + const sourceOne = await store.nodes.PcSource.create( + { label: "scanner", retracted: false }, + { id: "exhibit-source-1" }, + ); + const sourceTwo = await store.nodes.PcSource.create( + { label: "vendor", retracted: false }, + { id: "exhibit-source-2" }, + ); + const dossier = await store.nodes.PcDossier.create( + {}, + { id: "dossier-1", ...dossierWindow }, + ); + const exhibit = await store.nodes.PcExhibit.create( + {}, + { + id: "exhibit-1", + partOf: { whole: { kind: "PcDossier", id: dossier.id } }, + }, + ); + const attachments = await store.edges.pcExhibitOf.find({}); + const attachment = requireDefined( + attachments[0], + "the exhibit's composition edge", + ); + for (const [index, source] of [sourceOne, sourceTwo].entries()) { + const justification = await store.nodes.PcJustification.create( + { label: `exhibits-${index}` }, + { id: `exhibit-justification-${index}` }, + ); + await store.edges.pcPremiseOf.create(source, justification); + await store.edges.pcDerives.create(justification, exhibit); + } + return { sourceOne, sourceTwo, dossier, exhibit, attachment }; +} + +/** Every currently live fact, as the report spells a fact reference. */ +async function liveFacts( + store: HistoryStore, +): Promise[]> { + const byKind = await Promise.all([ + store.nodes.PcReport.find({}), + store.nodes.PcSection.find({}), + store.nodes.PcParagraph.find({}), + store.nodes.PcAnnex.find({}), + store.nodes.PcExhibit.find({}), + ]); + return byKind + .flat() + .map((row) => ({ kind: row.kind, id: row.id })) + .toSorted((left, right) => + `${left.kind}/${left.id}`.localeCompare(`${right.kind}/${right.id}`), + ); +} + +/** + * Runs `transition` and returns which facts it actually tombstoned, so a case + * can assert the report NAMES every close. A close the report cannot mention is + * invisible data loss — the defect the whole-liveness predicate must not + * reintroduce. + */ +async function closedFactsDuring( + store: HistoryStore, + transition: () => Promise, +): Promise> { + const before = await liveFacts(store); + const result = await transition(); + const factsAfter = await liveFacts(store); + const after = new Set(factsAfter.map((fact) => `${fact.kind}/${fact.id}`)); + return { + result, + closed: before + .map((fact) => `${fact.kind}/${fact.id}`) + .filter((key) => !after.has(key)), + }; +} + +/** What `run` threw, or `undefined` when it returned. */ +function captureThrown(run: () => unknown): unknown { + try { + run(); + } catch (error) { + return error; + } + return undefined; +} + +describe("provenance composition existence", () => { + it("closes the required parts of a closed whole and leaves the optional part believed", async () => { + const { store } = await createCompositionStore( + "provenance_composition_close", + ); + const seeded = await seedComposedReport(store); + const provenance = createRetractionCapability(store, config); + + const { result: report, closed } = await closedFactsDuring(store, () => + provenance.retract(seeded.reportSource), + ); + + // Leaf-first is irrelevant here (no edge is touched), but every closed + // part must be NAMED: a part that died invisibly would be data loss the + // report cannot explain. + expect(closed).toEqual( + report.died.map((fact) => `${fact.kind}/${fact.id}`).toSorted(), + ); + expect(report.died).toEqual([ + { kind: "PcParagraph", id: "paragraph-1" }, + { kind: "PcReport", id: "report-1" }, + { kind: "PcSection", id: "section-1" }, + ]); + // The optional part is not even in scope: nothing this transition can + // reach decides its belief status. + expect(report.survivedVia).toEqual([]); + expect(report.unaffected).toEqual([{ kind: "PcAnnex", id: "annex-1" }]); + + await expect( + store.nodes.PcReport.getById(seeded.report.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.PcSection.getById(seeded.section.id), + ).resolves.toBeUndefined(); + await expect( + store.nodes.PcParagraph.getById(seeded.paragraph.id), + ).resolves.toBeUndefined(); + // The optional part keeps its attachment to a closed whole and its own + // belief status: it can exist with no whole at all. + await expect( + store.nodes.PcAnnex.getById(seeded.annex.id), + ).resolves.toMatchObject({ id: "annex-1" }); + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcAnnex", id: "annex-1" }, + ]); + + // A closed required part is tombstoned, not orphaned, so the audit that + // reports live unattached required parts must stay silent. + const violations = await store.verifyConstraintFences(); + expect( + violations.filter( + (violation) => violation.family === "compositionExistence", + ), + ).toEqual([]); + }); + // MUTATION CHECK: make `compositionExistenceSupported` + // (src/provenance/index.ts) return `true` unconditionally. The section and + // paragraph stay believed and both the `died` list and the two `getById` + // assertions fail. + + it("reopens the parts its close closed once the whole is supported again", async () => { + const { store } = await createCompositionStore( + "provenance_composition_reopen", + ); + const { reportSource } = await seedComposedReport(store); + const provenance = createRetractionCapability(store, config); + + await provenance.retract(reportSource); + const report = await provenance.unRetract(reportSource); + + // Reopen is driven by support, exactly as a close is: the parts come back + // because their own justification still fires AND their whole is + // supported again. There is no ledger of what a close closed. + expect(report.died).toEqual([]); + expect(report.survivedVia).toEqual([ + { + fact: { kind: "PcParagraph", id: "paragraph-1" }, + via: [{ kind: "PcJustification", id: "part-justification" }], + }, + { + fact: { kind: "PcReport", id: "report-1" }, + via: [{ kind: "PcJustification", id: "report-justification" }], + }, + { + fact: { kind: "PcSection", id: "section-1" }, + via: [{ kind: "PcJustification", id: "part-justification" }], + }, + ]); + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcAnnex", id: "annex-1" }, + { kind: "PcParagraph", id: "paragraph-1" }, + { kind: "PcReport", id: "report-1" }, + { kind: "PcSection", id: "section-1" }, + ]); + }); + // MUTATION CHECK: drop the part propagation from + // `computeAffectedFactKeys` (the `partKeysByWholeKey` walk). The section and + // paragraph fall out of `affected`, so the reopen pass never reaches them + // and `holding()` returns the annex and the report alone. + + it("keeps a part closed while its own source is retracted and its whole is not", async () => { + const { store } = await createCompositionStore( + "provenance_composition_part_source", + ); + const { partSource } = await seedComposedReport(store); + const provenance = createRetractionCapability(store, config); + + // The mirror of the first case: retracting the PART source closes the + // parts and leaves the whole believed — the dependency runs one way. + const report = await provenance.retract(partSource); + + expect(report.died).toEqual([ + { kind: "PcAnnex", id: "annex-1" }, + { kind: "PcParagraph", id: "paragraph-1" }, + { kind: "PcSection", id: "section-1" }, + ]); + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcReport", id: "report-1" }, + ]); + }); + // MUTATION CHECK: make `compositionExistenceSupported` return `false` for + // every fact. The report closes too and `holding()` comes back empty. + + it("leaves a required part alone while its whole stays supported through another source", async () => { + const { store } = await createCompositionStore( + "provenance_composition_surviving_whole", + ); + const firstSource = await store.nodes.PcSource.create( + { label: "first", retracted: false }, + { id: "source-1" }, + ); + const secondSource = await store.nodes.PcSource.create( + { label: "second", retracted: false }, + { id: "source-2" }, + ); + const report = await store.nodes.PcReport.create({}, { id: "report-1" }); + // A required part whose own justification is not linked yet: unsupported + // before and after, so no transition over the whole is its business. + const section = await store.nodes.PcSection.create( + {}, + { + id: "section-1", + partOf: { whole: { kind: "PcReport", id: report.id } }, + }, + ); + for (const [index, source] of [firstSource, secondSource].entries()) { + const justification = await store.nodes.PcJustification.create( + { label: `justification-${index}` }, + { id: `justification-${index}` }, + ); + await store.edges.pcPremiseOf.create(source, justification); + await store.edges.pcDerives.create(justification, report); + } + const provenance = createRetractionCapability(store, config); + + const { result: partial, closed: closedByPartial } = + await closedFactsDuring(store, () => provenance.retract(firstSource)); + expect(partial.died).toEqual([]); + expect(partial.survivedVia.map(({ fact }) => fact.id)).toEqual([ + "report-1", + ]); + expect(closedByPartial).toEqual([]); + await expect( + store.nodes.PcSection.getById(section.id), + ).resolves.toMatchObject({ + id: "section-1", + }); + + // Once the whole's last source goes, its support does change and the part + // it holds up is carried into scope with it. + const { result: final, closed: closedByFinal } = await closedFactsDuring( + store, + () => provenance.retract(secondSource), + ); + expect(final.died).toEqual([ + { kind: "PcReport", id: "report-1" }, + { kind: "PcSection", id: "section-1" }, + ]); + expect(closedByFinal).toEqual( + final.died.map((fact) => `${fact.kind}/${fact.id}`).toSorted(), + ); + }); + // MUTATION CHECK: in `computeAffectedFactKeys`, drop the + // `supportedBefore.has(current) === supportedAfter.has(current)` guard so a + // reached whole always carries its required parts. The first retraction then + // closes the unjustified section while the report is still supported. + + it("closes the required parts of a whole it closes although the whole was already unsupported", async () => { + const { store } = await createCompositionStore( + "provenance_composition_stale_whole", + ); + const staleSource = await store.nodes.PcSource.create( + { label: "stale", retracted: true }, + { id: "stale-source" }, + ); + const liveSource = await store.nodes.PcSource.create( + { label: "live", retracted: false }, + { id: "live-source" }, + ); + const partSource = await store.nodes.PcSource.create( + { label: "part", retracted: false }, + { id: "part-source" }, + ); + const report = await store.nodes.PcReport.create({}, { id: "report-1" }); + const section = await store.nodes.PcSection.create( + {}, + { + id: "section-1", + partOf: { whole: { kind: "PcReport", id: report.id } }, + }, + ); + // The report is live although its only justification needs a retracted + // source: unsupported before and after the transition below. + const reportJustification = await store.nodes.PcJustification.create( + { label: "report" }, + { id: "report-justification" }, + ); + await store.edges.pcPremiseOf.create(staleSource, reportJustification); + await store.edges.pcPremiseOf.create(liveSource, reportJustification); + await store.edges.pcDerives.create(reportJustification, report); + const partJustification = await store.nodes.PcJustification.create( + { label: "part" }, + { id: "part-justification" }, + ); + await store.edges.pcPremiseOf.create(partSource, partJustification); + await store.edges.pcDerives.create(partJustification, section); + const provenance = createRetractionCapability(store, config); + + const { result: transition, closed } = await closedFactsDuring(store, () => + provenance.retract(liveSource), + ); + + // The close policy turns the composition cascade off on the promise that a + // closed whole's required parts close in the same transition, so a whole + // closed without a support change must still bring its parts along. + expect(transition.died).toEqual([ + { kind: "PcReport", id: "report-1" }, + { kind: "PcSection", id: "section-1" }, + ]); + expect(closed).toEqual(["PcReport/report-1", "PcSection/section-1"]); + }); + // MUTATION CHECK: in `computeAffectedFactKeys`, skip a reached whole whose + // support verdict is unchanged without also asking `currencyMovesWithSupport`. + // The report closes and the section stays live under it. + + it("reopens the required parts of a supported whole it reopens without a support change", async () => { + const { store, backend } = await createCompositionStore( + "provenance_composition_tombstoned_supported_whole", + ); + const firstSource = await store.nodes.PcSource.create( + { label: "first", retracted: false }, + { id: "source-1" }, + ); + const secondSource = await store.nodes.PcSource.create( + { label: "second", retracted: false }, + { id: "source-2" }, + ); + const partSource = await store.nodes.PcSource.create( + { label: "part", retracted: false }, + { id: "part-source" }, + ); + const report = await store.nodes.PcReport.create({}, { id: "report-1" }); + const section = await store.nodes.PcSection.create( + {}, + { + id: "section-1", + partOf: { whole: { kind: "PcReport", id: report.id } }, + }, + ); + for (const [index, source] of [firstSource, secondSource].entries()) { + const justification = await store.nodes.PcJustification.create( + { label: `report-${index}` }, + { id: `report-justification-${index}` }, + ); + await store.edges.pcPremiseOf.create(source, justification); + await store.edges.pcDerives.create(justification, report); + } + const partJustification = await store.nodes.PcJustification.create( + { label: "part" }, + { id: "part-justification" }, + ); + await store.edges.pcPremiseOf.create(partSource, partJustification); + await store.edges.pcDerives.create(partJustification, section); + // Both die outside the store's own paths, leaving a supported but + // tombstoned whole and part. + for (const fact of [report, section]) { + await backend.deleteNode({ + graphId: store.graphId, + kind: fact.kind, + id: fact.id, + }); + } + const provenance = createRetractionCapability(store, config); + + await provenance.retract(firstSource); + + await expect( + store.nodes.PcReport.getById(report.id), + ).resolves.toMatchObject({ id: "report-1" }); + await expect( + store.nodes.PcSection.getById(section.id), + ).resolves.toMatchObject({ id: "section-1" }); + }); + // MUTATION CHECK: as above, dropping the `currencyMovesWithSupport` leg + // reopens the report and leaves the section tombstoned under it. + + it("holds a required part whose whole is a live node that carries no belief status", async () => { + const { store } = await createCompositionStore( + "provenance_composition_nonfact", + ); + const source = await store.nodes.PcSource.create( + { label: "exhibits", retracted: false }, + { id: "exhibit-source" }, + ); + const dossier = await store.nodes.PcDossier.create({}, { id: "dossier-1" }); + const exhibit = await store.nodes.PcExhibit.create( + {}, + { + id: "exhibit-1", + partOf: { whole: { kind: "PcDossier", id: dossier.id } }, + }, + ); + const justification = await store.nodes.PcJustification.create( + { label: "exhibits" }, + { id: "exhibit-justification" }, + ); + await store.edges.pcPremiseOf.create(source, justification); + await store.edges.pcDerives.create(justification, exhibit); + const provenance = createRetractionCapability(store, config); + + // A whole that is not a fact has no support to read, so its liveness is + // the whole of the dependency. + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcExhibit", id: "exhibit-1" }, + ]); + }); + // MUTATION CHECK: make the non-fact arm of `compositionExistenceSupported` + // return `false` (or skip the `liveWholeKeys` read in + // `readCompositionExistenceDependencies`). The exhibit is never held. + + it("keeps holding a required part whose non-fact whole has an ENDED validity window", async () => { + const { store } = await createCompositionStore( + "provenance_composition_ended_window", + ); + const { sourceOne } = await seedExhibitUnderDossier(store, { + validFrom: "2020-01-01T00:00:00.000Z", + validTo: "2021-01-01T00:00:00.000Z", + }); + const provenance = createRetractionCapability(store, config); + + // A whole whose window has closed is still a LIVE row, and the write path + // would accept it as a whole today, so the part is still held up. Judging + // it dead here made the part unbelieved BEFORE a transition and tombstoned + // BY one, a close no report could name. + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcExhibit", id: "exhibit-1" }, + ]); + + const { result: report, closed } = await closedFactsDuring(store, () => + provenance.retract(sourceOne), + ); + + expect(closed).toEqual([]); + expect(report.died).toEqual([]); + expect(report.survivedVia).toEqual([ + { + fact: { kind: "PcExhibit", id: "exhibit-1" }, + via: [{ kind: "PcJustification", id: "exhibit-justification-1" }], + }, + ]); + }); + // MUTATION CHECK: restore the validity-window conjunct in + // `readCompositionExistenceDependencies` (reject a whole row whose window + // does not contain the read instant). `holding()` comes back empty, `closed` + // names the exhibit, and `died` stays empty — the unnamed close. + + it("stops holding a required part whose non-fact whole is tombstoned", async () => { + const { store, backend } = await createCompositionStore( + "provenance_composition_dead_whole", + ); + const { dossier } = await seedExhibitUnderDossier(store); + const provenance = createRetractionCapability(store, config); + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcExhibit", id: "exhibit-1" }, + ]); + + // Tombstoned through the backend, which is what leaves the part behind: + // the store's own delete would cascade to it. + await backend.deleteNode({ + graphId: store.graphId, + kind: "PcDossier", + id: dossier.id, + }); + + await expect(provenance.holding()).resolves.toEqual([]); + // The same state the audit reports, from the same liveness verdict. + const violations = await store.verifyConstraintFences(); + expect( + violations.filter( + (violation) => violation.family === "compositionExistence", + ), + ).toEqual([ + { + family: "compositionExistence", + partKind: "PcExhibit", + parts: [{ kind: "PcExhibit", id: "exhibit-1" }], + }, + ]); + }); + // MUTATION CHECK: drop the `isLiveNodeRow` check in + // `readLiveCompositionWholes` (treat every read row as live). The exhibit is + // still held after its whole is tombstoned, and the audit reports nothing. + + it("a whole's delete removes the composition edge of a part a retraction already closed", async () => { + const { store } = await createCompositionStore( + "provenance_composition_retracted_part_edge", + ); + const source = await store.nodes.PcSource.create( + { label: "exhibits", retracted: false }, + { id: "exhibit-source" }, + ); + const dossier = await store.nodes.PcDossier.create({}, { id: "dossier-1" }); + const exhibit = await store.nodes.PcExhibit.create( + {}, + { + id: "exhibit-1", + partOf: { whole: { kind: "PcDossier", id: dossier.id } }, + }, + ); + const justification = await store.nodes.PcJustification.create( + { label: "exhibits" }, + { id: "exhibit-justification" }, + ); + await store.edges.pcPremiseOf.create(source, justification); + await store.edges.pcDerives.create(justification, exhibit); + const provenance = createRetractionCapability(store, config); + + // The retraction closes the exhibit and leaves its composition edge. + await provenance.retract(source); + expect(await store.nodes.PcExhibit.getById(exhibit.id)).toBeUndefined(); + expect(await store.edges.pcExhibitOf.find({})).toHaveLength(1); + + // MUTATION CHECK: returning from `applyCompositionCascade` when the plan + // has no live member skips the consumed-edge cleanup, and the edge + // between the two tombstones stays live — verified and reverted. + await store.nodes.PcDossier.delete(dossier.id); + + expect(await store.nodes.PcDossier.getById(dossier.id)).toBeUndefined(); + expect(await store.edges.pcExhibitOf.find({})).toHaveLength(0); + }); + + it("names a part it closes whose whole was already tombstoned before the transition", async () => { + const { store, backend } = await createCompositionStore( + "provenance_composition_already_dead_whole", + ); + const { sourceOne, dossier } = await seedExhibitUnderDossier(store); + const provenance = createRetractionCapability(store, config); + + // The whole dies outside the store's own paths, so the part is already + // unsupported when the transition arrives — the state + // `verifyConstraintFences()` reports as a `compositionExistence` + // violation. + await backend.deleteNode({ + graphId: store.graphId, + kind: "PcDossier", + id: dossier.id, + }); + + const { result: report, closed } = await closedFactsDuring(store, () => + provenance.retract(sourceOne), + ); + + // The transition still ends this row's currency, so the report must name + // it: a tombstone the report cannot mention is invisible data loss, no + // matter what made the fact unsupported beforehand. + expect(closed).toEqual(["PcExhibit/exhibit-1"]); + expect(report.died).toEqual([{ kind: "PcExhibit", id: "exhibit-1" }]); + }); + // MUTATION CHECK: restore the `believedBefore` filter in `buildReport` + // (`died` = believed before and unsupported after). The row is still + // tombstoned and `died` comes back empty. + + it("stops holding a required part whose composition edge is gone", async () => { + const { store, backend } = await createCompositionStore( + "provenance_composition_no_edge", + ); + const { attachment } = await seedExhibitUnderDossier(store); + const provenance = createRetractionCapability(store, config); + await expect(provenance.holding()).resolves.toEqual([ + { kind: "PcExhibit", id: "exhibit-1" }, + ]); + + // Hard-deleted through the backend: detaching a live required part is + // refused by the write path, which is the point — only a direct write can + // reach this state, and support must not pretend the part still hangs + // from something. + await backend.hardDeleteEdge({ + graphId: store.graphId, + kind: "pcExhibitOf", + id: attachment.id, + }); + + await expect(provenance.holding()).resolves.toEqual([]); + }); + // MUTATION CHECK: in `compositionExistenceSupported`, return `true` when + // `wholeKey === undefined`. The exhibit is held with no whole at all. + + it("refuses a configuration whose fact kind owns a required part that is not a fact", async () => { + const { store } = await createCompositionStore( + "provenance_composition_unreachable_part", + ); + + // PcSection and PcParagraph are required parts under fact wholes, so a + // configuration that tracks the wholes without them could close a whole + // and leave a live required part hanging from it. + expect(() => + createRetractionCapability(store, { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { kinds: ["PcReport"] }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, + }), + ).toThrow(/PcSection.*is not a fact kind/); + + const error = captureThrown(() => + createRetractionCapability(store, { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { kinds: ["PcReport", "PcAnnex"] }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, + }), + ); + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "PROVENANCE_REQUIRED_PART_NOT_A_FACT", + wholeKind: "PcReport", + partKind: "PcSection", + }); + }); + // MUTATION CHECK: delete the + // `assertRequiredPartsOfFactWholesAreFacts(store.registry, normalized)` call + // in `createRetractionCapability`. Both configurations above are accepted. + + it("refuses when the fact kind SUBCLASSES the kind the pair declared as its whole", async () => { + const [store] = await createStoreWithSchema( + buildSubsumptionGraph("provenance_composition_whole_subclass"), + createTestBackend(), + { history: true }, + ); + + // The pair names PcDocument as its whole; the configuration tracks + // PcDocReport, which IS a PcDocument, so its rows are wholes of required + // PcClause parts this configuration could never close. + const error = captureThrown(() => + createRetractionCapability(store, { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { kinds: ["PcDocReport"] }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, + }), + ); + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "PROVENANCE_REQUIRED_PART_NOT_A_FACT", + wholeKind: "PcDocReport", + partKind: "PcClause", + }); + }); + // MUTATION CHECK: in `assertRequiredPartsOfFactWholesAreFacts`, replace + // `registry.isAssignableTo(wholeKind, pair.wholeKind)` with + // `wholeKind === pair.wholeKind`. PcDocReport stops matching the pair's + // declared PcDocument whole and this configuration is accepted. + + it("refuses when only a SUBCLASS of the declared part kind is missing from the fact kinds", async () => { + const [store] = await createStoreWithSchema( + buildSubsumptionGraph("provenance_composition_part_subclass"), + createTestBackend(), + { history: true }, + ); + + // PcClause is tracked, so the declared part kind itself is reachable — but + // PcSubClause is a required part too, purely by inheriting PcClause's + // existence, and a row of it would be just as stranded. + const error = captureThrown(() => + createRetractionCapability(store, { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { kinds: ["PcDocReport", "PcClause"] }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, + }), + ); + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "PROVENANCE_REQUIRED_PART_NOT_A_FACT", + wholeKind: "PcDocReport", + partKind: "PcSubClause", + }); + + // With every required part kind tracked, the same graph configures. + expect(() => + createRetractionCapability(store, { + source: { kind: "PcSource" }, + justification: { kind: "PcJustification" }, + fact: { kinds: ["PcDocReport", "PcClause", "PcSubClause"] }, + premiseOf: { kind: "pcPremiseOf" }, + derives: { kind: "pcDerives" }, + }), + ).not.toThrow(); + }); + // MUTATION CHECK: in `assertRequiredPartsOfFactWholesAreFacts`, replace + // `registry.expandSubClasses(pair.partKind)` with `[pair.partKind]`. Only + // PcClause is checked, it is a fact kind, and this configuration is accepted. +}); diff --git a/packages/typegraph/tests/provenance.test.ts b/packages/typegraph/tests/provenance.test.ts index 66cbd8209..572a14f2a 100644 --- a/packages/typegraph/tests/provenance.test.ts +++ b/packages/typegraph/tests/provenance.test.ts @@ -530,6 +530,50 @@ describe("provenance retraction contract", () => { await expect(store.nodes.Fact.getById(fact.id)).resolves.toBeUndefined(); }); + it("closes and names a live fact whose justification never fired", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend, { + history: true, + }); + // An ordinary, fully validated seeding that still leaves a live fact with + // no firing justification: the justification needs BOTH premises, and one + // of its sources is created with its retracted flag already set. Nothing + // here violates a constraint, so no audit reports this state. + const available = await store.nodes.Source.create( + { label: "available", retracted: false }, + { id: "source-available" }, + ); + const retracted = await store.nodes.Source.create( + { label: "retracted", retracted: true }, + { id: "source-retracted" }, + ); + const fact = await store.nodes.Fact.create( + { label: "never-grounded" }, + { id: "never-grounded" }, + ); + const justification = await store.nodes.Justification.create( + { label: "needs-both" }, + { id: "needs-both" }, + ); + await store.edges.premiseOf.create(available, justification); + await store.edges.premiseOf.create(retracted, justification); + await store.edges.derives.create(justification, fact); + + const provenance = createRetractionCapability(store, config); + await expect(provenance.holding()).resolves.toEqual([]); + + const report = await provenance.retract(available); + + // The transition ends this row's currency, so it must say so — `died` is + // the set of facts the close pass wrote, not a filter over what was + // believed beforehand. + expect(report.died).toEqual([{ kind: "Fact", id: "never-grounded" }]); + await expect(store.nodes.Fact.getById(fact.id)).resolves.toBeUndefined(); + }); + // MUTATION CHECK: restore the `believedBefore` filter in `buildReport` + // (src/provenance/index.ts) so `died` keeps only facts believed before the + // transition. The fact is still tombstoned and `died` comes back empty. + it("supports no-premise justifications as axioms", async () => { const backend = createTestBackend(); const [store] = await createStoreWithSchema(graph, backend, { diff --git a/packages/typegraph/tests/public-contract.test.ts b/packages/typegraph/tests/public-contract.test.ts new file mode 100644 index 000000000..b96a6bd86 --- /dev/null +++ b/packages/typegraph/tests/public-contract.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + compositionViaKind, + DEFAULT_ALIAS_EXPANSION_AXIS, + defineEdge, + PROBE_VIOLATION_FAMILIES, + probeCoversViolationFamily, + SEARCH_EXPANSION_DEFAULT, +} from "../src"; + +describe("public contract seams", () => { + it("search and query expansion defaults are different constants", () => { + expect(SEARCH_EXPANSION_DEFAULT).toBe("exact"); + expect(DEFAULT_ALIAS_EXPANSION_AXIS).toBe("subclasses"); + expect(SEARCH_EXPANSION_DEFAULT).not.toBe(DEFAULT_ALIAS_EXPANSION_AXIS); + }); + + it("compositionViaKind accepts an edge type or a kind string", () => { + const via = defineEdge("episodeOf", { schema: z.object({}) }); + expect(compositionViaKind(via)).toBe("episodeOf"); + expect(compositionViaKind("episodeOf")).toBe("episodeOf"); + }); + + it("every probe kind names the violation families a fence row uses", () => { + const probe = { + families: PROBE_VIOLATION_FAMILIES.composition, + }; + expect(probe.families).toEqual([ + "composition", + "edgeAcyclicity", + "compositionFutureWindow", + ]); + expect(probeCoversViolationFamily(probe, "composition")).toBe(true); + expect(probeCoversViolationFamily(probe, "edgeAcyclicity")).toBe(true); + expect(probeCoversViolationFamily(probe, "compositionFutureWindow")).toBe( + true, + ); + expect(probeCoversViolationFamily(probe, "compositionExistence")).toBe( + false, + ); + expect(PROBE_VIOLATION_FAMILIES.compositionExistence).toEqual([ + "compositionExistence", + ]); + expect(PROBE_VIOLATION_FAMILIES.nodeUniqueness).toEqual(["nodeUniqueness"]); + expect( + ( + Object.values(PROBE_VIOLATION_FAMILIES).flat() as readonly string[] + ).includes("edgeCardinality"), + ).toBe(false); + }); +}); diff --git a/packages/typegraph/tests/query-builder-types.test.ts b/packages/typegraph/tests/query-builder-types.test.ts index 774f66005..26d5a18bb 100644 --- a/packages/typegraph/tests/query-builder-types.test.ts +++ b/packages/typegraph/tests/query-builder-types.test.ts @@ -37,6 +37,7 @@ import { type TypedEdgeCollection, } from "../src"; import { ConfigurationError } from "../src/errors"; +import type { Traversal } from "../src/query/ast"; import type { TraversalBuilder } from "../src/query/builder/traversal-builder"; import type { DialectAdapter } from "../src/query/dialect/types"; import type { @@ -584,9 +585,11 @@ describe("Query Builder Type Safety", () => { it("pins the positional constructor parameter order", () => { type Params = ConstructorParameters; - // Six required parameters followed by five optional ones. Any insertion + // Six required parameters followed by six optional ones. Any insertion // widens this union and fails here first. - expectTypeOf().toEqualTypeOf<6 | 7 | 8 | 9 | 10 | 11>(); + expectTypeOf().toEqualTypeOf< + 6 | 7 | 8 | 9 | 10 | 11 | 12 + >(); expectTypeOf().toEqualTypeOf(); expectTypeOf().toEqualTypeOf(); @@ -599,6 +602,9 @@ describe("Query Builder Type Safety", () => { expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); expectTypeOf>().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + Traversal["compositionMembership"] + >(); }); it("carries identity expansion through recursive and edge-predicate rebuilds", () => { diff --git a/packages/typegraph/tests/query-builder.test.ts b/packages/typegraph/tests/query-builder.test.ts index 25a280023..550a5a0c8 100644 --- a/packages/typegraph/tests/query-builder.test.ts +++ b/packages/typegraph/tests/query-builder.test.ts @@ -468,25 +468,38 @@ describe("Query Builder - Traversals", () => { describe("Query Builder - Subclass Expansion", () => { it("expands queries to include subclasses when requested", () => { const query = createQueryBuilder(graph.id, registry) - .from("Organization", "o", { includeSubClasses: true }) + .from("Organization", "o", { expansion: "subclasses" }) .select((context) => context.o); const ast = query.toAst(); expect(ast.start.kinds).toContain("Organization"); expect(ast.start.kinds).toContain("Company"); - expect(ast.start.includeSubClasses).toBe(true); + expect(ast.start.expansion).toBe("subclasses"); }); - it("does not expand subclasses by default", () => { + // A supertype query is polymorphic by default. + it("expands subclasses by default", () => { const query = createQueryBuilder(graph.id, registry) .from("Organization", "o") .select((context) => context.o); const ast = query.toAst(); + expect(ast.start.kinds).toContain("Organization"); + expect(ast.start.kinds).toContain("Company"); + expect(ast.start.expansion).toBe("subclasses"); + }); + + it("does not expand subclasses when explicitly narrowed", () => { + const query = createQueryBuilder(graph.id, registry) + .from("Organization", "o", { expansion: "exact" }) + .select((context) => context.o); + + const ast = query.toAst(); + expect(ast.start.kinds).toEqual(["Organization"]); - expect(ast.start.includeSubClasses).toBe(false); + expect(ast.start.expansion).toBe("exact"); }); }); @@ -575,10 +588,14 @@ describe("Query Compilation to SQL", () => { }); it("adds edge endpoint kind filters for outgoing traversals", () => { + // Pinned exact-kind: Organization is a subClassOf parent in this + // fixture graph, so an un-narrowed `.to()` would poly-expand to an + // `IN (...)` filter under the polymorphic default — this test is about the + // single-kind filter shape itself. const query = createQueryBuilder(graph.id, registry) - .from("Person", "p") + .from("Person", "p", { expansion: "exact" }) .traverse("worksAt", "e") - .to("Organization", "o") + .to("Organization", "o", { expansion: "exact" }) .select((context) => ({ p: context.p, o: context.o })); const sqlObject = compileQuery(query.toAst(), graph.id); diff --git a/packages/typegraph/tests/query-dynamic.test.ts b/packages/typegraph/tests/query-dynamic.test.ts index 5435cfbe4..aa2ae1a4e 100644 --- a/packages/typegraph/tests/query-dynamic.test.ts +++ b/packages/typegraph/tests/query-dynamic.test.ts @@ -2,6 +2,7 @@ import { describe, expect, expectTypeOf, it } from "vitest"; import { z } from "zod"; import { + ConfigurationError, defineEdge, defineGraph, defineNode, @@ -15,7 +16,7 @@ import { } from "../src/query/builder/dynamic"; import { createStoreWithSchema } from "../src/store/store"; import { requireDefined } from "../src/utils/presence"; -import { createTestBackend } from "./test-utils"; +import { createTestBackend, matchingObject } from "./test-utils"; const Document = defineNode("Document", { schema: z.object({ @@ -203,6 +204,99 @@ describe("traverseDynamic + toDynamic", () => { }); }); +describe("traverseDynamic + toKindSet", () => { + it("finalizes against an explicit, registry-derived kind set", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve(paperExtension); + + const papers = evolved.getNodeCollectionOrThrow("Paper"); + const authors = evolved.getNodeCollectionOrThrow("Author"); + const authoredBy = evolved.getEdgeCollectionOrThrow("authoredBy"); + + const paper = await papers.create({ title: "P", year: 2021 }); + const author = await authors.create({ name: "Only Author" }); + await authoredBy.create(paper, author, { order: 1 }); + + const rows = await evolved + .query() + .fromDynamic("Paper", "p") + .traverseDynamic("authoredBy", "a") + .toKindSet(["Author"], "u") + .select((ctx) => ctx.u) + .execute(); + + expect(rows).toHaveLength(1); + expect(rows[0]?.["name"]).toBe("Only Author"); + }); + + it("throws ConfigurationError (EMPTY_KIND_SET) on an empty kind array", async () => { + // MUTATION CHECK: dropping this guard lets `compileKindFilter` + // turn the empty list into a bare `1 = 0`, so the query would build and + // execute successfully, always returning zero rows, instead of refusing + // the invalid traversal — verified and reverted. + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve(paperExtension); + + expect(() => + evolved + .query() + .fromDynamic("Paper", "p") + .traverseDynamic("authoredBy", "a") + .toKindSet([], "u"), + ).toThrow(ConfigurationError); + expect(() => + evolved + .query() + .fromDynamic("Paper", "p") + .traverseDynamic("authoredBy", "a") + .toKindSet([], "u"), + ).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "EMPTY_KIND_SET" }), + }), + ); + }); + + it("throws KindNotFoundError on an unregistered kind", async () => { + // MUTATION CHECK: `toKindSet`, unlike `toDynamic`, ran no + // `registry.hasNodeType` check before this fix — a typo'd kind here + // compiled to a kind filter no row matches (a permanent, silent empty + // result) instead of throwing — verified and reverted. + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve(paperExtension); + + expect(() => + evolved + .query() + .fromDynamic("Paper", "p") + .traverseDynamic("authoredBy", "a") + .toKindSet(["Athor"], "u"), + ).toThrow(KindNotFoundError); + }); + + it("throws EndpointError when a kind in the set is not a valid endpoint", async () => { + // MUTATION CHECK: `toKindSet` ran no `#assertValidEndpoint` + // check before this fix — `authoredBy.to = [Author]`, so naming "Paper" + // here compiled and executed rather than refusing — verified and + // reverted. + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve(paperExtension); + + expect(() => + evolved + .query() + .fromDynamic("Paper", "p") + .traverseDynamic("authoredBy", "a") + .toKindSet(["Paper"], "wrong"), + ).toThrow(EndpointError); + }); +}); + describe("optionalTraverseDynamic", () => { it("LEFT JOINs over a runtime edge — papers without authors still surface", async () => { const backend = createTestBackend(); diff --git a/packages/typegraph/tests/query-execution.test.ts b/packages/typegraph/tests/query-execution.test.ts index 53e9105c6..51f27ed7a 100644 --- a/packages/typegraph/tests/query-execution.test.ts +++ b/packages/typegraph/tests/query-execution.test.ts @@ -460,11 +460,11 @@ describe("Query Execution (SQLite)", () => { .from("Person", "p") .whereNode("p", (p) => p.id.eq("collision-person")) .traverse("worksAt", "e") - .to("Organization", "o", { includeSubClasses: true }) + .to("Organization", "o", { expansion: "subclasses" }) .select((context) => ({ id: context.o.id, kind: context.o.kind, - name: context.o["name"], + name: context.o.name, })) .execute(); @@ -481,7 +481,7 @@ describe("Query Execution (SQLite)", () => { .from("Person", "p") .whereNode("p", (p) => p.id.eq("collision-person")) .optionalTraverse("worksAt", "e") - .to("Organization", "o", { includeSubClasses: true }) + .to("Organization", "o", { expansion: "subclasses" }) .groupByNode("p") .aggregate({ orgCount: count("o"), @@ -975,11 +975,11 @@ describe("Query Execution (SQLite)", () => { .whereNode("p", (p) => p.id.eq("recursive-collision-person")) .traverse("worksAt", "e") .recursive({ maxHops: 1 }) - .to("Organization", "o", { includeSubClasses: true }) + .to("Organization", "o", { expansion: "subclasses" }) .select((context) => ({ id: context.o.id, kind: context.o.kind, - name: context.o["name"], + name: context.o.name, })) .execute(); diff --git a/packages/typegraph/tests/recursive-compiler.test.ts b/packages/typegraph/tests/recursive-compiler.test.ts index 061a30575..129bfa1af 100644 --- a/packages/typegraph/tests/recursive-compiler.test.ts +++ b/packages/typegraph/tests/recursive-compiler.test.ts @@ -14,6 +14,7 @@ import { type Traversal, type VariableLengthSpec, } from "../src/query/ast"; +import { compileQuery } from "../src/query/compiler"; import { type PredicateCompilerContext } from "../src/query/compiler/predicates"; import { compileVariableLengthQuery, @@ -22,7 +23,10 @@ import { MAX_RECURSIVE_DEPTH, } from "../src/query/compiler/recursive"; import { compileMultiStageRecursiveQuery } from "../src/query/compiler/recursive-chain"; -import { DEFAULT_SQL_SCHEMA } from "../src/query/compiler/schema"; +import { + createRecordedReadBinding, + DEFAULT_SQL_SCHEMA, +} from "../src/query/compiler/schema"; import { postgresDialect, sqliteDialect } from "../src/query/dialect"; import { renderSql, sql } from "../src/query/sql-fragment"; import { KIND_QUALIFIED_PATH_TOKEN_SEPARATOR } from "../src/utils/path"; @@ -63,7 +67,7 @@ function createAst(overrides: Partial = {}): QueryAst { start: { alias: "source", kinds: ["StartNode"], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [ createTraversal({ @@ -100,6 +104,11 @@ function createContext( recursiveTraversal: assumeRecursiveTraversalSupported( "recursive compiler unit test", ), + // A recorded-pinned AST reaches the temporal filter through the recorded + // read seam, which refuses a compile with no bound relation — the same + // binding a `{ history: true }` store installs. Harmless for the + // current-time cases: nothing reads it unless `recordedAsOf` is set. + recordedReadBinding: createRecordedReadBinding(DEFAULT_SQL_SCHEMA), }; } @@ -371,11 +380,157 @@ describe("compileVariableLengthQuery", () => { const sql = getSqlString(ast); + // Both directions read from a preceding, non-recursive CTE that + // normalizes each orientation to tg_source_*/tg_target_* columns — + // never two branches each self-joining `recursive_cte` (PostgreSQL + // refuses more than one such self-reference; see + // `compileRecursiveDirectedEdgesCte`'s doc comment). + expect(sql).toContain("_directed_edges AS ("); expect(sql).toContain('"typegraph_edges" e'); - expect(sql).toContain("e.from_id = r.target_id"); - expect(sql).toContain("e.to_id = r.target_id"); + expect(sql).toContain("e.from_id AS tg_source_id"); + expect(sql).toContain("e.to_id AS tg_target_id"); + expect(sql).toContain("e.to_id AS tg_source_id"); + expect(sql).toContain("e.from_id AS tg_target_id"); + // The self-loop duplicate guard still applies inside the CTE. expect(sql).toContain("e.from_id = e.to_id"); expect(sql).toContain("e.from_kind = e.to_kind"); + // The recursive term itself joins the normalized relation exactly once. + expect(sql).toContain("e.tg_source_id = r.target_id"); + expect(sql).toContain("e.tg_source_kind = r.target_kind"); + // `edgeKinds` and `inverseEdgeKinds` both name "RELATES_TO" here (an + // ordinary symmetric `direction: "both"` traversal on one edge kind): + // the merged kind filter must dedupe the union, not bind the same + // kind twice in one IN list. + expect(sql).not.toContain("'RELATES_TO', 'RELATES_TO'"); + // No edge predicate and no identity frontier widening on this + // traversal, so the directed-edges CTE projects only the columns the + // recursive term structurally needs — not `e.*` (guarding the + // materialization regression a wildcard caused). + expect(sql).not.toContain("SELECT e.*"); + expect(sql).toContain( + "SELECT e.graph_id, e.kind, e.valid_from, e.valid_to, e.deleted_at,", + ); + }); + + it("adds recorded_from/recorded_to to the narrowed directed-edges projection under a recorded-pinned read", () => { + // MUTATION CHECK: dropping the + // `temporalFilterPass.recordedColumns` spread (restoring the bare + // `e.graph_id, e.kind, e.valid_from, e.valid_to, e.deleted_at` list) + // makes this assertion fail while the recursive term's + // `e.recorded_from`/`e.recorded_to` reference (below) still compiles + // — it would only fail at execution time against a real edges table, + // which this unit test does not hit — verified and reverted. + const ast = createAst({ + traversals: [ + createTraversal({ + direction: "out", + inverseEdgeKinds: ["RELATES_TO"], + variableLength: createVariableLengthSpec(), + }), + ], + recordedAsOf: "r1:0000000000000009:2024-07-01T00:00:00.000Z", + }); + + const sql = getSqlString(ast); + + // No edge predicate and no identity frontier widening, so the + // narrowed (non-`e.*`) branch still applies... + expect(sql).not.toContain("SELECT e.*"); + // ...but it must carry the two columns the recorded-pinned temporal + // filter references on this alias, or the emitted + // `e.recorded_from <= ... AND ... < e.recorded_to` predicate fails + // with "no such column" against the CTE (SQLite) / an undefined-column + // error (PostgreSQL) once a real edges table backs it. + expect(sql).toContain( + "SELECT e.graph_id, e.kind, e.valid_from, e.valid_to, e.deleted_at, e.recorded_from, e.recorded_to,", + ); + expect(sql).toContain("e.recorded_from"); + expect(sql).toContain("e.recorded_to"); + }); + + it("pushes graph_id into both arms of the directed-edges CTE", () => { + // MUTATION CHECK: dropping the `e.graph_id = + // ${graphId} AND` prefix from both arms' WHERE clauses makes this + // assertion fail — the two arms would filter on `e.kind` alone — + // verified and reverted. Functional behavior is unchanged either way + // (the recursive term already requires `graph_id = ${graphId}`), so + // this guards a normalization-cost regression, not a correctness one. + const ast = createAst({ + traversals: [ + createTraversal({ + direction: "out", + inverseEdgeKinds: ["RELATES_TO"], + variableLength: createVariableLengthSpec(), + }), + ], + }); + + const sql = getSqlString(ast); + const directedEdgesCte = requireDefined( + sql.split("_directed_edges AS (")[1], + ); + + expect( + directedEdgesCte.match(/WHERE e\.graph_id = 'test-graph' AND/g), + ).toHaveLength(2); + }); + + it("pushes the edge temporal filter into both arms of the directed-edges CTE", () => { + // MUTATION CHECK: dropping `AND ${edgeTemporalFilter}` from the two arms + // makes this assertion fail (each arm would filter on `graph_id` and + // `kind` only) — verified and reverted. The predicate is row-local over + // `e`'s own temporal columns and the recursive term applies it again, so + // this guards the normalization cost: without it the CTE materializes + // every soft-deleted and superseded edge version of these kinds. + const ast = createAst({ + traversals: [ + createTraversal({ + direction: "out", + inverseEdgeKinds: ["RELATES_TO"], + variableLength: createVariableLengthSpec(), + }), + ], + }); + + const directedEdgesCte = requireDefined( + requireDefined( + getSqlString(ast).split("_directed_edges AS (")[1], + ).split("recursive_cte AS (")[0], + ); + + expect(directedEdgesCte.match(/e\.deleted_at IS NULL/g)).toHaveLength(2); + }); + + it("keeps the full e.* projection when an edge predicate targets a mixed-orientation traversal", () => { + const ast = createAst({ + predicates: [ + { + targetAlias: "e", + targetType: "edge", + expression: { + __type: "comparison", + op: "gte", + left: createFieldRef("e", ["props", "weight"], "number"), + right: { __type: "literal", value: 10, valueType: "number" }, + }, + }, + ], + traversals: [ + createTraversal({ + direction: "out", + inverseEdgeKinds: ["RELATES_TO"], + variableLength: createVariableLengthSpec(), + }), + ], + }); + + const sql = getSqlString(ast); + + // The predicate reads `e.props`, which the narrowed column list + // does not carry — the safe fallback keeps `e.*` whenever an + // edge predicate is present, so the property is still readable. + expect(sql).toContain("SELECT e.*,"); + expect(sql).toContain("weight"); }); it("forces worktable-first join order on sqlite recursive steps", () => { @@ -448,6 +603,91 @@ describe("compileVariableLengthQuery", () => { expect(sql).toContain("r.depth < 500"); }); + it("applies no hop ceiling to an exhaustive traversal, which ends on cycle prevention alone", () => { + const ast = createAst({ + traversals: [ + createTraversal({ + variableLength: createVariableLengthSpec({ exhaustive: true }), + }), + ], + }); + + const sql = getSqlString(ast); + + expect(sql).not.toContain("r.depth <"); + expect(sql).toContain("r.path"); + }); + // MUTATION CHECK: emit `maxDepthCondition` for an exhaustive spec too + // (drop the `vl.exhaustive === true ? undefined` arm) and `r.depth <` + // returns. + + it("refuses an exhaustive traversal that also allows cycles", () => { + const ast = createAst({ + traversals: [ + createTraversal({ + variableLength: createVariableLengthSpec({ + exhaustive: true, + cyclePolicy: "allow", + }), + }), + ], + }); + + expect(() => getSqlString(ast)).toThrow( + 'exhaustive recursive traversal requires cyclePolicy "prevent"', + ); + }); + // MUTATION CHECK: delete the `!shouldEnforceCycleCheck` refusal inside the + // `vl.exhaustive === true` guard and this compiles an unbounded, + // uncycle-checked recursion. + + it.each([ + { maxDepth: 5, label: "within the explicit ceiling" }, + { maxDepth: MAX_EXPLICIT_RECURSIVE_DEPTH + 1, label: "beyond it" }, + ])( + "refuses an exhaustive traversal that also states maxDepth $maxDepth ($label)", + ({ maxDepth }) => { + const ast = createAst({ + traversals: [ + createTraversal({ + variableLength: createVariableLengthSpec({ + exhaustive: true, + maxDepth, + }), + }), + ], + }); + + expect(() => getSqlString(ast)).toThrow( + `cannot also state maxDepth(${maxDepth})`, + ); + }, + ); + // MUTATION CHECK: delete the `vl.maxDepth > 0` refusal inside the + // `vl.exhaustive === true` guard and maxDepth 5 compiles unbounded (the + // stated bound silently ignored). + + it("refuses an exhaustive single-hop traversal instead of lowering it to a plain join", () => { + const ast = createAst({ + traversals: [ + createTraversal({ + variableLength: createVariableLengthSpec({ + exhaustive: true, + maxDepth: 1, + }), + }), + ], + }); + + expect(() => compileQuery(ast, "test-graph", "sqlite")).toThrow( + "cannot also state maxDepth(1)", + ); + }); + // MUTATION CHECK: drop the `variableLength.exhaustive === true` arm in + // `tryLowerSingleHopRecursiveTraversal` and the hand-built spec is lowered + // to a non-recursive join, so `exhaustive` is silently dropped and nothing + // throws. + it("throws when explicit maxDepth exceeds MAX_EXPLICIT_RECURSIVE_DEPTH", () => { const ast = createAst({ traversals: [ @@ -870,7 +1110,7 @@ describe("compileVariableLengthQuery", () => { // unchanged; one that can hold several must key the token on (kind, id) so // same-id nodes of different kinds are not mistaken for revisits. const singleKindAst = createAst({ - start: { alias: "source", kinds: ["Node"], includeSubClasses: false }, + start: { alias: "source", kinds: ["Node"], expansion: "exact" }, traversals: [ createTraversal({ nodeKinds: ["Node"], @@ -932,7 +1172,7 @@ describe("compileVariableLengthQuery", () => { it("kind-qualifies the token of a single-kind walk that expands identity members", () => { const { sql: text } = renderForDialect( createAst({ - start: { alias: "source", kinds: ["Node"], includeSubClasses: false }, + start: { alias: "source", kinds: ["Node"], expansion: "exact" }, traversals: [ createTraversal({ nodeKinds: ["Node"], diff --git a/packages/typegraph/tests/recursive-emitter.test.ts b/packages/typegraph/tests/recursive-emitter.test.ts index 6e4f9af14..99867bb17 100644 --- a/packages/typegraph/tests/recursive-emitter.test.ts +++ b/packages/typegraph/tests/recursive-emitter.test.ts @@ -37,7 +37,7 @@ function createRecursivePlan(): LogicalPlan { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"], }, temporalMode: { mode: "current" as const }, @@ -133,7 +133,7 @@ describe("emitRecursiveQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [ { @@ -184,7 +184,7 @@ describe("emitRecursiveQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [ { @@ -235,7 +235,7 @@ describe("emitRecursiveQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [ { diff --git a/packages/typegraph/tests/recursive-traversal-inventory.test.ts b/packages/typegraph/tests/recursive-traversal-inventory.test.ts index 5d505a30a..1637fbad3 100644 --- a/packages/typegraph/tests/recursive-traversal-inventory.test.ts +++ b/packages/typegraph/tests/recursive-traversal-inventory.test.ts @@ -1,7 +1,7 @@ /** * THE INVENTORY RATCHET for `WITH RECURSIVE` emission in `src/**` (I1). * - * The invariant it enforces: **exactly eight `WITH RECURSIVE` emission sites + * The invariant it enforces: **exactly ten `WITH RECURSIVE` emission sites * exist in `src/**`, in both directions** — a site in the tree with no * matching entry fails, and an entry matching no site in the tree fails — * **and exactly one `assumeRecursiveTraversalSupported` call site exists**, @@ -21,7 +21,7 @@ * and `query/compiler/emitter/recursive.ts:13`, sit in files that also hold * a real site, so they do not change the file-set count.) A ratchet keyed * on the file set would have failed on arrival at 8. The fix is to key on - * `(file, line)` after parsing comments out, so the eight emission sites are + * `(file, line)` after parsing comments out, so the ten emission sites are * counted and the four comment occurrences are not. * * ## Honest statement of its limits @@ -87,7 +87,7 @@ * * Comment stripping is done by parsing with `ts.createSourceFile` and walking * the resulting AST (`ts.forEachChild`), not with a bare `ts.createScanner` - * token loop or a regex: every one of the eight sites lives inside a tagged + * token loop or a regex: every one of the ten sites lives inside a tagged * template with `${...}` interpolations, and a scanner that re-enters a * template body after a `}` re-lexes it as ordinary source — which mis-lexes * apostrophes and `--` inside the embedded SQL and can blank or swallow real @@ -117,7 +117,7 @@ const RECURSION_PHRASE = "WITH RECURSIVE"; * case. A same-case single-space `String.prototype.indexOf` would miss a * hand-written `with\n recursive` (an actual line break) inside a tagged * template in BOTH ratchet directions (no `undeclared` row because the - * phrase never matches, and no `stale` row because the eight declared sites + * phrase never matches, and no `stale` row because the ten declared sites * are unaffected); the `\n`/`\r`/`\t` alternation closes the same gap for * the escape-sequence spelling of the same shape (`` `WITH\n RECURSIVE` `` * written literally, not a real newline), which a whitespace-only pattern @@ -141,22 +141,24 @@ type FoundSite = Readonly<{ line: string; }>; -/** A declared site: where it is, which of the eight/one it is, and why. */ +/** A declared site: where it is, which of the ten/one it is, and why. */ type InventoryEntry = Readonly<{ /** Path relative to `packages/typegraph/src`. */ file: string; /** The site's own source line, trimmed. */ line: string; - /** "A".."H" for an emission site, "compileQuery default" for the assume call. */ + /** "A".."J" for an emission site, "compileQuery default" for the assume call. */ site: string; /** What emits here, or why the assumption is sound here. Mandatory. */ reason: string; }>; /** - * The eight `WITH RECURSIVE` emission sites, measured on this branch (§2 of - * the batch spec, reproduced from `grep -rn "WITH RECURSIVE" src - * --include=*.ts`). + * The ten `WITH RECURSIVE` emission sites, measured on this branch (reproduced from `grep -rn "WITH RECURSIVE" src + * --include=*.ts`) — sites G onward were added after that batch: site H (the + * composition closure's exhaustive, set-semantics walk) in place of the + * hop-bounded directed builder it replaced, site I (the acyclicity probe), + * and site J (the graph-storage distinct graph id walk). */ const EMISSION_SITES: readonly InventoryEntry[] = [ { @@ -168,11 +170,18 @@ const EMISSION_SITES: readonly InventoryEntry[] = [ }, { file: "store/recursive-cte.ts", - line: "sql`WITH RECURSIVE reachable AS (${baseCase} UNION ALL ${recursiveCase})`", + line: "return sql`WITH RECURSIVE reachable AS (${prepared.baseCase} UNION ALL ${recursiveCase})`;", site: "B", reason: "buildReachableCte compiles a fixed/variable-length traversal into a bounded reachable set.", }, + { + file: "store/recursive-cte.ts", + line: "return sql`WITH RECURSIVE reachable(id, kind) AS (${prepared.baseCase} UNION ${recursiveCase})`;", + site: "H", + reason: + "buildExhaustiveDirectedReachableCte compiles the composition closure subgraph({ composition: true }) reads as a SET-SEMANTICS walk: `UNION` over an (id, kind) frontier, bounded by its visited set with no hop ceiling, because a truncated prefix of a part tree is not an owned unit. Shares its node and kind filters with the hop-bounded builders via prepareReachableFilters, and reads its edge rows by composition membership rather than the read's validity window.", + }, { file: "identity/service-read.ts", line: "WITH RECURSIVE", @@ -203,15 +212,22 @@ const EMISSION_SITES: readonly InventoryEntry[] = [ }, { file: "store/recursive-cte.ts", - line: ": sql`WITH RECURSIVE typegraph_windowed_edges AS (${windowedEdges}), reachable AS (${baseCase} UNION ALL ${recursiveCase})`;", + line: "return sql`WITH RECURSIVE ${sql.raw(WINDOWED_EDGES_CTE)} AS (${windowedEdges}), reachable AS (${prepared.baseCase} UNION ALL ${recursiveCase})`;", site: "G", reason: "buildReachableCte ranks each requested edge kind before expanding the bounded reachable set.", }, + { + file: "store/recursive-cte.ts", + line: "return sql`WITH RECURSIVE ${body}`;", + site: "I", + reason: + "buildEdgeAcyclicityProbe runs the exhaustive, set-semantics reachability walk an acyclic edge kind's write path, audit, and merge plan-time preview all probe.", + }, { file: "backend/graph-storage.ts", line: "WITH RECURSIVE graph_ids(${graphId}, ${step}) AS (", - site: "H", + site: "J", reason: "distinctGraphIds walks the distinct graph ids of the anchor tables by index seek (bounded to the page where the dialect keeps text indexes in byte order), and selects a de-duplicating scan instead when the verdict says the engine cannot recurse.", }, @@ -459,7 +475,7 @@ describe("recursion inventory ratchet", () => { if (reported.length > 0) { throw new Error( `A WITH RECURSIVE literal is emitted at a site EMISSION_SITES does not declare:\n\n${reported.join("\n")}\n\n` + - `Every SQL builder that emits a recursive CTE must take a RecursiveTraversalVerdict and refuse (or, for the weighted-shortest-path fallback, degrade) when the engine cannot recurse (I1). Add an entry to EMISSION_SITES in this file, site "H" onward, with a one-line reason naming the emitting function — the reason is the review.`, + `Every SQL builder that emits a recursive CTE must take a RecursiveTraversalVerdict and refuse (or, for the weighted-shortest-path fallback, degrade) when the engine cannot recurse (I1). Add an entry to EMISSION_SITES in this file, site "K" onward, with a one-line reason naming the emitting function — the reason is the review.`, ); } expect(reported).toEqual([]); @@ -552,9 +568,9 @@ describe("recursion inventory ratchet", () => { it("finds a case-, whitespace-, and escape-sequence-variant phrase a same-case single-space match would miss", () => { // A same-case, exact-single-space `String.indexOf` (the defect this // test guards against) matches none of these four lines, so a new - // ninth emission site written in any of these shapes would be + // eleventh emission site written in any of these shapes would be // invisible in BOTH ratchet directions: no `undeclared` row (the - // phrase never matches) and no `stale` row (the eight declared sites are + // phrase never matches) and no `stale` row (the ten declared sites are // unaffected). The fourth shape — `\n` typed literally as two source // characters (backslash, then `n`), never a real line break — is the // escape-sequence bypass the checkpoint measured as still invisible @@ -589,8 +605,9 @@ describe("recursion inventory ratchet", () => { // M-9, reproduced in miniature: the round-1 formulation counted FILES // holding the phrase with a comment-blind scan and compared that count // to 6. On this tree `grep -rl "WITH RECURSIVE" src --include=*.ts | wc - // -l` is 8 for exactly 6 real emission sites (EMISSION_SITES.length), - // because two files hold the phrase only in a doc comment. This + // -l` is 9 for exactly 10 real emission sites (EMISSION_SITES.length), + // because two files hold the phrase only in a doc comment and + // `store/recursive-cte.ts` alone holds four of the sites. This // fixture reproduces the shape in miniature: at least four comment // lines a raw, line-oriented scan cannot distinguish from code, and // zero real sites once the parser strips comments out. diff --git a/packages/typegraph/tests/registry-ontology.test.ts b/packages/typegraph/tests/registry-ontology.test.ts index c2d44db63..51029b114 100644 --- a/packages/typegraph/tests/registry-ontology.test.ts +++ b/packages/typegraph/tests/registry-ontology.test.ts @@ -8,7 +8,9 @@ * Key ontology relations: * - subClassOf(Child, Parent) - Type inheritance * - broader(Specific, General) - Concept hierarchy - * - equivalentTo(A, B) / sameAs(A, B) - Type equivalence + * - equivalentTo(A, B) - Type equivalence (`sameAs` was a deprecated + * alias, removed; a persisted document that still names it loads and + * folds identically — see the "sameAs" describe block below) * - disjointWith(A, B) - Types that cannot overlap * - partOf(Part, Whole) / hasPart(Whole, Part) - Composition */ @@ -17,18 +19,39 @@ import { z } from "zod"; import { broader, + defineEdge, defineGraph, defineNode, disjointWith, equivalentTo, hasPart, narrower, + type NodeType, + type OntologyRelation, partOf, - sameAs, subClassOf, } from "../src"; +import { ALL_META_EDGE_NAMES } from "../src/ontology/constants"; +import { metaEdgesByName } from "../src/ontology/core-meta-edges"; import { buildKindRegistry } from "../src/registry"; +/** + * `sameAs` has no public factory any more (it was removed), and its + * meta-edge object is absent from the public `core` export too — a document + * persisted before the removal that still names a `sameAs` relation must + * keep loading and folding exactly like `equivalentTo`. + * `metaEdgesByName.sameAs` is the internal-only object + * `compileOntologyRelation` (`src/graph-extension/compiler.ts`) resolves + * that name to, so building the relation directly from it here exercises + * the same registry fold a persisted `sameAs` document would (whose + * relation carries the name as a plain string, not this object — + * `collectOntologyRelations`, `src/registry/kind-registry.ts`, switches on + * the name either way). + */ +function sameAsRelation(kindA: NodeType, kindB: NodeType): OntologyRelation { + return { metaEdge: metaEdgesByName.sameAs, from: kindA, to: kindB }; +} + const emptySchema = z.object({}); describe("subClassOf - Type Inheritance", () => { @@ -187,8 +210,7 @@ describe("sameAs - Alias for equivalentTo", () => { Account: { type: Account }, }, edges: {}, - // eslint-disable-next-line @typescript-eslint/no-deprecated -- pins the migration-period alias behavior - ontology: [sameAs(User, Account)], + ontology: [sameAsRelation(User, Account)], }); const registry = buildKindRegistry(graph); @@ -198,6 +220,23 @@ describe("sameAs - Alias for equivalentTo", () => { }); }); +describe("metaEdgesByName - internal by-name lookup correspondence", () => { + // `metaEdgesByName` is a hand-written literal keyed by every + // `MetaEdgeName`; nothing else pins that key N maps to the meta-edge + // object actually NAMED N. `compileOntologyRelation` + // (`src/graph-extension/compiler.ts`) trusts this correspondence + // unconditionally when resolving a declarative graph extension's + // `metaEdge` string — a mis-mapped entry (e.g. `sameAs` pointing at + // `equivalentToMetaEdge`) would compile and persist under the wrong + // name with no other test catching it. + it.each(ALL_META_EDGE_NAMES)( + "metaEdgesByName[%s] carries that name", + (name) => { + expect(metaEdgesByName[name].name).toBe(name); + }, + ); +}); + describe("disjointWith - Mutually Exclusive Types", () => { const Person = defineNode("Person", { schema: emptySchema }); const Organization = defineNode("Organization", { schema: emptySchema }); @@ -229,6 +268,8 @@ describe("partOf/hasPart - Composition", () => { const Engine = defineNode("Engine", { schema: emptySchema }); const Car = defineNode("Car", { schema: emptySchema }); const Vehicle = defineNode("Vehicle", { schema: emptySchema }); + const installedIn = defineEdge("installedIn", { schema: emptySchema }); + const partOfVehicle = defineEdge("partOfVehicle", { schema: emptySchema }); const graph = defineGraph({ id: "composition_test", @@ -237,8 +278,24 @@ describe("partOf/hasPart - Composition", () => { Car: { type: Car }, Vehicle: { type: Vehicle }, }, - edges: {}, - ontology: [partOf(Engine, Car), partOf(Car, Vehicle)], + edges: { + installedIn: { + type: installedIn, + from: [Engine], + to: [Car], + cardinality: "one", + }, + partOfVehicle: { + type: partOfVehicle, + from: [Car], + to: [Vehicle], + cardinality: "one", + }, + }, + ontology: [ + partOf(Engine, Car, { via: installedIn }), + partOf(Car, Vehicle, { via: partOfVehicle }), + ], }); const registry = buildKindRegistry(graph); @@ -267,6 +324,7 @@ describe("partOf/hasPart - Composition", () => { describe("hasPart - Inverse of partOf", () => { const Wheel = defineNode("Wheel", { schema: emptySchema }); const Bicycle = defineNode("Bicycle", { schema: emptySchema }); + const hasWheel = defineEdge("hasWheel", { schema: emptySchema }); const graph = defineGraph({ id: "haspart_test", @@ -274,8 +332,15 @@ describe("hasPart - Inverse of partOf", () => { Wheel: { type: Wheel }, Bicycle: { type: Bicycle }, }, - edges: {}, - ontology: [hasPart(Bicycle, Wheel)], + edges: { + hasWheel: { + type: hasWheel, + from: [Bicycle], + to: [Wheel], + targetCardinality: "one", + }, + }, + ontology: [hasPart(Bicycle, Wheel, { via: hasWheel })], }); const registry = buildKindRegistry(graph); @@ -286,6 +351,42 @@ describe("hasPart - Inverse of partOf", () => { }); }); +describe("equivalentTo is mutual subsumption", () => { + const Person = defineNode("Person", { schema: emptySchema }); + const Company = defineNode("Company", { schema: emptySchema }); + const Corporation = defineNode("Corporation", { schema: emptySchema }); + + const graph = defineGraph({ + id: "equivalence_subsumption_test", + nodes: { + Person: { type: Person }, + Company: { type: Company }, + Corporation: { type: Corporation }, + }, + edges: {}, + ontology: [ + equivalentTo(Company, Corporation), + disjointWith(Person, Company), + ], + }); + + const registry = buildKindRegistry(graph); + + it("makes disjointness, assignability, expansion and the component all agree", () => { + expect(registry.areDisjoint("Person", "Corporation")).toBe(true); + expect(registry.isAssignableTo("Corporation", "Company")).toBe(true); + expect(registry.isAssignableTo("Company", "Corporation")).toBe(true); + expect(registry.expandSubClasses("Company")).toContain("Corporation"); + expect(registry.getSubClassComponent("Company")).toEqual([ + "Company", + "Corporation", + ]); + expect(registry.getSubClassComponent("Corporation")).toBe( + registry.getSubClassComponent("Company"), + ); + }); +}); + describe("isAssignableTo - Subsumption-based Assignment", () => { const Organization = defineNode("Organization", { schema: emptySchema }); const Company = defineNode("Company", { schema: emptySchema }); diff --git a/packages/typegraph/tests/result-mapper.test.ts b/packages/typegraph/tests/result-mapper.test.ts index d09174e0b..d2a8b2ee3 100644 --- a/packages/typegraph/tests/result-mapper.test.ts +++ b/packages/typegraph/tests/result-mapper.test.ts @@ -20,9 +20,10 @@ import { requireDefined } from "../src/utils/presence"; describe("transformPathColumns", () => { const baseState: QueryBuilderState = { startKinds: ["Person"], + startRequestedKinds: ["Person"], startAlias: "p", currentAlias: "p", - includeSubClasses: false, + startExpansion: "exact", traversals: [], predicates: [], projection: [], diff --git a/packages/typegraph/tests/schema-commit-preflight-order.test.ts b/packages/typegraph/tests/schema-commit-preflight-order.test.ts new file mode 100644 index 000000000..be9365517 --- /dev/null +++ b/packages/typegraph/tests/schema-commit-preflight-order.test.ts @@ -0,0 +1,181 @@ +/** + * Guards that `composeSchemaCommitPreflight` — the one place the + * schema-commit preflight step order is spelled — runs its roles in the + * canonical order: structural gates, then `edgeMatchIdentityPreflight`, then + * the ontology-tightening preflight, then the identity preflight. Call sites + * name each step by role rather than by position, so no commit path can + * reorder them; this file therefore certifies the order every path runs. + * + * The end-to-end guards for "ontology before the identity closure rebuild" + * live in `tests/identity.test.ts` (one case per path that can owe both + * preflights), and the adopted path's tightening refusal lives in the shared + * `tests/backends/integration/adopted-evolution.ts` suite. + */ +import { describe, expect, it } from "vitest"; + +import { type SchemaCommitPreflightBackend } from "../src/backend/types"; +import { + composeSchemaCommitPreflight, + planSchemaCommitPreflight, + type SchemaCommitPreflightSteps, +} from "../src/schema/manager"; +import { type SchemaTighteningPreflight } from "../src/schema/tightening-preflight"; + +function recordingStep( + calls: string[], + name: string, +): (target: SchemaCommitPreflightBackend) => Promise { + return () => { + calls.push(name); + return Promise.resolve(); + }; +} + +function recordingEdgeMatchIdentity( + calls: string[], + name: string, +): NonNullable { + return { + run: recordingStep(calls, name), + capabilityError: { code: "EDGE_TEST", message: "test" }, + }; +} + +function recordingTightening( + calls: string[], + name: string, +): SchemaTighteningPreflight { + return { + run: recordingStep(calls, name), + probedChanges: [], + newlyConstrainedAxes: [], + uniquenessClaimGates: [], + capabilityError: { code: "TEST", message: "test" }, + }; +} + +describe("composeSchemaCommitPreflight", () => { + it("runs its roles in the canonical order: structural, edge-match-identity, ontology, identity", async () => { + const calls: string[] = []; + const preflight = composeSchemaCommitPreflight({ + identity: recordingStep(calls, "identity"), + tightening: recordingTightening(calls, "ontology"), + edgeMatchIdentity: recordingEdgeMatchIdentity( + calls, + "edge-match-identity", + ), + structural: recordingStep(calls, "structural"), + }); + + await preflight({} as SchemaCommitPreflightBackend); + + expect(calls).toEqual([ + "structural", + "edge-match-identity", + "ontology", + "identity", + ]); + }); + + // MUTATION CHECK: reordering the role list inside + // `composeSchemaCommitPreflight` makes this assertion fail. + + it("drops undefined roles", async () => { + const calls: string[] = []; + const preflight = composeSchemaCommitPreflight({ + structural: undefined, + edgeMatchIdentity: undefined, + tightening: recordingTightening(calls, "only-step"), + identity: undefined, + }); + await preflight({} as SchemaCommitPreflightBackend); + expect(calls).toEqual(["only-step"]); + }); + + it("awaits each step before starting the next", async () => { + const order: string[] = []; + const preflight = composeSchemaCommitPreflight({ + structural: async () => { + order.push("first-start"); + await Promise.resolve(); + order.push("first-end"); + }, + edgeMatchIdentity: undefined, + tightening: undefined, + identity: () => { + order.push("second-start"); + return Promise.resolve(); + }, + }); + await preflight({} as SchemaCommitPreflightBackend); + expect(order).toEqual(["first-start", "first-end", "second-start"]); + }); +}); + +describe("planSchemaCommitPreflight", () => { + const NO_STEPS = { + structural: undefined, + edgeMatchIdentity: undefined, + tightening: undefined, + identity: undefined, + } as const; + + it("owes no atomic preflight for no step, or a structural gate alone", () => { + expect(planSchemaCommitPreflight(NO_STEPS)).toBeUndefined(); + expect( + planSchemaCommitPreflight({ + ...NO_STEPS, + structural: recordingStep([], "structural"), + }), + ).toBeUndefined(); + }); + + it("names the step that needs the atomic primitive", () => { + const tightening = recordingTightening([], "ontology"); + const capabilityCodes = [ + { + ...NO_STEPS, + edgeMatchIdentity: recordingEdgeMatchIdentity([], "edge"), + }, + { + ...NO_STEPS, + structural: recordingStep([], "structural"), + edgeMatchIdentity: recordingEdgeMatchIdentity([], "edge"), + }, + { + ...NO_STEPS, + edgeMatchIdentity: recordingEdgeMatchIdentity([], "edge"), + tightening, + }, + { + ...NO_STEPS, + edgeMatchIdentity: recordingEdgeMatchIdentity([], "edge"), + tightening, + identity: recordingStep([], "identity"), + }, + ].map((steps) => planSchemaCommitPreflight(steps)?.capabilityError.code); + + expect(capabilityCodes).toEqual([ + "EDGE_TEST", + "EDGE_TEST", + "TEST", + "IDENTITY_REQUIRES_ATOMIC_BACKEND", + ]); + }); + + it("runs the structural gate inside the plan once another step is owed", async () => { + const calls: string[] = []; + const plan = planSchemaCommitPreflight({ + ...NO_STEPS, + structural: recordingStep(calls, "structural"), + edgeMatchIdentity: recordingEdgeMatchIdentity( + calls, + "edge-match-identity", + ), + }); + + await plan?.run({} as SchemaCommitPreflightBackend); + + expect(calls).toEqual(["structural", "edge-match-identity"]); + }); +}); diff --git a/packages/typegraph/tests/schema-composition-load.test.ts b/packages/typegraph/tests/schema-composition-load.test.ts new file mode 100644 index 000000000..f26b6fa5f --- /dev/null +++ b/packages/typegraph/tests/schema-composition-load.test.ts @@ -0,0 +1,281 @@ +/** + * A persisted `partOf`/`hasPart` relation with no `via` is refused ON + * LOAD, through the same `validateOntologyRelations` path the compile-time + * builder and the extension builder already share. + * + * `deserializeSchema(schema).buildRegistry()` is the exact function every + * schema loader (`createStoreWithSchema`, `ensureSchema`, `migrateSchema`) + * calls to interpret a persisted document, so exercising it directly here + * tests the real load-time refusal without standing up a backend. + * + * MUTATION CHECK: removed the + * `relation.via === undefined` arm from `validateCompositionShape` + * (`src/ontology/validation.ts`) — "refuses persisted partOf with no via" + * flipped from throwing to passing (an inert relation loaded clean); no + * other test in this file changed outcome. Restored after the check. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + partOf, +} from "../src"; +import { type AnyEdgeType } from "../src/core/types"; +import { mergeGraphExtension } from "../src/graph-extension/merge"; +import { buildKindRegistry } from "../src/registry"; +import { deserializeSchema } from "../src/schema/deserializer"; +import { computeSchemaDiff } from "../src/schema/migration"; +import { serializeSchema } from "../src/schema/serializer"; +import { matchingObject } from "./test-utils"; + +const emptySchema = z.object({}); + +describe("a persisted composition relation missing `via` is refused on load", () => { + it("refuses persisted partOf with no via", () => { + const Part = defineNode("Part", { schema: emptySchema }); + const Whole = defineNode("Whole", { schema: emptySchema }); + const graph = defineGraph({ + id: "composition-load-refusal", + nodes: { Part: { type: Part }, Whole: { type: Whole } }, + edges: {}, + ontology: [], + }); + const schema = serializeSchema(graph, 1); + // Splice in a `partOf` relation with no `via`, as a pre-`via` schema + // (or a hand-edited document) would carry it. + const brokenSchema = { + ...schema, + ontology: { + ...schema.ontology, + relations: [{ metaEdge: "partOf", from: "Part", to: "Whole" }], + }, + }; + + expect(() => deserializeSchema(brokenSchema).buildRegistry()).toThrow( + expect.objectContaining({ + code: "CONFIGURATION_ERROR", + details: matchingObject({ code: "ONTOLOGY_COMPOSITION_VIA_REQUIRED" }), + }), + ); + }); + + it("refuses the same shape through the extension builder", () => { + expect(() => + defineGraphExtension({ + nodes: { + Part: { properties: { name: { type: "string" } } }, + Whole: { properties: { name: { type: "string" } } }, + }, + ontology: [{ metaEdge: "partOf", from: "Part", to: "Whole" }], + }), + ).toThrow( + expect.objectContaining({ + details: matchingObject({ + issues: expect.arrayContaining([ + expect.objectContaining({ + code: "ONTOLOGY_COMPOSITION_VIA_REQUIRED", + }), + ]), + }), + }), + ); + }); +}); + +describe("a `via` change diffs as remove + add, not a no-op", () => { + // Kind names deliberately contain the two delimiters the old + // `${metaEdge}:${from}:${to}` join used — the fix (`encodeTupleKey`) must + // never re-split a joined key to build the change message. + const Part = defineNode("Pa:rt", { schema: emptySchema }); + const Whole = defineNode("Wh|ole", { schema: emptySchema }); + const edgeOld = defineEdge("edgeOld", { schema: emptySchema }); + const edgeNew = defineEdge("edgeNew", { schema: emptySchema }); + + function graphWithVia(viaEdge: AnyEdgeType, edgeKind: string) { + return defineGraph({ + id: "composition-via-change", + nodes: { [Part.kind]: { type: Part }, [Whole.kind]: { type: Whole } }, + edges: { + [edgeKind]: { + type: viaEdge, + from: [Part], + to: [Whole], + cardinality: "one" as const, + }, + }, + ontology: [partOf(Part, Whole, { via: viaEdge })], + }); + } + + it("reports the via change as one removed relation and one added relation", () => { + const before = serializeSchema(graphWithVia(edgeOld, "edgeOld"), 1); + const after = serializeSchema(graphWithVia(edgeNew, "edgeNew"), 2); + + const diff = computeSchemaDiff(before, after); + + expect(diff.ontology).toHaveLength(2); + const removed = diff.ontology.find((change) => change.type === "removed"); + const added = diff.ontology.find((change) => change.type === "added"); + expect(removed?.details).toBe( + 'Relation partOf(Pa:rt, Wh|ole) via "edgeOld" was removed', + ); + expect(added?.details).toBe( + 'Relation partOf(Pa:rt, Wh|ole) via "edgeNew" was added', + ); + }); +}); + +describe("`partSide` round-trips across representations", () => { + // `partSide` is the one composition field that cannot be re-derived on + // load: it is only required (and only meaningful) for the ambiguous + // reflexive shape (a reflexive pair whose realizing edge admits both orientations), + // so a dropped copy is silent until the next load of exactly that shape, + // where it turns a valid persisted graph into an + // ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED refusal. + // + // MUTATION CHECK: removed the + // `relation.partSide === undefined ? {} : { partSide: relation.partSide }` + // spread from `buildRegistryFromSerializedSchema` + // (`src/schema/deserializer.ts`) — "round-trips through + // deserializeSchema" flipped from passing to throwing + // ONTOLOGY_COMPOSITION_PART_SIDE_REQUIRED; the extension-path case was + // unaffected (it does not go through the deserializer). Restored after + // the check. + const Section = defineNode("Section", { schema: emptySchema }); + const parentSection = defineEdge("parentSection", { schema: emptySchema }); + + it("round-trips through deserializeSchema", () => { + const graph = defineGraph({ + id: "composition-partside-roundtrip", + nodes: { Section: { type: Section } }, + edges: { + parentSection: { + type: parentSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [ + partOf(Section, Section, { via: parentSection, partSide: "from" }), + ], + }); + + const schema = serializeSchema(graph, 1); + const registry = deserializeSchema(schema).buildRegistry(); + + expect(registry.compositionPartSide("parentSection")).toBe("from"); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: "Section", + wholeKind: "Section", + viaEdgeKind: "parentSection", + partSide: "from", + population: "one", + existence: "optional", + }, + ]); + }); + + it("round-trips through mergeGraphExtension", () => { + const baseGraph = defineGraph({ + id: "composition-partside-roundtrip-extension", + nodes: { Section: { type: Section } }, + edges: {}, + ontology: [], + }); + const extension = defineGraphExtension({ + nodes: {}, + edges: { + parentSection: { + from: ["Section"], + to: ["Section"], + cardinality: "one", + properties: {}, + }, + }, + ontology: [ + { + metaEdge: "partOf", + from: "Section", + to: "Section", + via: "parentSection", + partSide: "from", + }, + ], + }); + + const merged = mergeGraphExtension(baseGraph, extension); + const registry = buildKindRegistry(merged); + + expect(registry.compositionPartSide("parentSection")).toBe("from"); + expect(registry.compositionRelation().pairs).toEqual([ + { + partKind: "Section", + wholeKind: "Section", + viaEdgeKind: "parentSection", + partSide: "from", + population: "one", + existence: "optional", + }, + ]); + }); +}); + +describe('an explicit `existence: "optional"` hashes identically to omitting it', () => { + it("matches the schema hash of an otherwise-identical graph that never states existence", async () => { + const Part = defineNode("ExPart", { schema: emptySchema }); + const Whole = defineNode("ExWhole", { schema: emptySchema }); + const via = defineEdge("exPartOf", { schema: emptySchema }); + + const withoutExistence = defineGraph({ + id: "composition-existence-hash", + nodes: { ExPart: { type: Part }, ExWhole: { type: Whole } }, + edges: { + exPartOf: { + type: via, + from: [Part], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [partOf(Part, Whole, { via })], + }); + const withExplicitOptional = defineGraph({ + id: "composition-existence-hash", + nodes: { ExPart: { type: Part }, ExWhole: { type: Whole } }, + edges: { + exPartOf: { + type: via, + from: [Part], + to: [Whole], + cardinality: "one", + }, + }, + ontology: [partOf(Part, Whole, { via, existence: "optional" })], + }); + + const { computeSchemaHash, serializeSchema } = + await import("../src/schema/serializer"); + const hashWithout = await computeSchemaHash( + serializeSchema(withoutExistence, 1), + ); + const hashWithExplicit = await computeSchemaHash( + serializeSchema(withExplicitOptional, 1), + ); + expect(hashWithExplicit).toEqual(hashWithout); + + // The serialized relation itself carries no `existence` key either way. + const serialized = serializeSchema(withExplicitOptional, 1); + expect(serialized.ontology.relations[0]).not.toHaveProperty("existence"); + }); + // MUTATION CHECK: in `compositionRelationFields` + // (src/registry/composition-relation.ts), emit `existence` unconditionally + // whenever it is present (drop the `=== "optional"` branch of the guard). + // The two hashes then differ and the first assertion fails; the property + // check on the serialized relation fails too. +}); diff --git a/packages/typegraph/tests/schema-composition-part-side.test.ts b/packages/typegraph/tests/schema-composition-part-side.test.ts new file mode 100644 index 000000000..180f46bf2 --- /dev/null +++ b/packages/typegraph/tests/schema-composition-part-side.test.ts @@ -0,0 +1,104 @@ +/** + * A composition relation's `partSide` is persisted only when it decides + * something: a side the realizing edge's endpoints already fix serializes, + * hashes and diffs like an omitted one, while a side the edge admits both + * ways is kept. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { computeSchemaHash, serializeSchema } from "../src/schema/serializer"; +import { createTestBackend } from "./test-utils"; + +const Chapter = defineNode("PartSideChapter", { schema: z.object({}) }); +const Book = defineNode("PartSideBook", { schema: z.object({}) }); +const chapterOf = defineEdge("partSideChapterOf", { schema: z.object({}) }); + +function chapterGraph(partSide: "omitted" | "from") { + return defineGraph({ + id: "composition_part_side_redundant", + nodes: { PartSideChapter: { type: Chapter }, PartSideBook: { type: Book } }, + edges: { + partSideChapterOf: { + type: chapterOf, + from: [Chapter], + to: [Book], + cardinality: "one", + }, + }, + ontology: [ + partOf(Chapter, Book, { + via: chapterOf, + ...(partSide === "from" ? { partSide: "from" as const } : {}), + }), + ], + }); +} + +const Section = defineNode("PartSideSection", { schema: z.object({}) }); +const sectionOf = defineEdge("partSideSectionOf", { schema: z.object({}) }); + +function sectionGraph(partSide: "from" | "to") { + return defineGraph({ + id: "composition_part_side_decisive", + nodes: { PartSideSection: { type: Section } }, + edges: { + partSideSectionOf: { + type: sectionOf, + from: [Section], + to: [Section], + cardinality: partSide === "from" ? "one" : "many", + targetCardinality: partSide === "to" ? "one" : "many", + }, + }, + ontology: [partOf(Section, Section, { via: sectionOf, partSide })], + }); +} + +function serializedPartSides( + graph: Parameters[0], +): readonly (string | undefined)[] { + return serializeSchema(graph, 1).ontology.relations.map( + (relation) => relation.partSide, + ); +} + +describe("composition partSide in the schema document", () => { + it("serializes a partSide the realizing edge already decides like an omitted one", async () => { + expect(serializedPartSides(chapterGraph("from"))).toEqual([undefined]); + expect( + await computeSchemaHash(serializeSchema(chapterGraph("from"), 1)), + ).toBe( + await computeSchemaHash(serializeSchema(chapterGraph("omitted"), 1)), + ); + }); + + it("reopens unchanged when only a redundant partSide is spelled out", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(chapterGraph("omitted"), backend); + + const [, spelledOut] = await createStoreWithSchema( + chapterGraph("from"), + backend, + ); + const [, omittedAgain] = await createStoreWithSchema( + chapterGraph("omitted"), + backend, + ); + + expect(spelledOut.status).toBe("unchanged"); + expect(omittedAgain.status).toBe("unchanged"); + }); + + it("keeps a partSide that decides a same-kind composition", () => { + expect(serializedPartSides(sectionGraph("from"))).toEqual(["from"]); + expect(serializedPartSides(sectionGraph("to"))).toEqual(["to"]); + }); +}); diff --git a/packages/typegraph/tests/schema-diff-ontology-summary.test.ts b/packages/typegraph/tests/schema-diff-ontology-summary.test.ts new file mode 100644 index 000000000..58582524a --- /dev/null +++ b/packages/typegraph/tests/schema-diff-ontology-summary.test.ts @@ -0,0 +1,113 @@ +/** + * A diff whose only change is a `modified` ontology entry — an edge kind's + * `acyclic` flag, a composition pair's `existence` — summarizes that change. + * The summary is interpolated into the `MigrationError` a verified store + * raises, so "No changes" there read as a refusal with nothing to migrate. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + createVerifiedStore, + defineEdge, + defineGraph, + defineNode, + partOf, +} from "../src"; +import { MigrationError } from "../src/errors"; +import { getSchemaChanges } from "../src/schema"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Task = defineNode("Task", { schema: z.object({}) }); +const Project = defineNode("Project", { schema: z.object({}) }); +const dependsOn = defineEdge("dependsOn"); +const inProject = defineEdge("inProject"); + +const MODIFIED_ONLY_SUMMARY = "Ontology: 0 added, 0 removed, 1 modified"; + +function taskGraph( + options: Readonly<{ acyclic: boolean; existence: "optional" | "required" }>, +) { + return defineGraph({ + id: "ontology_modified_summary", + nodes: { Task: { type: Task }, Project: { type: Project } }, + edges: { + dependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + acyclic: options.acyclic, + }, + inProject: { + type: inProject, + from: [Task], + to: [Project], + cardinality: "one", + }, + }, + ontology: [ + partOf(Task, Project, { via: inProject, existence: options.existence }), + ], + }); +} + +const BASELINE = { acyclic: false, existence: "optional" } as const; + +const MODIFICATIONS = [ + { + name: "acyclic turned on", + from: BASELINE, + to: { ...BASELINE, acyclic: true }, + }, + { + name: "acyclic turned off", + from: { ...BASELINE, acyclic: true }, + to: BASELINE, + }, + { + name: "composition existence optional to required", + from: BASELINE, + to: { ...BASELINE, existence: "required" }, + }, + { + name: "composition existence required to optional", + from: { ...BASELINE, existence: "required" }, + to: BASELINE, + }, +] as const; + +describe("schema diff summary for a modified ontology entry", () => { + for (const modification of MODIFICATIONS) { + it(`counts ${modification.name}`, async () => { + const backend = createTestBackend(); + await createStoreWithSchema(taskGraph(modification.from), backend); + + const diff = requireDefined( + await getSchemaChanges(backend, taskGraph(modification.to)), + ); + + console.log(modification.name, "->", diff.summary, diff.ontology); + expect(diff.hasChanges).toBe(true); + expect(diff.ontology.map((change) => change.type)).toEqual(["modified"]); + expect(diff.summary).toBe(MODIFIED_ONLY_SUMMARY); + }); + } + + it("names the modification in a verified store's refusal", async () => { + const backend = createTestBackend(); + await createStoreWithSchema(taskGraph(BASELINE), backend); + + const refusal = await createVerifiedStore( + taskGraph({ ...BASELINE, acyclic: true }), + backend, + ).catch((error: unknown) => error); + + expect(refusal).toBeInstanceOf(MigrationError); + expect((refusal as MigrationError).message).toContain( + MODIFIED_ONLY_SUMMARY, + ); + expect((refusal as MigrationError).message).not.toContain("No changes"); + }); +}); diff --git a/packages/typegraph/tests/schema-diff-projection-spelling.test.ts b/packages/typegraph/tests/schema-diff-projection-spelling.test.ts new file mode 100644 index 000000000..a8ae20002 --- /dev/null +++ b/packages/typegraph/tests/schema-diff-projection-spelling.test.ts @@ -0,0 +1,498 @@ +/** + * A stored schema document and the same graph projected today must compare + * equal even when the JSON Schema projection spells a construct differently. + * + * Two spellings changed between projections: a union of bare primitives + * (`anyOf` members, then a `type` token array) and a tuple's arity (a bare + * `prefixItems`, then `items: false` with `minItems` / `maxItems` restated). + * Before these were folded, reopening a document stored under the earlier + * spelling reported every such property as a breaking change and demanded a + * migration for a schema that had not changed. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineGraph, defineNode, type GraphDef } from "../src"; +import type { GraphBackend } from "../src/backend/types"; +import { + computeSchemaDiff, + computeSchemaHash, + ensureSchema, + getSchemaChanges, + isStructuralSubtype, + type JsonSchema, + requiresMigration, + type SerializedSchema, + serializeSchema, +} from "../src/schema"; +import { propertySchemasEqual } from "../src/schema/migration"; +import { createStoreWithSchema, createVerifiedStore } from "../src/store"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +type JsonObject = Record; + +/** + * Rewrites a projection into the spelling an earlier projection emitted: + * unions of bare primitives as `anyOf`, tuples as a bare `prefixItems` (plus + * the rest element's `items`, when there is one). + */ +function toEarlierSpelling(node: unknown): unknown { + if (Array.isArray(node)) { + return node.map((entry) => toEarlierSpelling(entry)); + } + if (typeof node !== "object" || node === null) return node; + const rewritten: JsonObject = {}; + for (const [key, value] of Object.entries(node)) { + rewritten[key] = toEarlierSpelling(value); + } + const { type } = rewritten; + if (Array.isArray(type) && type.length > 1) { + delete rewritten["type"]; + rewritten["anyOf"] = type.map((token: unknown) => ({ type: token })); + } + if (Array.isArray(rewritten["prefixItems"])) { + if (rewritten["items"] === false) { + delete rewritten["items"]; + delete rewritten["maxItems"]; + } + delete rewritten["minItems"]; + } + return rewritten; +} + +const Item = defineNode("Item", { + schema: z.object({ + nickname: z.string().nullable(), + code: z.union([z.string(), z.number()]), + point: z.tuple([z.number(), z.number()]), + tags: z.array(z.string().nullable()), + }), +}); +const graph = defineGraph({ + id: "diff_projection_spelling", + nodes: { Item: { type: Item } }, + edges: {}, +}); + +/** Commits `graph`, then re-commits its document under the earlier spelling. */ +async function storeUnderEarlierSpelling( + backend: GraphBackend, + target: GraphDef, +): Promise { + await createStoreWithSchema(target, backend); + const active = requireDefined(await backend.getActiveSchema(target.id)); + const document = JSON.parse(active.schema_doc) as SerializedSchema; + const version = active.version + 1; + const schemaDocument = { + ...(toEarlierSpelling(document) as SerializedSchema), + version, + }; + expect(JSON.stringify(schemaDocument.nodes)).not.toBe( + JSON.stringify(document.nodes), + ); + await backend.commitSchemaVersion({ + graphId: target.id, + expected: { kind: "active", version: active.version }, + version, + schemaHash: await computeSchemaHash(schemaDocument), + schemaDoc: schemaDocument, + }); + return version; +} + +describe("a projection respelling is not a schema change", () => { + it("reads a stored document under the earlier spelling as unchanged on every path", async () => { + const backend = createTestBackend(); + const version = await storeUnderEarlierSpelling(backend, graph); + + const changes = await getSchemaChanges(backend, graph); + expect(changes?.hasChanges ?? false).toBe(false); + expect(await requiresMigration(backend, graph)).toBe(false); + const [, verified] = await createVerifiedStore(graph, backend); + expect(verified).toEqual({ status: "unchanged", version }); + expect(await ensureSchema(backend, graph)).toEqual({ + status: "unchanged", + version, + }); + const active = await backend.getActiveSchema(graph.id); + expect(active?.version).toBe(version); + }); + + it("diffs the earlier spelling against the current one as no change", () => { + const current = serializeSchema(graph, 1); + const earlier = toEarlierSpelling(current) as SerializedSchema; + + expect(earlier.nodes["Item"]?.properties.properties).toMatchObject({ + nickname: { anyOf: [{ type: "string" }, { type: "null" }] }, + point: { + type: "array", + prefixItems: [{ type: "number" }, { type: "number" }], + }, + }); + expect(computeSchemaDiff(earlier, current).hasChanges).toBe(false); + expect(computeSchemaDiff(current, earlier).hasChanges).toBe(false); + }); + + it("still reports a genuine change to a respelled property as breaking", () => { + const Widened = defineNode("Item", { + schema: Item.schema.extend({ + nickname: z.union([z.string(), z.number(), z.null()]), + }), + }); + const widened = serializeSchema( + defineGraph({ + id: graph.id, + nodes: { Item: { type: Widened } }, + edges: {}, + }), + 2, + ); + const earlier = toEarlierSpelling( + serializeSchema(graph, 1), + ) as SerializedSchema; + + const diff = computeSchemaDiff(earlier, widened); + + expect(diff.hasBreakingChanges).toBe(true); + expect(diff.nodes.map((change) => change.details)).toEqual([ + expect.stringContaining( + 'Property schemas changed incompatibly in "Item": nickname.', + ), + ]); + }); +}); + +function equal(left: unknown, right: unknown): boolean { + return propertySchemasEqual(left, right) && propertySchemasEqual(right, left); +} + +describe("propertySchemasEqual over projection spellings", () => { + it("folds a union of bare primitives, at any depth and beside sibling keywords", () => { + expect( + equal( + { anyOf: [{ type: "string" }, { type: "null" }], minLength: 3 }, + { type: ["string", "null"], minLength: 3 }, + ), + ).toBe(true); + expect( + equal( + { + type: "array", + items: { anyOf: [{ type: "string" }, { type: "null" }] }, + }, + { type: "array", items: { type: ["string", "null"] } }, + ), + ).toBe(true); + }); + + it("flattens a union nested in a union and collapses a repeated token", () => { + // `z.union([z.string(), z.number()]).nullable()` under the earlier spelling. + expect( + equal( + { + anyOf: [ + { anyOf: [{ type: "string" }, { type: "number" }] }, + { type: "null" }, + ], + }, + { type: ["string", "number", "null"] }, + ), + ).toBe(true); + // `z.string().nullable().nullable()` under the earlier spelling. + expect( + equal( + { + anyOf: [ + { anyOf: [{ type: "string" }, { type: "null" }] }, + { type: "null" }, + ], + }, + { type: ["string", "null"] }, + ), + ).toBe(true); + }); + + it("still reports a nested union that admits a different set of types", () => { + const nested = { + anyOf: [ + { anyOf: [{ type: "string" }, { type: "number" }] }, + { type: "null" }, + ], + }; + expect(equal(nested, { type: ["string", "null"] })).toBe(false); + expect( + equal(nested, { type: ["string", "number", "null", "boolean"] }), + ).toBe(false); + expect( + equal( + { + anyOf: [ + { anyOf: [{ type: "string", minLength: 2 }, { type: "number" }] }, + { type: "null" }, + ], + }, + { type: ["string", "number", "null"] }, + ), + ).toBe(false); + }); + + it("keeps a union whose member carries any other keyword", () => { + expect( + equal( + { anyOf: [{ type: "string", minLength: 2 }, { type: "null" }] }, + { type: ["string", "null"] }, + ), + ).toBe(false); + expect( + equal( + { anyOf: [{ type: "string", "x-tag": "a" }, { type: "null" }] }, + { type: ["string", "null"] }, + ), + ).toBe(false); + }); + + it("does not fold a union that sits beside its own type keyword", () => { + expect( + equal( + { type: "string", anyOf: [{ type: "string" }, { type: "null" }] }, + { type: ["string", "null"] }, + ), + ).toBe(false); + }); + + it("still sees a changed, added or reordered union member", () => { + const earlier = { anyOf: [{ type: "string" }, { type: "null" }] }; + expect(equal(earlier, { type: ["number", "null"] })).toBe(false); + expect(equal(earlier, { type: ["string", "null", "number"] })).toBe(false); + expect(equal(earlier, { type: ["null", "string"] })).toBe(false); + }); + + it("folds a closed tuple whose arity is only restated", () => { + const prefixItems = [{ type: "number" }, { type: "number" }]; + expect( + equal( + { type: "array", prefixItems }, + { + type: "array", + prefixItems, + items: false, + minItems: 2, + maxItems: 2, + }, + ), + ).toBe(true); + }); + + // The current projection writes `z.tuple([a.optional()], rest)` with no + // `minItems`, so a rest tuple that states none means zero required members. + it("keeps a rest tuple's minimum: an omitted one means no required member", () => { + const prefixItems = [{ type: "string" }]; + const items = { type: "number" }; + expect( + equal( + { type: "array", prefixItems, items }, + { type: "array", prefixItems, items, minItems: 1 }, + ), + ).toBe(false); + }); + + it("keeps a closed tuple's minimum beside an explicit `items: false`", () => { + const prefixItems = [{ type: "string" }, { type: "number" }]; + const allOptional = { + type: "array", + prefixItems, + items: false, + maxItems: 2, + }; + expect(equal(allOptional, { ...allOptional, minItems: 2 })).toBe(false); + // A closed tuple cannot exceed its prefix, so `maxItems` restates it. + expect( + equal( + { type: "array", prefixItems, items: false, minItems: 2 }, + { type: "array", prefixItems, items: false, minItems: 2, maxItems: 2 }, + ), + ).toBe(true); + }); + + it("keeps arity bounds that say more than the prefix length", () => { + const prefixItems = [{ type: "string" }, { type: "number" }]; + const bare = { type: "array", prefixItems }; + // An optional trailing member: information the bare spelling never held. + expect( + equal(bare, { + type: "array", + prefixItems, + items: false, + minItems: 1, + maxItems: 2, + }), + ).toBe(false); + // A rest tuple is not the closed tuple the bare spelling denotes. + expect(equal(bare, { type: "array", prefixItems, items: {} })).toBe(false); + // `maxItems` on an open tuple bounds the rest elements; it restates nothing. + expect( + equal( + { type: "array", prefixItems, items: { type: "number" } }, + { + type: "array", + prefixItems, + items: { type: "number" }, + maxItems: 2, + }, + ), + ).toBe(false); + // A homogeneous array's bounds are untouched. + expect( + equal( + { type: "array", items: { type: "number" } }, + { type: "array", items: { type: "number" }, minItems: 0 }, + ), + ).toBe(false); + }); + + it("never rewrites instance data or unknown extension keys", () => { + const earlier = { anyOf: [{ type: "string" }, { type: "null" }] }; + const current = { type: ["string", "null"] }; + for (const key of ["default", "const", "examples", "x-config"]) { + expect( + equal( + { type: "object", [key]: earlier }, + { type: "object", [key]: current }, + ), + ).toBe(false); + } + expect( + equal({ ...earlier, "x-config": 1 }, { ...current, "x-config": 2 }), + ).toBe(false); + }); +}); + +describe("isStructuralSubtype over projection spellings", () => { + it("reads a tuple and its restated-arity spelling as mutual subtypes", () => { + const prefixItems: JsonSchema[] = [{ type: "number" }, { type: "number" }]; + const earlier: JsonSchema = { type: "array", prefixItems }; + const current: JsonSchema = { + type: "array", + prefixItems, + items: false, + minItems: 2, + maxItems: 2, + }; + + expect(isStructuralSubtype(earlier, current)).toEqual({ + verdict: "subtype", + }); + expect(isStructuralSubtype(current, earlier)).toEqual({ + verdict: "subtype", + }); + }); +}); + +describe("a rest tuple stored without a minimum reads as written", () => { + // The earlier projection wrote `z.tuple([a], rest)` with no `minItems`, + // which is byte-for-byte what the current one writes for + // `z.tuple([a.optional()], rest)`. Nothing in the document says which was + // meant, so it is read as written and the required tuple is a change. + const Headed = defineNode("Headed", { + schema: z.object({ head: z.tuple([z.string()]).rest(z.number()) }), + }); + const headedGraph = defineGraph({ + id: "diff_rest_tuple_spelling", + nodes: { Headed: { type: Headed } }, + edges: {}, + }); + + it("reports the earlier spelling of a required rest tuple as a breaking change", () => { + const current = serializeSchema(headedGraph, 2); + const earlier = toEarlierSpelling( + serializeSchema(headedGraph, 1), + ) as SerializedSchema; + + const diff = computeSchemaDiff(earlier, current); + + expect(diff.hasBreakingChanges).toBe(true); + expect(diff.nodes.map((change) => change.details)).toEqual([ + expect.stringContaining( + 'Property schemas changed incompatibly in "Headed": head.', + ), + ]); + }); +}); + +describe("a tuple whose members became required is a change", () => { + const graphOf = (pair: z.ZodType) => + defineGraph({ + id: "diff_tuple_required_members", + nodes: { + Thing: { type: defineNode("Thing", { schema: z.object({ pair }) }) }, + }, + edges: {}, + }); + const allOptional = z.tuple([z.string().optional(), z.number().optional()]); + const required = z.tuple([z.string(), z.number()]); + + it("reports optional-to-required tuple members as a breaking property change", () => { + const diff = computeSchemaDiff( + serializeSchema(graphOf(allOptional), 1), + serializeSchema(graphOf(required), 2), + ); + + console.log("tuple tightening diff:", diff.summary, diff.nodes); + expect(diff.hasBreakingChanges).toBe(true); + expect(diff.nodes.map((change) => change.details)).toEqual([ + expect.stringContaining( + 'Property schemas changed incompatibly in "Thing": pair.', + ), + ]); + }); + + it("refuses to open the tightened graph over a row the old tuple admitted", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graphOf(allOptional), backend); + await store.nodes.Thing.create({ pair: [] }); + + const outcome = await createStoreWithSchema( + graphOf(required), + backend, + ).then( + ([, result]) => result.status, + (error: unknown) => (error as Error).name, + ); + + console.log("reopen with required tuple:", outcome); + expect(outcome).toBe("MigrationError"); + const active = await backend.getActiveSchema("diff_tuple_required_members"); + expect(active?.version).toBe(1); + }); + + it.each([ + ["closed", allOptional, required], + [ + "rest", + z.tuple([z.string().optional()], z.number()), + z.tuple([z.string()], z.number()), + ], + ] as const)( + "judges an all-optional %s tuple not a subtype of the required one", + (_label, child, parent) => { + const result = isStructuralSubtype( + z.toJSONSchema(child) as JsonSchema, + z.toJSONSchema(parent) as JsonSchema, + ); + console.log("all-optional vs required:", JSON.stringify(result)); + expect(child.safeParse([]).success).toBe(true); + expect(parent.safeParse([]).success).toBe(false); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "array-bounds-not-tighter", + }); + expect( + isStructuralSubtype( + z.toJSONSchema(parent) as JsonSchema, + z.toJSONSchema(child) as JsonSchema, + ), + ).toEqual({ verdict: "subtype" }); + }, + ); +}); diff --git a/packages/typegraph/tests/schema-diff-subsumption-gate.test.ts b/packages/typegraph/tests/schema-diff-subsumption-gate.test.ts new file mode 100644 index 000000000..7f54f6cba --- /dev/null +++ b/packages/typegraph/tests/schema-diff-subsumption-gate.test.ts @@ -0,0 +1,291 @@ +/** + * Gap fix: `computeSchemaDiff` reports an incompatible + * `subClassOf`/`equivalentTo` hierarchy even when the diff touches NO + * relation at all — only a node kind's PROPERTY schema, on a kind that + * already participates in an existing hierarchy. + * + * `classifyOntologyChanges` short-circuits before building any registry + * when no relation changed (`src/schema/ontology-change.ts`), so without + * this gate a migration that only edits a child kind's properties would + * pass `getSchemaChanges`/`requiresMigration` and fail only at commit. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStore, + defineGraph, + defineNode, + embedding, + subClassOf, +} from "../src"; +import { ConfigurationError } from "../src/errors"; +import { + ensureSchema, + getSchemaChanges, + requiresMigration, +} from "../src/schema"; +import { computeSchemaDiff } from "../src/schema/migration"; +import { computeSchemaHash, serializeSchema } from "../src/schema/serializer"; +import { + type SerializedOntology, + type SerializedSchema, +} from "../src/schema/types"; +import { createTestBackend, matchingObject } from "./test-utils"; + +function emptyOntology( + relations: SerializedOntology["relations"] = [], +): SerializedOntology { + return { + metaEdges: {}, + relations, + closures: { + subClassAncestors: {}, + subClassDescendants: {}, + broaderClosure: {}, + narrowerClosure: {}, + equivalenceSets: {}, + disjointPairs: [], + partOfClosure: {}, + hasPartClosure: {}, + iriToKind: {}, + edgeInverses: {}, + edgeImplicationsClosure: {}, + edgeImplyingClosure: {}, + }, + }; +} + +function schemaWithLooseCode( + looseHasMinLength: boolean, + looseMinLength = 5, +): SerializedSchema { + return { + graphId: "subsumption_gate_test", + version: 1, + generatedAt: "2024-01-01T00:00:00Z", + nodes: { + Loose: { + kind: "Loose", + properties: { + type: "object", + properties: { + code: + looseHasMinLength ? + { type: "string", minLength: looseMinLength } + : { type: "string" }, + }, + required: ["code"], + additionalProperties: false, + }, + uniqueConstraints: [], + onDelete: "restrict", + description: undefined, + }, + Tight: { + kind: "Tight", + properties: { + type: "object", + properties: { code: { type: "string", minLength: 5 } }, + required: ["code"], + additionalProperties: false, + }, + uniqueConstraints: [], + onDelete: "restrict", + description: undefined, + }, + }, + edges: {}, + ontology: emptyOntology([ + { metaEdge: "subClassOf", from: "Loose", to: "Tight" }, + ]), + defaults: { onNodeDelete: "restrict", temporalMode: "current" }, + }; +} + +describe("computeSchemaDiff — property-only change against an existing hierarchy", () => { + it("refuses when a migration relaxes a child's property, breaking an UNCHANGED subClassOf relation", () => { + const before = schemaWithLooseCode(true); + const after = { ...schemaWithLooseCode(false), version: 2 }; + + // The relation itself is byte-identical on both sides — only Loose's + // properties changed. + expect(before.ontology.relations).toEqual(after.ontology.relations); + + expect(() => computeSchemaDiff(before, after)).toThrow(ConfigurationError); + }); + + it("does not throw when the child's property change keeps the hierarchy valid", () => { + // `after` genuinely changes Loose's `code` property (minLength 5 -> 10, + // still >= Tight's minLength 5, so the hierarchy stays valid). + const before = schemaWithLooseCode(true); + const after = { ...schemaWithLooseCode(true, 10), version: 2 }; + expect(before.nodes["Loose"]).not.toEqual(after.nodes["Loose"]); + + expect(() => computeSchemaDiff(before, after)).not.toThrow(); + }); +}); + +/** + * The pre-upgrade report: a deployment whose stored document and graph are + * IDENTICAL, carrying a hierarchy an earlier release accepted and this one + * refuses. Nothing changed, so no relation-level or property-level trigger + * fires — the diff must hold the proposed graph to the contract regardless. + * + * LOAD-BEARING CHECK: gating the AFTER-side registry build on a changed node + * kind again makes `computeSchemaDiff` return "No changes" and + * `getSchemaChanges` resolve, failing all three cases. + */ +describe("an unchanged graph whose existing hierarchy is refused", () => { + it("is refused by computeSchemaDiff with nothing to diff", () => { + const stored = schemaWithLooseCode(false); + + expect(() => computeSchemaDiff(stored, { ...stored, version: 2 })).toThrow( + expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + childKind: "Loose", + parentKind: "Tight", + }), + }), + ); + }); + + const Tight = defineNode("Tight", { + schema: z.object({ code: z.string().min(5) }), + }); + const Loose = defineNode("Loose", { schema: z.object({ code: z.string() }) }); + // The typed `subClassOf` factory refuses this pair at compile time; an + // earlier release accepted it, so the cast stands in for that release. + const storedRelation = subClassOf(Loose as unknown as typeof Tight, Tight); + + async function seedUnchangedDeployment() { + const backend = createTestBackend(); + // Exactly the document the graph serializes to, so the diff has no node + // change to notice the hierarchy through. + const stored = serializeSchema(unchangedGraph(), 1); + await backend.commitSchemaVersion({ + graphId: stored.graphId, + expected: { kind: "initial" }, + version: stored.version, + schemaHash: await computeSchemaHash(stored), + schemaDoc: stored, + }); + return backend; + } + + function unchangedGraph() { + // `defineGraph` validates lazily; only the schema entry points are asked. + return defineGraph({ + id: "preupgrade", + nodes: { Loose: { type: Loose }, Tight: { type: Tight } }, + edges: {}, + ontology: [storedRelation], + }); + } + + it("is reported by getSchemaChanges before the upgrade", async () => { + const backend = await seedUnchangedDeployment(); + + const outcome = await getSchemaChanges(backend, unchangedGraph()).then( + (diff) => `resolved: ${diff?.summary ?? "no schema"}`, + (error: unknown) => error, + ); + + console.log("getSchemaChanges on an unchanged refused hierarchy:", outcome); + expect(outcome).toBeInstanceOf(ConfigurationError); + expect((outcome as ConfigurationError).details).toMatchObject({ + code: "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + childKind: "Loose", + parentKind: "Tight", + }); + }); + + it("routes requiresMigration to the privileged path", async () => { + const backend = await seedUnchangedDeployment(); + + expect(await requiresMigration(backend, unchangedGraph())).toBe(true); + }); +}); + +async function settle(promise: Promise): Promise { + return promise.then( + (value) => ({ resolved: value }), + (error: unknown) => error, + ); +} + +/** + * A kind holding a field JSON Schema cannot express (`embedding()` here) + * persists as a whole-kind placeholder, so a stored document's registry cannot + * judge the pair it sits in. The commit path and `createStore` judge with the + * live graph's per-field projection instead; the pre-flight has to reach the + * same verdict, or a least-privilege runtime routed by `requiresMigration` + * boots straight into the refusal. + */ +describe("a hierarchy refused beside a field the stored document cannot express", () => { + const Parent = defineNode("Parent", { + schema: z.object({ code: z.string().min(5) }), + }); + const Child = defineNode("Child", { + schema: z.object({ code: z.string(), vector: embedding(3) }), + }); + const nodes = { Parent: { type: Parent }, Child: { type: Child } }; + const graphId = "unprojectable_preflight"; + + function hierarchyGraph() { + return defineGraph({ + id: graphId, + nodes, + edges: {}, + ontology: [subClassOf(Child, Parent)], + }); + } + + const expectedRefusal: unknown = expect.objectContaining({ + details: matchingObject({ + code: "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + childKind: "Child", + parentKind: "Parent", + reason: "string-length-not-tighter", + path: ["code"], + }), + }); + + it("is reported by getSchemaChanges when the upgrade adds the hierarchy, as ensureSchema refuses it", async () => { + const backend = createTestBackend(); + await ensureSchema(backend, defineGraph({ id: graphId, nodes, edges: {} })); + + const preflight = await settle(getSchemaChanges(backend, hierarchyGraph())); + const commit = await settle(ensureSchema(backend, hierarchyGraph())); + + console.log("pre-flight:", preflight, "commit:", commit); + expect(commit).toEqual(expectedRefusal); + expect(preflight).toBeInstanceOf(ConfigurationError); + expect(preflight).toEqual(expectedRefusal); + expect(await requiresMigration(backend, hierarchyGraph())).toBe(true); + }); + + it("is reported by getSchemaChanges and requiresMigration when the stored document already holds it, as createStore refuses it", async () => { + const backend = createTestBackend(); + const stored = serializeSchema(hierarchyGraph(), 1); + await backend.commitSchemaVersion({ + graphId: stored.graphId, + expected: { kind: "initial" }, + version: stored.version, + schemaHash: await computeSchemaHash(stored), + schemaDoc: stored, + }); + + const preflight = await settle(getSchemaChanges(backend, hierarchyGraph())); + const routed = await requiresMigration(backend, hierarchyGraph()); + const open = await settle( + Promise.resolve().then(() => createStore(hierarchyGraph(), backend)), + ); + + console.log("pre-flight:", preflight, "routed:", routed, "open:", open); + expect(open).toEqual(expectedRefusal); + expect(preflight).toEqual(expectedRefusal); + expect(routed).toBe(true); + }); +}); diff --git a/packages/typegraph/tests/schema-fused-insert.test.ts b/packages/typegraph/tests/schema-fused-insert.test.ts index a13a242d1..1272e168a 100644 --- a/packages/typegraph/tests/schema-fused-insert.test.ts +++ b/packages/typegraph/tests/schema-fused-insert.test.ts @@ -765,7 +765,7 @@ describe("single-statement autocommit eligibility", () => { revisionTrackingEnabled: false, kindRegistered: true, convergesDynamically: false, - cardinality: "many" as const, + constrained: false, }; const eligible = ( overrides: Partial< @@ -785,7 +785,14 @@ describe("single-statement autocommit eligibility", () => { expect(eligible({ revisionTrackingEnabled: true })).toBe(false); expect(eligible({ kindRegistered: false })).toBe(false); expect(eligible({ convergesDynamically: true })).toBe(false); - expect(eligible({ cardinality: "one" })).toBe(false); + // `constrained` folds BOTH origins `edgeWriteNeedsConstraintFence` + // classifies — a constrained cardinality axis (source or target) and + // `acyclic: true` — since no native single-statement program applies + // either check; such a create must decline the fused path and + // re-enter the portable one. Each origin's own contribution to this + // fold is unit-tested separately (constraint-fence-capability.test.ts, + // edge-acyclicity.test.ts). + expect(eligible({ constrained: true })).toBe(false); expect(eligible({ backend: deriveBackend(backend, {}) })).toBe(false); } finally { await backend.close(); @@ -823,7 +830,7 @@ describe("single-statement autocommit eligibility", () => { revisionTrackingEnabled: false, kindRegistered: true, convergesDynamically: false, - cardinality: "many", + constrained: false, }; try { @@ -882,7 +889,7 @@ describe("single-statement autocommit eligibility", () => { expect( canFuseSchemaFenceInFirstWrite({ kind: "edge", - candidate: { ...edgeCandidate, cardinality: "one" }, + candidate: { ...edgeCandidate, constrained: true }, }), ).toBe(false); } finally { diff --git a/packages/typegraph/tests/schema-golden-document.test.ts b/packages/typegraph/tests/schema-golden-document.test.ts new file mode 100644 index 000000000..8ac07f510 --- /dev/null +++ b/packages/typegraph/tests/schema-golden-document.test.ts @@ -0,0 +1,245 @@ +/** + * A committed schema document, byte for byte, for a graph exercising every + * slice the serializer writes or conditionally omits. + * + * A stored version is compared against the graph by hash, so a serializer + * change that adds, renames or reorders a field changes the hash of every + * document already stored. The fixture is written once and never + * regenerated by the test that reads it: the serializer must reproduce it, + * and a store whose row holds it must reopen `unchanged` without rewriting + * it. A deliberate change to the serialized form updates the fixture in the + * same commit, with its upgrade path stated. + */ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +import type Database from "better-sqlite3"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineGraphExtension, + defineNode, + defineNodeIndex, + disjointWith, + hasPart, + partOf, + subClassOf, + type UniqueConstraint, +} from "../src"; +import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; +import { mergeGraphExtension } from "../src/graph-extension/merge"; +import { applyDeprecatedKinds } from "../src/schema/manager"; +import { computeSchemaHash, serializeSchema } from "../src/schema/serializer"; +import { type SerializedSchema } from "../src/schema/types"; +import { createAdapterStoreWithSchema } from "../src/store/store"; +import { requireDefined } from "../src/utils/presence"; + +const FIXTURE_PATH = path.join( + import.meta.dirname, + "fixtures", + "golden-schema-document.json", +); + +/** The fixture's `generatedAt`, the one field read off the clock. */ +const GOLDEN_GENERATED_AT = "2026-11-03T00:00:00.000Z"; + +/** `computeSchemaHash` of the fixture, as committed with it. */ +const GOLDEN_SCHEMA_HASH = "9aa79a42455e4821"; + +const Library = defineNode("GoldenLibrary", { + schema: z.object({ name: z.string(), code: z.string() }), + annotations: { owner: "catalog" }, +}); +const Shelf = defineNode("GoldenShelf", { + schema: z.object({ label: z.string().min(1) }), +}); +const Book = defineNode("GoldenBook", { + schema: z.object({ title: z.string(), isbn: z.string().optional() }), +}); +const RareBook = defineNode("GoldenRareBook", { + schema: z.object({ + title: z.string(), + isbn: z.string().optional(), + vault: z.string(), + }), +}); +const Patron = defineNode("GoldenPatron", { + schema: z.object({ email: z.string() }), +}); +const Legacy = defineNode("GoldenLegacy", { schema: z.object({}) }); +const Section = defineNode("GoldenSection", { schema: z.object({}) }); + +const shelfIn = defineEdge("goldenShelfIn", { schema: z.object({}) }); +const holds = defineEdge("goldenHolds", { schema: z.object({}) }); +const cites = defineEdge("goldenCites", { schema: z.object({}) }); +const nests = defineEdge("goldenNests", { schema: z.object({}) }); +const borrows = defineEdge("goldenBorrows", { + schema: z.object({ loanCode: z.string() }), +}); + +/** + * The types require `scope` and `collation`; a JavaScript caller can omit + * them, and `defineGraph` writes the defaults in. + */ +function defaultedUnique(name: string, field: string): UniqueConstraint { + return { name, fields: [field] } as unknown as UniqueConstraint; +} + +const baseGraph = defineGraph({ + id: "schema_golden_document", + annotations: { domain: "library", revision: 1 }, + identity: { sameIdAcrossKinds: "ignore" }, + nodes: { + GoldenLibrary: { + type: Library, + unique: [ + defaultedUnique("golden_library_name", "name"), + { + name: "golden_library_code", + fields: ["code"], + scope: "kind", + collation: "caseInsensitive", + }, + ], + }, + GoldenShelf: { type: Shelf }, + GoldenBook: { + type: Book, + unique: [ + { + name: "golden_book_isbn", + fields: ["isbn"], + scope: "kindWithSubClasses", + collation: "binary", + where: (fields) => requireDefined(fields["isbn"]).isNotNull(), + }, + ], + }, + GoldenRareBook: { type: RareBook }, + GoldenPatron: { type: Patron }, + GoldenLegacy: { type: Legacy }, + GoldenSection: { type: Section }, + }, + edges: { + goldenShelfIn: { + type: shelfIn, + from: [Shelf], + to: [Library], + cardinality: "one", + }, + goldenHolds: { + type: holds, + from: [Shelf], + to: [Book], + targetCardinality: "oneActive", + }, + goldenCites: { + type: cites, + from: [Book], + to: [Book], + acyclic: true, + endpointExistence: "currentlyValid", + }, + goldenNests: { + type: nests, + from: [Section], + to: [Section], + cardinality: "one", + }, + goldenBorrows: { + type: borrows, + from: [Patron], + to: [Book], + matchIdentity: { name: "golden-loan", fields: ["loanCode"] }, + }, + }, + ontology: [ + subClassOf(RareBook, Book), + disjointWith(Patron, Book), + partOf(Shelf, Library, { + via: shelfIn, + existence: "required", + partSide: "from", + }), + hasPart(Shelf, Book, { via: holds }), + // The edge admits the pair both ways, so the side is what decides it + // and the document keeps it. + partOf(Section, Section, { via: nests, partSide: "from" }), + ], + indexes: [ + defineNodeIndex(Book, { fields: ["title"] }), + defineNodeIndex(Patron, { + fields: ["email"], + where: (props) => props.email.isNotNull(), + }), + ], +}); + +const goldenGraph = applyDeprecatedKinds( + mergeGraphExtension( + baseGraph, + defineGraphExtension({ + nodes: { + GoldenArchive: { + properties: { box: { type: "string" } }, + unique: [{ name: "golden_archive_box", fields: ["box"] }], + }, + }, + }), + ), + ["GoldenLegacy"], +); + +function serializedBytes(): string { + vi.useFakeTimers({ toFake: ["Date"] }); + try { + vi.setSystemTime(new Date(GOLDEN_GENERATED_AT)); + return JSON.stringify(serializeSchema(goldenGraph, 1)); + } finally { + vi.useRealTimers(); + } +} + +/** The fixture's bytes: the compact JSON a schema row stores. */ +function fixtureBytes(): string { + return JSON.stringify(JSON.parse(readFileSync(FIXTURE_PATH, "utf8"))); +} + +const backends: ReturnType["backend"][] = []; +afterEach(async () => { + for (const backend of backends.splice(0)) await backend.close(); +}); + +describe("the golden schema document", () => { + it("is what today's serializer writes for the graph, with the committed hash", async () => { + expect(serializedBytes()).toBe(fixtureBytes()); + expect( + await computeSchemaHash(JSON.parse(fixtureBytes()) as SerializedSchema), + ).toBe(GOLDEN_SCHEMA_HASH); + }); + + it("reopens unchanged from a stored row holding it, and leaves the row as written", async () => { + const { backend, db } = createLocalSqliteBackend(); + backends.push(backend); + await createAdapterStoreWithSchema(goldenGraph, backend); + const client = (db as unknown as Readonly<{ $client: Database.Database }>) + .$client; + const golden = fixtureBytes(); + client + .prepare( + "UPDATE typegraph_schema_versions SET schema_doc = ?, schema_hash = ? WHERE graph_id = ? AND version = 1", + ) + .run(golden, GOLDEN_SCHEMA_HASH, goldenGraph.id); + + const [, result] = await createAdapterStoreWithSchema(goldenGraph, backend); + const row = requireDefined(await backend.getActiveSchema(goldenGraph.id)); + + expect(result.status).toBe("unchanged"); + expect(row.version).toBe(1); + expect(row.schema_doc).toBe(golden); + expect(row.schema_hash).toBe(GOLDEN_SCHEMA_HASH); + }); +}); diff --git a/packages/typegraph/tests/schema-graph-write-fence.test.ts b/packages/typegraph/tests/schema-graph-write-fence.test.ts index 125f9703c..e3a6c3d8d 100644 --- a/packages/typegraph/tests/schema-graph-write-fence.test.ts +++ b/packages/typegraph/tests/schema-graph-write-fence.test.ts @@ -17,6 +17,7 @@ import { postgresFenceSql } from "../src/backend/drizzle/postgres-fence-sql"; import { tables as postgresTables } from "../src/backend/drizzle/schema/postgres"; import { createPostgresBackend } from "../src/backend/postgres"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; +import type { GraphWriteFenceAcquisition } from "../src/backend/types"; import { tsvectorStrategy } from "../src/query/dialect/fulltext-strategy"; import { requireDefined } from "../src/utils/presence"; @@ -59,12 +60,40 @@ describe("schema + graph write fence", () => { }); expect(backend.lockSchemaVersionAndGraphWrite).toBeUndefined(); - await backend.transaction(async (tx) => { - await requireDefined(tx.lockSchemaVersionAndGraphWrite)({ - expectedVersion: 1, - graphId: "fused-lock", - }); + const acquire = ( + fenceRow: "advance" | "leave", + ): Promise => + backend.transaction((tx) => + requireDefined(tx.lockSchemaVersionAndGraphWrite)({ + expectedVersion: 1, + graphId: "fused-lock", + fenceRow, + }), + ); + const fenceRowGenerations = async (): Promise => { + const fenceRows = await client.query<{ generation: string }>( + `SELECT generation FROM "typegraph_fences" WHERE key = $1`, + ["typegraph:recorded-graph-write:fused-lock"], + ); + return fenceRows.rows.map((row) => Number(row.generation)); + }; + // A holder that decides nothing under the fence takes the lock alone: + // the statement reports the session's isolation and writes no row. + await expect(acquire("leave")).resolves.toEqual({ + isolation: "read_committed", + fenceRow: "none", + }); + expect(await fenceRowGenerations()).toEqual([]); + // A holder that goes on to a lock-only decision advances the graph's + // fence row behind the lock, and reports that it did — the evidence a + // snapshot-isolation session's freshness rests on. + await expect(acquire("advance")).resolves.toEqual({ + isolation: "read_committed", + fenceRow: "advanced", }); + await acquire("advance"); + await acquire("leave"); + expect(await fenceRowGenerations()).toEqual([2]); const strategy = createPostgresOperationStrategy( postgresTables, @@ -72,11 +101,20 @@ describe("schema + graph write fence", () => { ); const compiled = new PgDialect().sqlToQuery( requireDefined(strategy.buildLockSchemaVersionAndGraphWrite)( - { expectedVersion: 1, graphId: "fused-lock" }, + { expectedVersion: 1, graphId: "fused-lock", fenceRow: "advance" }, + "typegraph:recorded-graph-write", + postgresFenceSql, + ), + ); + const lockOnly = new PgDialect().sqlToQuery( + requireDefined(strategy.buildLockSchemaVersionAndGraphWrite)( + { expectedVersion: 1, graphId: "fused-lock", fenceRow: "leave" }, "typegraph:recorded-graph-write", postgresFenceSql, ), ); + expect(lockOnly.sql.toLowerCase()).toContain("pg_advisory_xact_lock"); + expect(lockOnly.sql).not.toContain("typegraph_fences"); const statement = compiled.sql.toLowerCase(); expect(statement).toContain('"schema_fence" as materialized'); expect(statement).toContain("for share"); @@ -87,6 +125,16 @@ describe("schema + graph write fence", () => { statement.indexOf('"graph_write_lock" as materialized'), ); expect(statement).toContain('from "schema_fence"'); + // The fence row is written only after — and only if — the advisory + // lock was taken: its row source is the lock CTE. + expect(statement).toContain('insert into "typegraph_fences"'); + expect(statement).toContain('from "graph_write_lock"'); + expect( + statement.indexOf('"graph_write_lock" as materialized'), + ).toBeLessThan(statement.indexOf('insert into "typegraph_fences"')); + expect(compiled.params).toContain( + "typegraph:recorded-graph-write:fused-lock", + ); } finally { await client.close(); } @@ -106,6 +154,7 @@ describe("schema + graph write fence", () => { requireDefined(tx.lockSchemaVersionAndGraphWrite)({ expectedVersion: 2, graphId: "stale-lock", + fenceRow: "advance", }), ), ).rejects.toMatchObject({ @@ -129,6 +178,7 @@ describe("schema + graph write fence", () => { requireDefined(tx.lockSchemaVersionAndGraphWrite)({ expectedVersion: 0, graphId: "rolled-back-lock", + fenceRow: "advance", }), ), ).rejects.toMatchObject({ diff --git a/packages/typegraph/tests/schema-hash-upgrade.test.ts b/packages/typegraph/tests/schema-hash-upgrade.test.ts new file mode 100644 index 000000000..0bd05ab00 --- /dev/null +++ b/packages/typegraph/tests/schema-hash-upgrade.test.ts @@ -0,0 +1,758 @@ +/** + * A stored schema version keeps the document and `schema_hash` the release + * that wrote it produced. Every comparison against a graph serialized today + * reads `activeSchemaHash`, which hashes the stored document in this + * release's serialization, so a document an earlier release wrote for an + * unchanged schema compares equal to its graph without being rewritten. + */ +import type Database from "better-sqlite3"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + defineEdge, + defineGraph, + defineNode, + defineNodeIndex, + equivalentTo, + type GraphDef, + partOf, + subClassOf, + type UniqueConstraint, + UniquenessError, +} from "../src"; +import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; +import { + captureCandidateWriteSetTarget, + isErr, + planCandidateWriteSetReview, + revalidateCandidateWriteSetReview, +} from "../src/graph-merge"; +import { + instantiateGraphTemplate, + registerGraphTemplate, +} from "../src/schema/graph-templates"; +import { activeSchemaHash, parseSerializedSchema } from "../src/schema/manager"; +import { computeSchemaDiff } from "../src/schema/migration"; +import { + computeSchemaHash, + getSchemaHash, + serializeSchema, +} from "../src/schema/serializer"; +import { + type SerializedSchema, + type SerializedUniqueConstraint, +} from "../src/schema/types"; +import { + createAdapterStoreWithSchema, + createVerifiedAdapterStore, +} from "../src/store/store"; +import { requireDefined } from "../src/utils/presence"; + +const Animal = defineNode("UpgradeAnimal", { + schema: z.object({ name: z.string() }), +}); +const Dog = defineNode("UpgradeDog", { + schema: z.object({ name: z.string() }), +}); +const Hound = defineNode("UpgradeHound", { + schema: z.object({ name: z.string() }), +}); +const Kennel = defineNode("UpgradeKennel", { schema: z.object({}) }); +const housedIn = defineEdge("upgradeHousedIn", { schema: z.object({}) }); + +const subClassGraph = defineGraph({ + id: "schema_hash_upgrade_subclass", + nodes: { UpgradeAnimal: { type: Animal }, UpgradeDog: { type: Dog } }, + edges: {}, + ontology: [subClassOf(Dog, Animal)], +}); + +const equivalenceGraph = defineGraph({ + id: "schema_hash_upgrade_equivalence", + nodes: { + UpgradeAnimal: { type: Animal }, + UpgradeDog: { type: Dog }, + UpgradeHound: { type: Hound }, + UpgradeKennel: { type: Kennel }, + }, + edges: { + upgradeHousedIn: { type: housedIn, from: [Dog], to: [Kennel] }, + }, + ontology: [subClassOf(Dog, Animal), equivalentTo(Hound, Dog)], +}); + +/** The same graph with one property changed: a real schema change. */ +const ResizedKennel = defineNode("UpgradeKennel", { + schema: z.object({ capacity: z.number() }), +}); +const changedEquivalenceGraph = defineGraph({ + id: equivalenceGraph.id, + nodes: { + UpgradeAnimal: { type: Animal }, + UpgradeDog: { type: Dog }, + UpgradeHound: { type: Hound }, + UpgradeKennel: { type: ResizedKennel }, + }, + edges: { + upgradeHousedIn: { type: housedIn, from: [Dog], to: [ResizedKennel] }, + }, + ontology: [subClassOf(Dog, Animal), equivalentTo(Hound, Dog)], +}); + +/** + * A graph exercising every slice the stored-document parser defaults or the + * serializer conditionally omits: unique constraints with the scope and + * collation omitted and with them declared, an index, graph annotations, a + * composition pair, an acyclic edge kind and a target cardinality. + */ +const Folder = defineNode("UpgradeFolder", { + schema: z.object({ name: z.string(), slug: z.string() }), +}); +const containsFolder = defineEdge("upgradeContainsFolder", { + schema: z.object({}), +}); +const linksTo = defineEdge("upgradeLinksTo", { schema: z.object({}) }); +/** + * The types require `scope` and `collation`, but a JavaScript caller can omit + * them. `defineGraph` writes the defaults in; an earlier release serialized + * them away instead ({@link withoutFolderNameEnforcementOptions}). + */ +function uniqueWithoutScopeOrCollation( + name: string, + field: string, +): UniqueConstraint { + return { name, fields: [field] } as unknown as UniqueConstraint; +} + +const representativeGraph = defineGraph({ + id: "schema_hash_upgrade_representative", + annotations: { owner: "schema-hash-upgrade" }, + nodes: { + UpgradeFolder: { + type: Folder, + unique: [ + uniqueWithoutScopeOrCollation("folder_name", "name"), + { + name: "folder_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + upgradeContainsFolder: { + type: containsFolder, + from: [Folder], + to: [Folder], + cardinality: "one", + }, + upgradeLinksTo: { + type: linksTo, + from: [Folder], + to: [Folder], + acyclic: true, + targetCardinality: "oneActive", + }, + }, + ontology: [partOf(Folder, Folder, { via: containsFolder, partSide: "from" })], + indexes: [defineNodeIndex(Folder, { fields: ["slug"] })], +}); + +interface MutableDocument { + ontology: { + metaEdges: Record>; + closures: Record; + }; +} + +/** + * The document an earlier release wrote: every meta-edge entry carries the + * fields that release serialized, and the subsumption closures were derived + * without folding `equivalentTo` into them. + */ +function earlierReleaseDocument(current: SerializedSchema): SerializedSchema { + const document = structuredClone(current) as unknown as MutableDocument; + for (const metaEdge of Object.values(document.ontology.metaEdges)) { + metaEdge["transitive"] = metaEdge["name"] !== "equivalentTo"; + metaEdge["symmetric"] = metaEdge["name"] === "equivalentTo"; + metaEdge["reflexive"] = false; + metaEdge["inference"] = + metaEdge["name"] === "equivalentTo" ? "substitution" : "subsumption"; + } + document.ontology.closures["subClassAncestors"] = { + UpgradeDog: ["UpgradeAnimal"], + }; + document.ontology.closures["subClassDescendants"] = { + UpgradeAnimal: ["UpgradeDog"], + }; + return document as unknown as SerializedSchema; +} + +type SqliteHandle = Readonly<{ + backend: ReturnType["backend"]; + client: Database.Database; +}>; + +const backends: SqliteHandle["backend"][] = []; +afterEach(async () => { + for (const backend of backends.splice(0)) await backend.close(); +}); + +function sqliteHandle(): SqliteHandle { + const { backend, db } = createLocalSqliteBackend(); + backends.push(backend); + const client = (db as unknown as Readonly<{ $client: Database.Database }>) + .$client; + return { backend, client }; +} + +/** + * `subClassGraph` with a unique constraint on both kinds that leaves `scope` + * and `collation` out, or states them as `"kindWithSubClasses"` and + * `"binary"`. An earlier release enforced the omitted form exactly as the + * stated one: across the subclass component, with claims at its code-point + * minimum, `UpgradeAnimal`. + */ +function hierarchyGraphDeclaring( + constraint: (name: string, field: string) => UniqueConstraint, +) { + return defineGraph({ + id: "schema_hash_upgrade_defaulted_hierarchy", + nodes: { + UpgradeAnimal: { + type: Animal, + unique: [constraint("animal_name", "name")], + }, + UpgradeDog: { type: Dog, unique: [constraint("animal_name", "name")] }, + }, + edges: {}, + ontology: [subClassOf(Dog, Animal)], + }); +} +const defaultedHierarchyGraph = hierarchyGraphDeclaring( + uniqueWithoutScopeOrCollation, +); +const componentScopedHierarchyGraph = hierarchyGraphDeclaring( + (name, field) => ({ + name, + fields: [field], + scope: "kindWithSubClasses", + collation: "binary", + }), +); + +/** `current` with every unique constraint's `scope` and `collation` removed. */ +function withoutEnforcementOptions( + current: SerializedSchema, +): SerializedSchema { + return { + ...current, + nodes: Object.fromEntries( + Object.entries(current.nodes).map(([kind, node]) => [ + kind, + { + ...node, + uniqueConstraints: node.uniqueConstraints.map((constraint) => { + const { + scope: _scope, + collation: _collation, + ...rest + } = constraint; + return rest as SerializedUniqueConstraint; + }), + }, + ]), + ), + }; +} + +/** + * The document an earlier release wrote for `representativeGraph`: the + * serializer copied the `folder_name` declaration as given, so the `scope` + * and `collation` its caller omitted are absent. + */ +function withoutFolderNameEnforcementOptions( + current: SerializedSchema, +): SerializedSchema { + const folder = requireDefined(current.nodes["UpgradeFolder"]); + return { + ...current, + nodes: { + ...current.nodes, + UpgradeFolder: { + ...folder, + uniqueConstraints: folder.uniqueConstraints.map((constraint) => { + if (constraint.name !== "folder_name") return constraint; + const { scope: _scope, collation: _collation, ...rest } = constraint; + return rest as SerializedUniqueConstraint; + }), + }, + }, + }; +} + +/** Rewrites the active v1 row to what an earlier release committed. */ +async function storeEarlierReleaseRow( + handle: SqliteHandle, + graph: GraphDef, + document: SerializedSchema = earlierReleaseDocument( + serializeSchema(graph, 1), + ), +): Promise> { + const schemaDocument = JSON.stringify(document); + const schemaHash = await computeSchemaHash(document); + handle.client + .prepare( + "UPDATE typegraph_schema_versions SET schema_doc = ?, schema_hash = ? WHERE graph_id = ? AND version = 1", + ) + .run(schemaDocument, schemaHash, graph.id); + return { schemaDoc: schemaDocument, schemaHash }; +} + +describe("activeSchemaHash", () => { + it("equals the column for every row this release commits", async () => { + const handle = sqliteHandle(); + for (const graph of [ + subClassGraph, + equivalenceGraph, + representativeGraph, + ]) { + const [store] = await createAdapterStoreWithSchema(graph, handle.backend); + const row = requireDefined( + await handle.backend.getActiveSchema(graph.id), + ); + + expect(await activeSchemaHash(row)).toBe(row.schema_hash); + expect(store.reconciledSchema.hash).toBe(await getSchemaHash(graph, 1)); + } + }); + + it("agrees with the schema diff on whether an earlier release's document describes the graph", async () => { + const stored = earlierReleaseDocument(serializeSchema(equivalenceGraph, 1)); + const row = { schema_doc: JSON.stringify(stored) }; + const storedHash = await activeSchemaHash(row); + const cases = [ + { label: "same graph", graph: equivalenceGraph }, + { label: "a changed property", graph: changedEquivalenceGraph }, + { label: "a removed relation", graph: subClassGraph }, + ]; + + const verdicts = await Promise.all( + cases.map(async ({ label, graph }) => { + const current = serializeSchema(graph, 2); + const diff = computeSchemaDiff( + parseSerializedSchema(row.schema_doc), + current, + ); + return { + label, + semanticallyUnchanged: !diff.hasChanges, + hashesEqual: storedHash === (await getSchemaHash(graph, 2)), + }; + }), + ); + + expect(await computeSchemaHash(stored)).not.toBe( + await getSchemaHash(equivalenceGraph, 2), + ); + expect(verdicts).toEqual([ + { label: "same graph", semanticallyUnchanged: true, hashesEqual: true }, + { + label: "a changed property", + semanticallyUnchanged: false, + hashesEqual: false, + }, + { + label: "a removed relation", + semanticallyUnchanged: false, + hashesEqual: false, + }, + ]); + }); + // MUTATION CHECK: hashing the parsed document without + // `canonicalStoredSchemaDocument` makes "same graph" report + // `hashesEqual: false` while the diff still calls it unchanged. +}); + +describe("a schema version this release commits", () => { + it("registers a template on a graph declaring default-scope unique constraints", async () => { + const handle = sqliteHandle(); + const [store] = await createAdapterStoreWithSchema( + representativeGraph, + handle.backend, + ); + + const template = await registerGraphTemplate(handle.backend, { + templateId: "schema-hash-representative-template", + reconciled: store.reconciledSchema, + }); + + expect(template.schemaHash).toBe( + await getSchemaHash(representativeGraph, 1), + ); + }); + + it("revalidates a candidate review on a graph declaring default-scope unique constraints", async () => { + const handle = sqliteHandle(); + const [store] = await createAdapterStoreWithSchema( + representativeGraph, + handle.backend, + { history: true }, + ); + const args = { + target: store, + makeBackend: () => Promise.resolve(createLocalSqliteBackend().backend), + writeSet: { + formatVersion: 1 as const, + sourceId: "source", + target: await captureCandidateWriteSetTarget(store), + nodes: [ + { + kind: "UpgradeFolder", + id: "candidate", + properties: { name: "root", slug: "root" }, + validFrom: "2026-01-01T00:00:00.000Z", + }, + ], + edges: [], + }, + policy: { id: "schema-hash-representative-policy", context: {} }, + }; + + const planned = await planCandidateWriteSetReview(args); + if (isErr(planned)) throw planned.error; + const revalidated = await revalidateCandidateWriteSetReview({ + ...args, + review: planned.data, + }); + + expect(isErr(revalidated)).toBe(false); + }); +}); + +describe("a schema version an earlier release committed", () => { + it("reopens unchanged without rewriting the row, and a template registers from it", async () => { + const handle = sqliteHandle(); + await createAdapterStoreWithSchema(equivalenceGraph, handle.backend); + const earlier = await storeEarlierReleaseRow(handle, equivalenceGraph); + + const [store, result] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + ); + const row = requireDefined( + await handle.backend.getActiveSchema(equivalenceGraph.id), + ); + const template = await registerGraphTemplate(handle.backend, { + templateId: "schema-hash-upgrade-template", + reconciled: store.reconciledSchema, + }); + + expect(result.status).toBe("unchanged"); + expect(row.version).toBe(1); + expect(row.schema_doc).toBe(earlier.schemaDoc); + expect(row.schema_hash).toBe(earlier.schemaHash); + expect(store.reconciledSchema.hash).toBe( + await getSchemaHash(equivalenceGraph, 1), + ); + expect(template.schemaHash).toBe(store.reconciledSchema.hash); + expect(await store.refreshSchema()).toBe(store); + }); + // MUTATION CHECK: reading `row.schema_hash` in `schemaMetadataFromRow` + // (store.ts) makes `registerGraphTemplate` throw + // GRAPH_TEMPLATE_RECONCILED_HASH_MISMATCH. + + it("reopens unchanged when it left a defaulted unique constraint's scope and collation out", async () => { + const handle = sqliteHandle(); + await createAdapterStoreWithSchema(representativeGraph, handle.backend); + const current = serializeSchema(representativeGraph, 1); + const earlier = await storeEarlierReleaseRow( + handle, + representativeGraph, + withoutFolderNameEnforcementOptions(current), + ); + + const [store, result] = await createAdapterStoreWithSchema( + representativeGraph, + handle.backend, + ); + const row = requireDefined( + await handle.backend.getActiveSchema(representativeGraph.id), + ); + const template = await registerGraphTemplate(handle.backend, { + templateId: "schema-hash-upgrade-defaulted-unique-template", + reconciled: store.reconciledSchema, + }); + + expect( + current.nodes["UpgradeFolder"]?.uniqueConstraints.find( + (constraint) => constraint.name === "folder_name", + ), + ).toMatchObject({ scope: "kindWithSubClasses", collation: "binary" }); + expect(earlier.schemaHash).not.toBe( + await getSchemaHash(representativeGraph, 1), + ); + expect(result.status).toBe("unchanged"); + expect(row.version).toBe(1); + expect(row.schema_doc).toBe(earlier.schemaDoc); + expect(row.schema_hash).toBe(earlier.schemaHash); + expect(store.reconciledSchema.hash).toBe( + await getSchemaHash(representativeGraph, 1), + ); + expect(template.schemaHash).toBe(store.reconciledSchema.hash); + }); + // MUTATION CHECK: dropping the unique-constraint defaults from + // `canonicalStoredSchemaDocument` hashes the earlier row differently from + // today's serialization of the same graph: the diff still calls the reopen + // unchanged, but `registerGraphTemplate` throws + // GRAPH_TEMPLATE_RECONCILED_HASH_MISMATCH. + + it("keeps reaching the claims it wrote for an omitted scope at the component axis", async () => { + const handle = sqliteHandle(); + // Seeded through the stated scope, independent of how this release reads + // an omitted one: the `UpgradeDog` claim sits at the component axis, + // `UpgradeAnimal`, where the earlier release wrote it. + const [before] = await createAdapterStoreWithSchema( + componentScopedHierarchyGraph, + handle.backend, + ); + await before.nodes.UpgradeDog.create({ name: "rex" }); + const earlier = await storeEarlierReleaseRow( + handle, + defaultedHierarchyGraph, + withoutEnforcementOptions( + serializeSchema(componentScopedHierarchyGraph, 1), + ), + ); + + const [store, result] = await createAdapterStoreWithSchema( + defaultedHierarchyGraph, + handle.backend, + ); + const row = requireDefined( + await handle.backend.getActiveSchema(defaultedHierarchyGraph.id), + ); + + expect(result.status).toBe("unchanged"); + expect(row.schema_doc).toBe(earlier.schemaDoc); + await expect( + store.nodes.UpgradeDog.create({ name: "rex" }), + ).rejects.toBeInstanceOf(UniquenessError); + await expect( + store.nodes.UpgradeAnimal.create({ name: "rex" }), + ).rejects.toBeInstanceOf(UniquenessError); + await store.nodes.UpgradeAnimal.create({ name: "fido" }); + expect(await store.verifyConstraintFences()).toEqual([]); + }); + // MUTATION CHECK: reading an omitted node scope as `"kind"` in + // `NODE_UNIQUE_CONSTRAINT_DEFAULTS` still reopens unchanged, but the + // `UpgradeDog` probe no longer reaches the seeded claim at `UpgradeAnimal`, + // and the same-kind `UpgradeDog` duplicate is accepted. + + it("plans and revalidates a candidate merge review against it", async () => { + const handle = sqliteHandle(); + await createAdapterStoreWithSchema(equivalenceGraph, handle.backend, { + history: true, + }); + await storeEarlierReleaseRow(handle, equivalenceGraph); + const [target] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + { history: true }, + ); + const writeSet = { + formatVersion: 1 as const, + sourceId: "source", + target: await captureCandidateWriteSetTarget(target), + nodes: [ + { + kind: "UpgradeKennel", + id: "candidate", + properties: {}, + validFrom: "2026-01-01T00:00:00.000Z", + }, + ], + edges: [], + }; + const args = { + target, + makeBackend: () => Promise.resolve(createLocalSqliteBackend().backend), + writeSet, + policy: { id: "schema-hash-upgrade-policy", context: {} }, + }; + + const planned = await planCandidateWriteSetReview(args); + if (isErr(planned)) throw planned.error; + const revalidated = await revalidateCandidateWriteSetReview({ + ...args, + review: planned.data, + }); + + expect(isErr(revalidated)).toBe(false); + }); + // MUTATION CHECK: reading `activeSchema.schema_hash` in + // `captureMergePlanTargetFence` (merge.ts) makes revalidation refuse with + // `incompatible-plan`. +}); + +/** Rewrites a registered template to what an earlier release registered. */ +async function storeEarlierReleaseTemplate( + handle: SqliteHandle, + templateId: string, + graph: GraphDef, +): Promise> { + const document = earlierReleaseDocument(serializeSchema(graph, 1)); + const schemaDocument = JSON.stringify(document); + const schemaHash = await computeSchemaHash(document); + handle.client + .prepare( + "UPDATE typegraph_graph_templates SET schema_doc = ?, schema_hash = ? WHERE template_id = ?", + ) + .run(schemaDocument, schemaHash, templateId); + return { schemaDoc: schemaDocument, schemaHash }; +} + +function storedTemplateRow( + handle: SqliteHandle, + templateId: string, +): Readonly<{ schema_doc: string; schema_hash: string }> { + return handle.client + .prepare( + "SELECT schema_doc, schema_hash FROM typegraph_graph_templates WHERE template_id = ?", + ) + .get(templateId) as Readonly<{ schema_doc: string; schema_hash: string }>; +} + +describe("a graph template an earlier release registered", () => { + it("registers again under its stored key without rewriting it, and instantiates a target", async () => { + const handle = sqliteHandle(); + const [source] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + ); + await registerGraphTemplate(handle.backend, { + templateId: "earlier-release-template", + reconciled: source.reconciledSchema, + }); + const earlier = await storeEarlierReleaseTemplate( + handle, + "earlier-release-template", + equivalenceGraph, + ); + + const template = await registerGraphTemplate(handle.backend, { + templateId: "earlier-release-template", + reconciled: source.reconciledSchema, + }); + const instantiated = await instantiateGraphTemplate(handle.backend, { + template, + graphId: "earlier-release-tenant", + }); + const targetGraph = { ...equivalenceGraph, id: "earlier-release-tenant" }; + const [target] = await createVerifiedAdapterStore( + targetGraph, + handle.backend, + ); + + expect(storedTemplateRow(handle, "earlier-release-template")).toEqual({ + schema_doc: earlier.schemaDoc, + schema_hash: earlier.schemaHash, + }); + expect(template.schemaHash).toBe(source.reconciledSchema.hash); + expect(template.registryHash).toBe(earlier.schemaHash); + expect(instantiated.reconciled.hash).toBe( + await getSchemaHash(targetGraph, 1), + ); + expect(target.reconciledSchema.hash).toBe(instantiated.reconciled.hash); + }); + + it("still refuses registering different content under the same template id", async () => { + const handle = sqliteHandle(); + const [source] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + ); + await registerGraphTemplate(handle.backend, { + templateId: "earlier-release-conflict", + reconciled: source.reconciledSchema, + }); + await storeEarlierReleaseTemplate( + handle, + "earlier-release-conflict", + changedEquivalenceGraph, + ); + + await expect( + registerGraphTemplate(handle.backend, { + templateId: "earlier-release-conflict", + reconciled: source.reconciledSchema, + }), + ).rejects.toMatchObject({ + details: { code: "GRAPH_TEMPLATE_CONTENT_CONFLICT" }, + }); + }); + + it("instantiates idempotently onto a target an earlier release instantiated, leaving its row as written", async () => { + const handle = sqliteHandle(); + const [source] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + ); + const template = await registerGraphTemplate(handle.backend, { + templateId: "earlier-release-target-template", + reconciled: source.reconciledSchema, + }); + const targetGraph = { ...equivalenceGraph, id: "earlier-release-target" }; + await instantiateGraphTemplate(handle.backend, { + template, + graphId: targetGraph.id, + }); + const earlier = await storeEarlierReleaseRow(handle, targetGraph); + + const again = await instantiateGraphTemplate(handle.backend, { + template, + graphId: targetGraph.id, + }); + const row = requireDefined( + await handle.backend.getActiveSchema(targetGraph.id), + ); + + expect(again.reconciled.hash).toBe(await getSchemaHash(targetGraph, 1)); + expect(again.schema.schema_hash).toBe(earlier.schemaHash); + expect(row.schema_doc).toBe(earlier.schemaDoc); + expect(row.schema_hash).toBe(earlier.schemaHash); + }); + + it("refuses instantiating onto a target holding a different schema", async () => { + const handle = sqliteHandle(); + const [source] = await createAdapterStoreWithSchema( + equivalenceGraph, + handle.backend, + ); + const template = await registerGraphTemplate(handle.backend, { + templateId: "earlier-release-other-target-template", + reconciled: source.reconciledSchema, + }); + const targetGraph = { ...equivalenceGraph, id: "earlier-release-other" }; + await instantiateGraphTemplate(handle.backend, { + template, + graphId: targetGraph.id, + }); + await storeEarlierReleaseRow(handle, { + ...changedEquivalenceGraph, + id: targetGraph.id, + }); + + await expect( + instantiateGraphTemplate(handle.backend, { + template, + graphId: targetGraph.id, + }), + ).rejects.toMatchObject({ + details: { code: "GRAPH_TEMPLATE_INSTANTIATION_REFUSED" }, + }); + }); +}); diff --git a/packages/typegraph/tests/schema-introspector.test.ts b/packages/typegraph/tests/schema-introspector.test.ts index e38736c9f..cffc19766 100644 --- a/packages/typegraph/tests/schema-introspector.test.ts +++ b/packages/typegraph/tests/schema-introspector.test.ts @@ -454,6 +454,94 @@ describe("getFieldTypeInfo - caching", () => { }); }); +// ============================================================ +// kindsDivergingFromSubsumingDeclaration +// ============================================================ + +function subsumedBy(parentKind: string, ...childKinds: readonly string[]) { + return (childKind: string, candidate: string) => + candidate === parentKind && childKinds.includes(childKind); +} + +describe("kindsDivergingFromSubsumingDeclaration", () => { + const vector = z.object({ vector: embedding(3).optional() }); + const plainVector = z.object({ vector: z.array(z.number()).optional() }); + + it("names a subclass that drops the subsuming kind's embedding tag, and the builder refuses the same alias", () => { + const introspector = createSchemaIntrospector( + new Map([ + ["Doc", { schema: vector }], + ["Memo", { schema: plainVector }], + ["Note", { schema: z.object({ text: z.string() }) }], + ]), + undefined, + subsumedBy("Doc", "Memo", "Note"), + ); + const kinds = ["Doc", "Memo", "Note"]; + + expect( + introspector.kindsDivergingFromSubsumingDeclaration(kinds, "vector"), + ).toEqual(["Memo"]); + expect( + introspector.getSharedFieldTypeInfo(kinds, "vector"), + ).toBeUndefined(); + }); + + it("names a subclass that adds an embedding tag the subsuming kind lacks", () => { + const introspector = createSchemaIntrospector( + new Map([ + ["Doc", { schema: plainVector }], + ["Memo", { schema: vector }], + ]), + undefined, + subsumedBy("Doc", "Memo"), + ); + + expect( + introspector.kindsDivergingFromSubsumingDeclaration( + ["Doc", "Memo"], + "vector", + ), + ).toEqual(["Memo"]); + }); + + it("names nothing when every declaration keeps the tag, or a kind omits the field", () => { + const introspector = createSchemaIntrospector( + new Map([ + ["Doc", { schema: vector }], + ["Memo", { schema: vector }], + ["Note", { schema: z.object({ text: z.string() }) }], + ]), + undefined, + subsumedBy("Doc", "Memo", "Note"), + ); + + expect( + introspector.kindsDivergingFromSubsumingDeclaration( + ["Doc", "Memo", "Note"], + "vector", + ), + ).toEqual([]); + }); + + it("holds equivalent kinds that disagree on the tag against each other", () => { + const introspector = createSchemaIntrospector( + new Map([ + ["Tagged", { schema: vector }], + ["Plain", { schema: plainVector }], + ]), + undefined, + (childKind, candidate) => childKind !== candidate, + ); + + expect( + introspector + .kindsDivergingFromSubsumingDeclaration(["Tagged", "Plain"], "vector") + .toSorted(), + ).toEqual(["Plain", "Tagged"]); + }); +}); + // ============================================================ // getSharedFieldTypeInfo // ============================================================ diff --git a/packages/typegraph/tests/schema-kind-emptiness.test.ts b/packages/typegraph/tests/schema-kind-emptiness.test.ts index dde14a8d1..80ba62748 100644 --- a/packages/typegraph/tests/schema-kind-emptiness.test.ts +++ b/packages/typegraph/tests/schema-kind-emptiness.test.ts @@ -1,12 +1,16 @@ import { PGlite } from "@electric-sql/pglite"; import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { z } from "zod"; import { defineEdge, defineGraph, defineNode } from "../src"; import { projectBackendWithout } from "../src/backend/derive-backend"; import { generatePostgresDDL } from "../src/backend/drizzle/ddl"; import { createPostgresBackend } from "../src/backend/postgres"; +import { + countSchemaKindRows, + schemaKindHasRows, +} from "../src/backend/schema-kind-emptiness"; import { createLocalSqliteBackend } from "../src/backend/sqlite/local"; import type { GraphBackend } from "../src/backend/types"; import { initializeSchema } from "../src/schema/manager"; @@ -97,3 +101,73 @@ describe("schema kind emptiness", () => { } }); }); + +const PROBE_GRAPH_ID = "emptiness_probe"; + +function spyBackend(rowCount: number) { + return { + countNodesByKind: vi.fn(() => Promise.resolve(rowCount)), + countEdgesByKind: vi.fn(() => Promise.resolve(rowCount)), + }; +} + +describe("schemaKindHasRows", () => { + it("asks a node count to stop at one row", async () => { + const backend = spyBackend(1); + + const hasRows = await schemaKindHasRows(backend, PROBE_GRAPH_ID, { + entity: "node", + kind: "Person", + rows: "nonDeleted", + }); + + expect(hasRows).toBe(true); + expect(backend.countNodesByKind).toHaveBeenCalledExactlyOnceWith({ + graphId: PROBE_GRAPH_ID, + kind: "Person", + excludeDeleted: true, + temporalMode: "includeEnded", + limit: 1, + }); + expect(backend.countEdgesByKind).not.toHaveBeenCalled(); + }); + + it("asks an edge count to stop at one row, over every row when told to", async () => { + const backend = spyBackend(0); + + const hasRows = await schemaKindHasRows(backend, PROBE_GRAPH_ID, { + entity: "edge", + kind: "knows", + rows: "all", + }); + + expect(hasRows).toBe(false); + expect(backend.countEdgesByKind).toHaveBeenCalledExactlyOnceWith({ + graphId: PROBE_GRAPH_ID, + kind: "knows", + excludeDeleted: false, + temporalMode: "includeTombstones", + limit: 1, + }); + }); +}); + +describe("countSchemaKindRows", () => { + it("sends no limit unless one is given", async () => { + const backend = spyBackend(7); + + const count = await countSchemaKindRows(backend, PROBE_GRAPH_ID, { + entity: "node", + kind: "Person", + rows: "all", + }); + + expect(count).toBe(7); + expect(backend.countNodesByKind).toHaveBeenCalledExactlyOnceWith({ + graphId: PROBE_GRAPH_ID, + kind: "Person", + excludeDeleted: false, + temporalMode: "includeTombstones", + }); + }); +}); diff --git a/packages/typegraph/tests/schema-legacy-composition-upgrade.test.ts b/packages/typegraph/tests/schema-legacy-composition-upgrade.test.ts new file mode 100644 index 000000000..777c88703 --- /dev/null +++ b/packages/typegraph/tests/schema-legacy-composition-upgrade.test.ts @@ -0,0 +1,157 @@ +/** + * A stored document carrying the three-part `partOf(part, whole)` relation + * (no `via`) can be moved forward by every schema-managed path: declaring + * the realizing edge, or dropping the relation. + * + * The stored side of a diff is a delta input, never a registry a store reads + * or writes through, so its composition shape is not enforced. A LOAD of + * that document still refuses (`schema-composition-load.test.ts`). + * + * LOAD-BEARING CHECK: restoring the stored-side + * `ONTOLOGY_COMPOSITION_VIA_REQUIRED` refusal in `buildValidatedKindRegistry` + * makes every case here throw that `ConfigurationError`; classifying the + * removal of a relation that realizes nothing as `breaking` again makes the + * two `createStoreWithSchema` cases refuse with `breaking-change`. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, + type GraphBackend, + partOf, +} from "../src"; +import { + getActiveSchema, + getSchemaChanges, + migrateSchema, +} from "../src/schema"; +import { computeSchemaHash, serializeSchema } from "../src/schema/serializer"; +import { type SerializedSchema } from "../src/schema/types"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const GRAPH_ID = "legacy_composition_upgrade"; +const INITIAL_VERSION = 1; + +const Book = defineNode("Book", { schema: z.object({ title: z.string() }) }); +const Chapter = defineNode("Chapter", { + schema: z.object({ title: z.string() }), +}); +const inBook = defineEdge("inBook", { schema: z.object({}) }); + +const nodes = { Book: { type: Book }, Chapter: { type: Chapter } }; +const edges = { + inBook: { + type: inBook, + from: [Chapter], + to: [Book], + cardinality: "one", + }, +} as const; + +const withoutRelation = defineGraph({ id: GRAPH_ID, nodes, edges }); +const withVia = defineGraph({ + id: GRAPH_ID, + nodes, + edges, + ontology: [partOf(Chapter, Book, { via: inBook })], +}); + +const LEGACY_RELATION = { metaEdge: "partOf", from: "Chapter", to: "Book" }; + +/** The document the previous release wrote for `partOf(Chapter, Book)`. */ +function legacyDocument(): SerializedSchema { + const base = serializeSchema(withoutRelation, INITIAL_VERSION); + return { + ...base, + ontology: { ...base.ontology, relations: [LEGACY_RELATION] }, + }; +} + +async function backendWithLegacyDocument(): Promise { + const backend = createTestBackend(); + const schemaDocument = legacyDocument(); + await backend.commitSchemaVersion({ + graphId: GRAPH_ID, + expected: { kind: "initial" }, + version: INITIAL_VERSION, + schemaHash: await computeSchemaHash(schemaDocument), + schemaDoc: schemaDocument, + }); + return backend; +} + +async function activeRelations(backend: GraphBackend): Promise { + const active = requireDefined(await getActiveSchema(backend, GRAPH_ID)); + return active.ontology.relations; +} + +describe("a stored partOf relation with no `via` upgrades through every schema path", () => { + it("getSchemaChanges reports declaring the realizing edge instead of throwing", async () => { + const backend = await backendWithLegacyDocument(); + + const diff = requireDefined(await getSchemaChanges(backend, withVia)); + + console.log("legacy -> via diff:", JSON.stringify(diff.ontology)); + expect( + diff.ontology.map((change) => [change.type, change.severity]), + ).toEqual([ + ["removed", "safe"], + ["added", "warning"], + ]); + expect(diff.hasBreakingChanges).toBe(false); + }); + + it("getSchemaChanges reports dropping the relation as safe", async () => { + const backend = await backendWithLegacyDocument(); + + const diff = requireDefined( + await getSchemaChanges(backend, withoutRelation), + ); + + console.log("legacy -> removed diff:", JSON.stringify(diff.ontology)); + expect( + diff.ontology.map((change) => [change.type, change.severity]), + ).toEqual([["removed", "safe"]]); + }); + + it.each([ + ["declares the realizing edge", withVia], + ["drops the relation", withoutRelation], + ] as const)( + "createStoreWithSchema migrates when the graph %s", + async (_label, graph) => { + const backend = await backendWithLegacyDocument(); + + const [, result] = await createStoreWithSchema(graph, backend); + + console.log("createStoreWithSchema:", result.status); + expect(result.status).toBe("migrated"); + expect(await activeRelations(backend)).toEqual( + serializeSchema(graph, INITIAL_VERSION).ontology.relations, + ); + }, + ); + + it.each([ + ["declares the realizing edge", withVia], + ["drops the relation", withoutRelation], + ] as const)( + "migrateSchema commits when the graph %s", + async (_label, graph) => { + const backend = await backendWithLegacyDocument(); + + const version = await migrateSchema(backend, graph, INITIAL_VERSION); + + console.log("migrateSchema committed version:", version); + expect(version).toBe(INITIAL_VERSION + 1); + expect(await activeRelations(backend)).toEqual( + serializeSchema(graph, INITIAL_VERSION).ontology.relations, + ); + }, + ); +}); diff --git a/packages/typegraph/tests/schema-parse-validation.test.ts b/packages/typegraph/tests/schema-parse-validation.test.ts index 5fdb7b180..a8c8df4fc 100644 --- a/packages/typegraph/tests/schema-parse-validation.test.ts +++ b/packages/typegraph/tests/schema-parse-validation.test.ts @@ -117,11 +117,13 @@ describe("serializedSchemaZod", () => { const parsed = parseSerializedSchema(JSON.stringify(document)); + // The scope the store enforced for the declaration that wrote this + // document: any scope but "kind" is subclass-wide. expect(parsed.nodes["Person"]?.uniqueConstraints).toEqual([ { name: "email_unique", fields: ["email"], - scope: "kind", + scope: "kindWithSubClasses", collation: "binary", }, ]); diff --git a/packages/typegraph/tests/schema-serialization.test.ts b/packages/typegraph/tests/schema-serialization.test.ts index ff6a1ce3d..033ef28ba 100644 --- a/packages/typegraph/tests/schema-serialization.test.ts +++ b/packages/typegraph/tests/schema-serialization.test.ts @@ -209,6 +209,27 @@ describe("serializeSchema", () => { ); }); + it("serializes a metaEdges catalog entry with only name and description", () => { + // Pins `SerializedMetaEdge`'s post-removal shape: `transitive`, + // `symmetric`, `reflexive`, `inverse`, and `inference` are gone, and + // nothing should silently start re-emitting them. + const graph = defineGraph({ + id: "test_graph", + nodes: { + Organization: { type: Organization }, + Company: { type: Company }, + }, + edges: {}, + ontology: [subClassOf(Company, Organization)], + }); + + const serialized = serializeSchema(graph, 1); + + expect( + Object.keys(serialized.ontology.metaEdges["subClassOf"] ?? {}), + ).toEqual(["name", "description"]); + }); + it("serializes uniqueness constraints", () => { const graph = defineGraph({ id: "test_graph", @@ -589,9 +610,11 @@ describe("computeSchemaDiff", () => { serializeSchema(newGraph, 2), ); - // Adding subClassOf relation adds both the meta-edge and the relation + // Adding a subClassOf relation is one relation-level ontology change. + // `SerializedOntology.metaEdges` is derived state (see its docblock) + // and is not an independent classification input. const addedItems = diff.ontology.filter((o) => o.type === "added"); - expect(addedItems.length).toBeGreaterThanOrEqual(1); - expect(addedItems.some((o) => o.entity === "relation")).toBe(true); + expect(addedItems).toHaveLength(1); + expect(addedItems[0]).toMatchObject({ entity: "relation" }); }); }); diff --git a/packages/typegraph/tests/schema-target-cardinality.test.ts b/packages/typegraph/tests/schema-target-cardinality.test.ts new file mode 100644 index 000000000..00f6f4421 --- /dev/null +++ b/packages/typegraph/tests/schema-target-cardinality.test.ts @@ -0,0 +1,244 @@ +/** + * Schema serialization, hashing, introspection and diffing for + * `targetCardinality` (issue #610). + */ +import { sql } from "drizzle-orm"; +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, +} from "../src"; +import { createSqliteBackend } from "../src/backend/drizzle/sqlite"; +import { + computeSchemaDiff, + computeSchemaHash, + deserializeSchema, + serializeSchema, +} from "../src/schema"; +import { ensureSchema, parseSerializedSchema } from "../src/schema/manager"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend, createTestDatabase } from "./test-utils"; + +const Person = defineNode("SchemaTcPerson", { schema: z.object({}) }); +const knows = defineEdge("schemaTcKnows", { schema: z.object({}) }); + +function buildGraph(targetCardinality?: "many" | "one" | "oneActive") { + return defineGraph({ + id: "schema_target_cardinality", + nodes: { SchemaTcPerson: { type: Person } }, + edges: { + schemaTcKnows: { + type: knows, + from: [Person], + to: [Person], + ...(targetCardinality === undefined ? {} : { targetCardinality }), + }, + }, + }); +} + +describe("targetCardinality serialization", () => { + it("serializes a non-default declared value", () => { + const serialized = serializeSchema(buildGraph("one"), 1); + expect(serialized.edges["schemaTcKnows"]?.targetCardinality).toBe("one"); + }); + + it("omits the key when undeclared", () => { + const serialized = serializeSchema(buildGraph(), 1); + expect(serialized.edges["schemaTcKnows"]).not.toHaveProperty( + "targetCardinality", + ); + }); + + it("omits the key when explicitly declared as the default", () => { + const serialized = serializeSchema(buildGraph("many"), 1); + expect(serialized.edges["schemaTcKnows"]).not.toHaveProperty( + "targetCardinality", + ); + }); + // MUTATION CHECK (verified): replace the conditional spread in + // `serializeEdgeDef` (`src/schema/serializer.ts`) with an unconditional + // `targetCardinality: registration.targetCardinality ?? "many"`. Both + // omission tests above fail — the key is written for every edge kind. + + it("round-trips through deserializeSchema", () => { + const serialized = serializeSchema(buildGraph("oneActive"), 1); + const deserialized = deserializeSchema(serialized); + const edge = deserialized.getEdge("schemaTcKnows"); + expect(edge?.targetCardinality).toBe("oneActive"); + }); + + it("loads a document with no targetCardinality key as many", () => { + // A graph that never declares the option already serializes without the + // key (see above) — this IS the shape a stored document from before target-side cardinality has. + // Round-trip it through the same zod parse a database read goes + // through — `.default("many")` is what resolves the absent key instead + // of dropping the declaration silently. + const serialized = serializeSchema(buildGraph(), 1); + expect(serialized.edges["schemaTcKnows"]).not.toHaveProperty( + "targetCardinality", + ); + const parsed = parseSerializedSchema(JSON.stringify(serialized)); + const deserialized = deserializeSchema(parsed); + expect(deserialized.getEdge("schemaTcKnows")?.targetCardinality).toBe( + "many", + ); + }); + // MUTATION CHECK (verified): remove `.default("many")` from the + // `targetCardinality` line in `serializedSchemaZod` (`src/schema/types.ts`). + // This test fails with `targetCardinality` reading `undefined`. +}); + +describe("targetCardinality and the schema hash", () => { + it("hashes differently when only targetCardinality differs", async () => { + const hashMany = await computeSchemaHash(serializeSchema(buildGraph(), 1)); + const hashOne = await computeSchemaHash( + serializeSchema(buildGraph("one"), 1), + ); + expect(hashOne).not.toBe(hashMany); + }); + + it("hashes identically for two schemas with the same targetCardinality", async () => { + const first = await computeSchemaHash( + serializeSchema(buildGraph("one"), 1), + ); + const second = await computeSchemaHash( + serializeSchema(buildGraph("one"), 1), + ); + expect(first).toBe(second); + }); + // MUTATION CHECK (verified): drop `targetCardinality` from + // `serializeEdgeDef` (`src/schema/serializer.ts`). Both hash tests above + // fail — the "differs" test because the two hashes become equal. +}); + +describe("targetCardinality introspection", () => { + it("reports the declared value", async () => { + const [store] = await createStoreWithSchema( + buildGraph("one"), + createTestBackend(), + ); + const edge = store + .introspect() + .edges.find((candidate) => candidate.name === "schemaTcKnows"); + expect(edge?.targetCardinality).toBe("one"); + }); + + it("reports many when undeclared", async () => { + const [store] = await createStoreWithSchema( + buildGraph(), + createTestBackend(), + ); + const edge = store + .introspect() + .edges.find((candidate) => candidate.name === "schemaTcKnows"); + expect(edge?.targetCardinality).toBe("many"); + }); + // MUTATION CHECK (verified): drop the `targetCardinality: reg + // .targetCardinality ?? "many"` line from `introspectSchema` + // (`src/store/introspect.ts`). Both tests above fail with + // `edge?.targetCardinality` reading `undefined`. +}); + +describe("targetCardinality diffing", () => { + it("classifies a targetCardinality change as a modified/warning edge change", () => { + const before = serializeSchema(buildGraph(), 1); + const after = serializeSchema(buildGraph("one"), 2); + const diff = computeSchemaDiff(before, after); + const change = diff.edges.find( + (candidate) => candidate.kind === "schemaTcKnows", + ); + expect(change).toBeDefined(); + expect(change?.type).toBe("modified"); + expect(change?.severity).toBe("warning"); + expect(change?.details).toContain("Target cardinality"); + }); + + it("reports no edge change when targetCardinality is unchanged", () => { + const before = serializeSchema(buildGraph("one"), 1); + const after = serializeSchema(buildGraph("one"), 2); + const diff = computeSchemaDiff(before, after); + expect( + diff.edges.some((candidate) => candidate.kind === "schemaTcKnows"), + ).toBe(false); + }); + // MUTATION CHECK (verified): remove the `targetCardinality` sibling block + // from `diffEdgeDef` (`src/schema/migration.ts`). The first test above + // fails (`change` is `undefined`). +}); + +describe("targetCardinality byte-identical serialization", () => { + // Golden value captured by running this exact graph (Person node, + // `schemaTcKnows` edge, both undeclared cardinality axes) through + // `serializeSchema` + `computeSchemaHash` on + // `origin/integration/ontology-semantics` @ 4eba11a2 — the commit this + // branch is based on, before `targetCardinality` existed at all. A graph + // that never declares the option must keep producing this exact document + // and hash forever, or every deployment that predates this feature pays a + // schema-hash mismatch (and the serialize-and-diff walk that follows) on + // its next boot. + const PRE_TARGET_CARDINALITY_HASH = "2babef64196c95c1"; + + it("hashes identically to a document produced before targetCardinality existed", async () => { + const serialized = serializeSchema(buildGraph(), 1); + expect(serialized.edges["schemaTcKnows"]).not.toHaveProperty( + "targetCardinality", + ); + const hash = await computeSchemaHash(serialized); + expect(hash).toBe(PRE_TARGET_CARDINALITY_HASH); + }); + // MUTATION CHECK (verified): revert `serializeEdgeDef` + // (`src/schema/serializer.ts`) to write `targetCardinality` + // unconditionally. The computed hash no longer matches the golden value + // above. +}); + +describe("targetCardinality and ensureSchema's hash short-circuit", () => { + // `serializeEdgeDef` omits `targetCardinality` for a graph that never + // declares it (see above), so a schema document stored before this + // release hashes identically to the one `ensureSchema` computes for the + // same graph today. That means the fast hash-equality check at the top of + // `ensureSchema` (`src/schema/manager.ts`) reports `"unchanged"` WITHOUT + // ever calling `computeSchemaDiff` — a pre-existing deployment that never + // uses this option pays no extra cost booting against this release. + it("reports unchanged via the fast hash-equality path for a document missing the key", async () => { + const db = createTestDatabase(); + const backend = createSqliteBackend(db); + const graph = buildGraph(); // targetCardinality left undeclared ("many") + + const currentDocument = serializeSchema(graph, 1); + expect(currentDocument.edges["schemaTcKnows"]).not.toHaveProperty( + "targetCardinality", + ); + const preD1Hash = await computeSchemaHash(currentDocument); + + db.run(sql` + INSERT INTO typegraph_schema_versions + (graph_id, version, schema_hash, schema_doc, created_at, is_active) + VALUES ( + ${graph.id}, 1, ${preD1Hash}, + ${JSON.stringify(currentDocument)}, + '2026-01-01T00:00:00.000Z', 1 + ) + `); + + const result = await ensureSchema(backend, graph); + expect(result.status).toBe("unchanged"); + + const rows = db.all(sql` + SELECT schema_hash FROM typegraph_schema_versions + WHERE graph_id = ${graph.id} AND version = 1 + `) as readonly Readonly<{ schema_hash: string }>[]; + expect(requireDefined(rows[0]).schema_hash).toBe(preD1Hash); + }); + // MUTATION CHECK (verified): with `targetCardinalityZod`'s `.default("many")` + // (`src/schema/types.ts`) temporarily removed, the stripped document's + // parsed `targetCardinality` reads `undefined` instead of `"many"`, the diff + // against the current graph's `"many"` is no longer empty, and this test + // fails with `result.status` reading `"migrated"` (or throwing, depending + // on `throwOnBreaking`) instead of `"unchanged"`. +}); diff --git a/packages/typegraph/tests/search-filter-pushdown.test.ts b/packages/typegraph/tests/search-filter-pushdown.test.ts index 38151e0c8..4534dccbd 100644 --- a/packages/typegraph/tests/search-filter-pushdown.test.ts +++ b/packages/typegraph/tests/search-filter-pushdown.test.ts @@ -1,7 +1,7 @@ /** * Facade search FILTER PUSHDOWN: `store.search.{vector,fulltext,hybrid}` * accept a `where` predicate, - * `offset` pagination, and `includeSubClasses` — all compiled into the + * `offset` pagination, and `expansion` — all compiled into the * search statement's candidate set, not post-filtered. * * The pushdown is pinned BEHAVIORALLY: the corpus is built so the global @@ -141,7 +141,7 @@ const EXPIRED_ARTICLE: Seed = { validTo: "2001-01-01T00:00:00.000Z", }; -/** Sub-kind rows: near the query, `alpha`, only visible via includeSubClasses. */ +/** Sub-kind rows: near the query, `alpha`, only visible via expansion: "subclasses". */ const NOTES: readonly Seed[] = [ { id: "note-1", @@ -407,7 +407,7 @@ describe("facade search filter pushdown", () => { vectorIds(fullOrder), ); - // Sub-kind rows only participate with includeSubClasses. + // Sub-kind rows only participate with expansion: "subclasses". const baseOnly = await store.search.vector("Article", { fieldPath: FIELD_PATH, queryEmbedding: QUERY_EMBEDDING, @@ -418,7 +418,7 @@ describe("facade search filter pushdown", () => { fieldPath: FIELD_PATH, queryEmbedding: QUERY_EMBEDDING, limit: 10, - includeSubClasses: true, + expansion: "subclasses", }); expect(vectorIds(withSubs)).toEqual( expect.arrayContaining(["note-1", "note-2"]), @@ -438,7 +438,7 @@ describe("facade search filter pushdown", () => { fieldPath: FIELD_PATH, queryEmbedding: QUERY_EMBEDDING, limit: 10, - includeSubClasses: true, + expansion: "subclasses", where: (article) => article.category.eq("alpha"), }); expect(new Set(vectorIds(alphaWithSubs))).toEqual( @@ -493,7 +493,7 @@ describe("facade search filter pushdown", () => { fieldPath: FIELD_PATH, queryEmbedding: QUERY_EMBEDDING, limit: 3, - includeSubClasses: true, + expansion: "subclasses", }), ).rejects.toThrow( /declare different metrics.*search the kinds separately/s, diff --git a/packages/typegraph/tests/session-sidecar-completeness.test.ts b/packages/typegraph/tests/session-sidecar-completeness.test.ts index b4cafac3f..c6058f0b8 100644 --- a/packages/typegraph/tests/session-sidecar-completeness.test.ts +++ b/packages/typegraph/tests/session-sidecar-completeness.test.ts @@ -77,7 +77,10 @@ import { createSqlSchema } from "../src/query/compiler/schema"; import { sql } from "../src/query/sql-fragment"; import { asCompiledSelectSql } from "../src/query/sql-intent"; import { buildKindRegistry } from "../src/registry"; -import { edgeCardinalityClaim } from "../src/store/claims/edge-claims"; +import { + edgeCardinalityAxisReferences, + edgeCardinalityClaims, +} from "../src/store/claims/edge-claims"; import { planNodeCreateClaims } from "../src/store/claims/node-claims"; import { runWritePlan, @@ -109,7 +112,7 @@ function createEdgeWithPlan(session: WriteSession): Promise { toId: "fused-b", props: {}, }, - claim: undefined, + claims: [], }; const command: EdgeCreateCommand = { kind: "edge.create", @@ -339,7 +342,10 @@ function edgeInsertWork( // so a change to what a `unique` kind claims moves this fixture too. return { params, - claim: requireDefined(edgeCardinalityClaim("unique", params)), + claims: edgeCardinalityClaims( + edgeCardinalityAxisReferences({ cardinality: "unique" }), + params, + ), }; } @@ -609,6 +615,45 @@ const CASES: Record = { row: "updateNode", plan: NODE_PLAN, }, + deleteCompositionEdges: { + run: async (raw) => { + // A CONSTRAINED kind, exactly as `purgeEdge`'s fixture: the claim + // release is a statement this case can observe, which an + // unconstrained kind's edge would not exercise. + await raw.insertNode({ + graphId: GRAPH_ID, + kind: "Doc", + id: "cascade-edges", + props: documentProps("cascade-edges"), + }); + await raw.insertNode({ + graphId: GRAPH_ID, + kind: "Doc", + id: "cascade-edges-b", + props: documentProps("cascade-edges-b"), + }); + await raw.insertEdge({ + graphId: GRAPH_ID, + kind: "owns", + id: "edge-owns-cascade", + fromKind: "Doc", + fromId: "cascade-edges", + toKind: "Doc", + toId: "cascade-edges-b", + props: {}, + }); + // eslint-disable-next-line unicorn/consistent-function-scoping -- every case returns its row work the same way; hoisting this one would make the table read as if it were different. + return (session) => + session.deleteCompositionEdges(["edge-owns-cascade"], "hard"); + }, + // The composition cascade's explicit consumed-edge cleanup routes + // through the SAME batched delete `retireNode` / `purgeNode` use, even + // for a single edge id. + sidecars: ["purgeEdgeClaims"], + row: "hardDeleteEdgesBatch", + postRowClaims: ["purgeEdgeClaims"], + plan: EDGE_PLAN, + }, reviseNodeSet: { run: async (raw) => { await seed(raw, "k"); @@ -767,7 +812,7 @@ const CASES: Record = { reviseEdge: { run: async (raw) => { await seed(raw, "p"); - const work = { id: "edge-p", props: {} }; + const work = { id: "edge-p", props: {}, claims: [] }; return (session) => session.reviseEdge(work, { validityLowerBound: {}, diff --git a/packages/typegraph/tests/set-operations-compiler.test.ts b/packages/typegraph/tests/set-operations-compiler.test.ts index 0beec9c47..79338f36d 100644 --- a/packages/typegraph/tests/set-operations-compiler.test.ts +++ b/packages/typegraph/tests/set-operations-compiler.test.ts @@ -50,7 +50,7 @@ function createMinimalAst(alias: string, extraFields: string[] = []): QueryAst { start: { alias, kinds: ["TestKind"], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [], predicates: [], @@ -345,7 +345,7 @@ describe("compileSetOperation", () => { it("throws when ordering with SELECT * (empty projection)", () => { // Create a set operation with empty projection (SELECT *) const emptyProjectionAst: QueryAst = { - start: { alias: "a", kinds: ["TestKind"], includeSubClasses: false }, + start: { alias: "a", kinds: ["TestKind"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { fields: [] }, // SELECT * @@ -367,7 +367,7 @@ describe("compileSetOperation", () => { it("normalizes equivalent field representations for matching", () => { // Create projection with path: ["props"] + jsonPointer const projectionWithPointer: QueryAst = { - start: { alias: "a", kinds: ["TestKind"], includeSubClasses: false }, + start: { alias: "a", kinds: ["TestKind"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { diff --git a/packages/typegraph/tests/set-operations-emitter.test.ts b/packages/typegraph/tests/set-operations-emitter.test.ts index b32ba61ce..e0934555c 100644 --- a/packages/typegraph/tests/set-operations-emitter.test.ts +++ b/packages/typegraph/tests/set-operations-emitter.test.ts @@ -26,7 +26,7 @@ function createSetOperationPlan(): LogicalPlan { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"], }, temporalMode: { mode: "current" as const }, @@ -80,7 +80,7 @@ describe("emitSetOperationQuerySql", () => { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"], }, temporalMode: { mode: "current" as const }, @@ -113,7 +113,7 @@ describe("emitSetOperationQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }; @@ -194,7 +194,7 @@ describe("emitSetOperationQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }; diff --git a/packages/typegraph/tests/set-operations-validation.test.ts b/packages/typegraph/tests/set-operations-validation.test.ts index 2a2bb4fc1..4a15fe9e7 100644 --- a/packages/typegraph/tests/set-operations-validation.test.ts +++ b/packages/typegraph/tests/set-operations-validation.test.ts @@ -31,7 +31,7 @@ function createMinimalAst( start: { alias, kinds: ["TestKind"], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [], predicates: [], diff --git a/packages/typegraph/tests/smart-select.test.ts b/packages/typegraph/tests/smart-select.test.ts index 246fb4ea5..6c2dad5e5 100644 --- a/packages/typegraph/tests/smart-select.test.ts +++ b/packages/typegraph/tests/smart-select.test.ts @@ -195,8 +195,9 @@ describe("createTrackingContext", () => { const mockState: QueryBuilderState = { startAlias: "p", startKinds: ["Person"], + startRequestedKinds: ["Person"], currentAlias: "p", - includeSubClasses: false, + startExpansion: "exact", traversals: [], predicates: [], projection: [], diff --git a/packages/typegraph/tests/snapshot-fence-retry.test.ts b/packages/typegraph/tests/snapshot-fence-retry.test.ts new file mode 100644 index 000000000..127c2a2d3 --- /dev/null +++ b/packages/typegraph/tests/snapshot-fence-retry.test.ts @@ -0,0 +1,325 @@ +/** + * Who retries when the per-graph write fence refuses a stale snapshot. + * + * A write that makes a lock-only decision advances the graph's fence row, so + * a session at a snapshot isolation whose snapshot predates another decider's + * commit fails there with a serialization failure. What happens next depends + * on who owns the transaction: + * + * - a write the STORE opened is replayed whole through `runRetriedUnit` — a + * new transaction is a new snapshot — within `OPTIMISTIC_RETRY_ATTEMPTS`; + * - a write inside a transaction the CALLER owns is not: the caller's earlier + * reads are part of the unit, so the refusal propagates as a + * `TransactionConflictError` for the caller to re-run from the top; + * - a serialized engine takes no keyed acquisition, so it is never a retried + * unit and pays nothing for one; + * - a write that names no constraint decides nothing under the fence and never + * advances the fence row, so it is not a retried unit either. + * + * Two real connections cannot overlap in-process, so the refusal is injected + * at the acquisition statement itself. `tests/backends/postgres/ + * mixed-isolation-fence.test.ts` drives the same decision with a genuine + * stale snapshot on a server. + * + * MUTATION CHECK: answering `false` from `opensKeyedFenceTransaction` fails + * the four store-owned cases (one transaction is opened and the conflict + * propagates) — verified and reverted. Rethrowing the driver error unchanged + * from `acquiringGraphWriteFence` fails the caller-owned case's in-callback + * assertion — verified and reverted. Opening the bulk get-or-create's + * transaction with `backend.transaction` directly fails its case — verified + * and reverted. Dropping the `fenceReason !== undefined` term from + * `ownsSnapshotFencedUnit` fails the unconstrained case — verified and + * reverted. + */ +import { PGlite } from "@electric-sql/pglite"; +import { drizzle as drizzlePglite } from "drizzle-orm/pglite"; +import { afterEach, describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + asEdgeId, + createStore, + defineEdge, + defineGraph, + defineNode, + partOf, + TransactionConflictError, +} from "../src"; +import { OPTIMISTIC_RETRY_ATTEMPTS } from "../src/backend/capabilities/retried-unit"; +import { deriveBackend } from "../src/backend/derive-backend"; +import { generateVectorlessPostgresMigrationSQL } from "../src/backend/drizzle/ddl"; +import { createPostgresBackend } from "../src/backend/postgres"; +import type { GraphBackend } from "../src/backend/types"; +import { renderPostgres } from "../src/query/sql-fragment"; +import { type CompiledRowsSql } from "../src/query/sql-intent"; +import { createTestBackend } from "./test-utils"; + +const Task = defineNode("Task", { schema: z.object({ name: z.string() }) }); +const dependsOn = defineEdge("dependsOn", { schema: z.object({}) }); + +const graph = defineGraph({ + id: "snapshot-fence-retry", + nodes: { Task: { type: Task } }, + edges: { + dependsOn: { type: dependsOn, from: [Task], to: [Task], acyclic: true }, + }, +}); + +const Assembly = defineNode("Assembly", { + schema: z.object({ name: z.string() }), +}); +const Component = defineNode("Component", { + schema: z.object({ slug: z.string() }), +}); +const containedIn = defineEdge("containedIn", { schema: z.object({}) }); + +const compositionGraph = defineGraph({ + id: "snapshot-fence-retry-composition", + nodes: { + Assembly: { type: Assembly }, + Component: { + type: Component, + unique: [ + { + name: "component_slug", + fields: ["slug"], + scope: "kind", + collation: "binary", + }, + ], + }, + }, + edges: { + containedIn: { + type: containedIn, + from: [Component], + to: [Assembly], + cardinality: "one", + }, + }, + ontology: [ + partOf(Component, Assembly, { via: containedIn, partSide: "from" }), + ], +}); + +const SERIALIZATION_FAILURE_SQL_STATE = "40001"; + +/** The shape a driver reports a stale-snapshot row update with. */ +function serializationFailure(): Error { + return Object.assign( + new Error("could not serialize access due to concurrent update"), + { code: SERIALIZATION_FAILURE_SQL_STATE }, + ); +} + +type Instrumented = Readonly<{ + backend: GraphBackend; + transactionsOpened: () => number; +}>; + +/** + * Counts the transactions `backend` opens and fails the first `refusals` + * statements `isRefused` selects, as a stale snapshot would. + */ +function refusing( + backend: GraphBackend, + refusals: number, + isRefused: (query: CompiledRowsSql) => boolean, +): Instrumented { + let opened = 0; + let remaining = refusals; + return { + backend: deriveBackend(backend, { + transaction: (run, options) => { + opened += 1; + return backend.transaction( + (transaction) => + run( + deriveBackend(transaction, { + execute: (query: CompiledRowsSql) => { + if (remaining > 0 && isRefused(query)) { + remaining -= 1; + return Promise.reject(serializationFailure()); + } + return transaction.execute(query); + }, + }), + ), + options, + ); + }, + }), + transactionsOpened: () => opened, + }; +} + +/** The statement text, for selecting which statement to refuse. */ +function statementText(query: CompiledRowsSql): string { + return renderPostgres(query).sql; +} + +function isFenceAcquisition(query: CompiledRowsSql): boolean { + return statementText(query).includes("pg_advisory_xact_lock"); +} + +describe("a stale-snapshot refusal at the per-graph write fence", () => { + let client: PGlite | undefined; + + afterEach(async () => { + await client?.close(); + client = undefined; + }); + + async function postgresBackend(): Promise { + client = await PGlite.create(); + await client.exec(generateVectorlessPostgresMigrationSQL()); + return createPostgresBackend(drizzlePglite(client), { vector: false }); + } + + it("replays a write the store owns, in a new transaction", async () => { + const instrumented = refusing( + await postgresBackend(), + 1, + isFenceAcquisition, + ); + const store = createStore(graph, instrumented.backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const openedBefore = instrumented.transactionsOpened(); + + await expect(store.edges.dependsOn.create(a, b)).resolves.toBeDefined(); + + expect(instrumented.transactionsOpened() - openedBefore).toBe(2); + expect(await store.edges.dependsOn.find({})).toHaveLength(1); + }); + + it("replays a bulk upsert whole: the collection opened its transaction, so the store owns it", async () => { + const instrumented = refusing( + await postgresBackend(), + 1, + isFenceAcquisition, + ); + const store = createStore(graph, instrumented.backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const openedBefore = instrumented.transactionsOpened(); + + await expect( + store.edges.dependsOn.bulkUpsertById([ + { id: asEdgeId("a-to-b"), from: a, to: b, props: {} }, + ]), + ).resolves.toHaveLength(1); + + expect(instrumented.transactionsOpened() - openedBefore).toBe(2); + expect(await store.edges.dependsOn.find({})).toHaveLength(1); + }); + + it("replays a bulk get-or-create whose leg attaches a composition part: the collection opened its transaction", async () => { + const backend = await postgresBackend(); + const whole = await createStore( + compositionGraph, + backend, + ).nodes.Assembly.create({ name: "whole" }); + const instrumented = refusing(backend, 1, isFenceAcquisition); + const store = createStore(compositionGraph, instrumented.backend); + const openedBefore = instrumented.transactionsOpened(); + + const results = await store.nodes.Component.bulkGetOrCreateByConstraint( + "component_slug", + [{ props: { slug: "bolt" } }], + { partOf: { whole: { kind: "Assembly", id: whole.id } } }, + ); + + expect(results.map((result) => result.action)).toEqual(["created"]); + expect(instrumented.transactionsOpened() - openedBefore).toBe(2); + expect(await store.edges.containedIn.find({})).toHaveLength(1); + }); + + it("takes one attempt for a write that names no constraint: it decides nothing under the fence", async () => { + const instrumented = refusing( + await postgresBackend(), + 1, + isFenceAcquisition, + ); + // The store captures revisions, so the write takes the fence — for its + // clock, with no decision behind it and no fence-row write to be refused. + const store = createStore(graph, instrumented.backend, { + revisionTracking: true, + }); + const openedBefore = instrumented.transactionsOpened(); + + await expect(store.nodes.Task.create({ name: "a" })).rejects.toMatchObject({ + cause: { code: SERIALIZATION_FAILURE_SQL_STATE }, + }); + + expect(instrumented.transactionsOpened() - openedBefore).toBe(1); + }); + + it("gives up on a store-owned write with one typed conflict once its attempts are spent", async () => { + const instrumented = refusing( + await postgresBackend(), + Number.POSITIVE_INFINITY, + isFenceAcquisition, + ); + const store = createStore(graph, instrumented.backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const openedBefore = instrumented.transactionsOpened(); + + const attempt = store.edges.dependsOn.create(a, b); + await expect(attempt).rejects.toBeInstanceOf(TransactionConflictError); + await expect(attempt).rejects.toMatchObject({ + details: { attempts: OPTIMISTIC_RETRY_ATTEMPTS }, + cause: { code: SERIALIZATION_FAILURE_SQL_STATE }, + }); + + expect(instrumented.transactionsOpened() - openedBefore).toBe( + OPTIMISTIC_RETRY_ATTEMPTS, + ); + }); + + it("propagates a typed conflict, unreplayed, from a transaction the caller owns", async () => { + const instrumented = refusing( + await postgresBackend(), + 1, + isFenceAcquisition, + ); + const store = createStore(graph, instrumented.backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const openedBefore = instrumented.transactionsOpened(); + + let conflictSeenInside: unknown; + const attempt = store.transaction(async (tx) => { + try { + await tx.edges.dependsOn.create(a, b); + } catch (error) { + conflictSeenInside = error; + throw error; + } + }); + await expect(attempt).rejects.toBeInstanceOf(TransactionConflictError); + + // Typed at the fence itself, not only once it reaches the transaction's + // owner: the caller's callback sees the same error family. + expect(conflictSeenInside).toBeInstanceOf(TransactionConflictError); + expect(instrumented.transactionsOpened() - openedBefore).toBe(1); + expect(await store.edges.dependsOn.find({})).toEqual([]); + }); + + it("is never retried on a serialized engine, which takes no keyed acquisition", async () => { + const instrumented = refusing(createTestBackend(), 1, (query) => + statementText(query).includes("typegraph_edges"), + ); + const store = createStore(graph, instrumented.backend); + const a = await store.nodes.Task.create({ name: "a" }); + const b = await store.nodes.Task.create({ name: "b" }); + const openedBefore = instrumented.transactionsOpened(); + + await expect(store.edges.dependsOn.create(a, b)).rejects.toMatchObject({ + code: SERIALIZATION_FAILURE_SQL_STATE, + }); + + expect(instrumented.transactionsOpened() - openedBefore).toBe(1); + }); +}); diff --git a/packages/typegraph/tests/sql-errors.test.ts b/packages/typegraph/tests/sql-errors.test.ts index 9b84b9bb7..fd2ab50b2 100644 --- a/packages/typegraph/tests/sql-errors.test.ts +++ b/packages/typegraph/tests/sql-errors.test.ts @@ -29,7 +29,7 @@ import { isMissingTableError, isNotNullColumnViolation, isPostgresConcurrentDdlRaceError, - isSqliteDuplicateEdgeMatchIdentityColumnError, + isSqliteDuplicateColumnError, isSqliteMissingEdgeMatchIdentityColumnError, isSqliteNotAuthorizedError, isSqliteStaleSnapshotError, @@ -892,20 +892,31 @@ describe("edge match-identity provisioning error classifiers", () => { ); }); - it("recognizes only the two duplicate-column adoption races", () => { + it("recognizes only a duplicate-column race on a column the adoption plans", () => { + const adoptionColumns = ["match_identity_name", "match_identity_key"]; expect( - isSqliteDuplicateEdgeMatchIdentityColumnError( + isSqliteDuplicateColumnError( Object.assign(new Error("duplicate column name: match_identity_key"), { code: "SQLITE_ERROR", }), + adoptionColumns, ), ).toBe(true); expect( - isSqliteDuplicateEdgeMatchIdentityColumnError( + isSqliteDuplicateColumnError( Object.assign(new Error("duplicate column name: props"), { code: "SQLITE_ERROR", }), + adoptionColumns, ), ).toBe(false); + expect( + isSqliteDuplicateColumnError( + Object.assign(new Error("duplicate column name: restored_at"), { + code: "SQLITE_ERROR", + }), + ["restored_at"], + ), + ).toBe(true); }); }); diff --git a/packages/typegraph/tests/sql-fragment.test.ts b/packages/typegraph/tests/sql-fragment.test.ts index f04183277..bc685ccc3 100644 --- a/packages/typegraph/tests/sql-fragment.test.ts +++ b/packages/typegraph/tests/sql-fragment.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + countSqlParameters, isSqlFragment, isSqlPlaceholder, Placeholder, @@ -101,6 +102,22 @@ describe("SQL fragments", () => { }); }); + // MUTATION CHECK: composing with `chunks.push(...getChunks(value))` again + // throws "RangeError: Maximum call stack size exceeded" here — verified and + // reverted. + it("composes a fragment with more chunks than a call accepts as arguments", () => { + const rowCount = 100_000; + const rows = Array.from( + { length: rowCount }, + (_unused, index) => sql`(${index}, ${index})`, + ); + const values = sql.join(rows, sql`, `); + + const statement = sql`SELECT * FROM (VALUES ${values}) AS t`; + + expect(countSqlParameters(statement)).toBe(rowCount * 2); + }); + it("recognizes globally branded fragments from another module instance", () => { const source = sql`SELECT ${1}`; const foreignFragment = { diff --git a/packages/typegraph/tests/standard-emitter.test.ts b/packages/typegraph/tests/standard-emitter.test.ts index 934250720..6b537c541 100644 --- a/packages/typegraph/tests/standard-emitter.test.ts +++ b/packages/typegraph/tests/standard-emitter.test.ts @@ -37,7 +37,7 @@ function createBasePlan(): LogicalPlan { }, start: { alias: "p", - includeSubClasses: false, + expansion: "exact" as const, kinds: ["Person"], }, temporalMode: { mode: "current" as const }, @@ -116,7 +116,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, @@ -151,7 +151,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, @@ -216,7 +216,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, @@ -313,7 +313,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, @@ -351,7 +351,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, @@ -386,7 +386,7 @@ describe("emitStandardQuerySql", () => { }, ], }, - start: { alias: "p", includeSubClasses: false, kinds: ["Person"] }, + start: { alias: "p", expansion: "exact" as const, kinds: ["Person"] }, temporalMode: { mode: "current" as const }, traversals: [], }, diff --git a/packages/typegraph/tests/standard-pass-pipeline.test.ts b/packages/typegraph/tests/standard-pass-pipeline.test.ts index d85c54f2c..6d6c2a520 100644 --- a/packages/typegraph/tests/standard-pass-pipeline.test.ts +++ b/packages/typegraph/tests/standard-pass-pipeline.test.ts @@ -107,7 +107,7 @@ function makeSelectiveField( function makeMinimalAst(overrides: Partial = {}): QueryAst { return { - start: { alias: "p", kinds: ["Person"], includeSubClasses: false }, + start: { alias: "p", kinds: ["Person"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { diff --git a/packages/typegraph/tests/state-snapshot.ts b/packages/typegraph/tests/state-snapshot.ts index d72720271..b80753065 100644 --- a/packages/typegraph/tests/state-snapshot.ts +++ b/packages/typegraph/tests/state-snapshot.ts @@ -13,6 +13,11 @@ * the same reason (the tombstone instant differs per run; its presence is * what queries observe). Embedding tables are strategy-owned per * `(kind, field)` and are not captured here — cover them with targeted tests. + * + * {@link dumpRoundTripState} widens the snapshot for interchange laws, where + * the windows and the derived ledgers ARE the payload: each row's + * `valid_from` / `valid_to`, the edge claim rows, and the identity ledger + * (assertions, closure, separation, transition log). */ import { type GraphBackend, rowPropsToObject } from "../src/backend/types"; import type { GraphDef } from "../src/core/define-graph"; @@ -55,6 +60,11 @@ type SnapshotFulltext = Readonly<{ content: string; }>; +type RowWindow = Readonly<{ + validFrom: string | undefined; + validTo: string | undefined; +}>; + export type ObservableState = Readonly<{ nodes: readonly SnapshotNode[]; edges: readonly SnapshotEdge[]; @@ -93,17 +103,25 @@ function byFulltextIdentity( ); } -/** - * Captures everything a user can observe about a store (default table names). - * Compare snapshots with `expect(after).toEqual(before)`. - */ -export async function dumpObservableState( +type WindowedNode = SnapshotNode & RowWindow; +type WindowedEdge = SnapshotEdge & RowWindow; + +function withoutWindow( + row: T, +): Omit { + const { validFrom: _validFrom, validTo: _validTo, ...rest } = row; + return rest; +} + +/** Every node and edge row, tombstones included, sorted by `(kind, id)`. */ +async function collectRows( store: Store, -): Promise { +): Promise< + Readonly<{ nodes: readonly WindowedNode[]; edges: readonly WindowedEdge[] }> +> { const backend: GraphBackend = storeBackend(store); const graphId = store.graphId; - - const nodes: SnapshotNode[] = []; + const nodes: WindowedNode[] = []; for (const kind of Object.keys(store.graph.nodes)) { const rows = await backend.findNodesByKind({ graphId, @@ -118,11 +136,13 @@ export async function dumpObservableState( id: row.id, props: rowPropsToObject(row.props), deleted: row.deleted_at !== undefined, + validFrom: canonicalInstant(row.valid_from), + validTo: canonicalInstant(row.valid_to), }); } } - const edges: SnapshotEdge[] = []; + const edges: WindowedEdge[] = []; for (const kind of Object.keys(store.graph.edges)) { const rows = await backend.findEdgesByKind({ graphId, @@ -141,10 +161,51 @@ export async function dumpObservableState( toId: row.to_id, props: rowPropsToObject(row.props), deleted: row.deleted_at !== undefined, + validFrom: canonicalInstant(row.valid_from), + validTo: canonicalInstant(row.valid_to), }); } } + return { + nodes: nodes.toSorted((left, right) => byNodeIdentity(left, right)), + edges: edges.toSorted((left, right) => byEdgeIdentity(left, right)), + }; +} + +/** + * One spelling for an instant whichever driver read it: PostgreSQL may hand + * back a `Date` or a zoneless string where SQLite returns canonical ISO text. + */ +function canonicalInstant(value: unknown): string | undefined { + if (value === undefined || value === null) return undefined; + if (value instanceof Date) return value.toISOString(); + if (typeof value === "string") { + const zoned = /(?:Z|[+-]\d{2}(?::?\d{2})?)$/u.test(value); + return new Date( + zoned ? value : `${value.replace(" ", "T")}Z`, + ).toISOString(); + } + throw new TypeError(`Unexpected instant value: ${JSON.stringify(value)}`); +} + +/** A JSON column, whether the driver returns it parsed (jsonb) or as text. */ +function jsonColumn(value: unknown): unknown { + if (value === undefined || value === null) return undefined; + return typeof value === "string" ? JSON.parse(value) : value; +} + +/** + * Captures everything a user can observe about a store (default table names). + * Compare snapshots with `expect(after).toEqual(before)`. + */ +export async function dumpObservableState( + store: Store, +): Promise { + const backend: GraphBackend = storeBackend(store); + const graphId = store.graphId; + const { nodes, edges } = await collectRows(store); + const uniqueRows = await backend.execute<{ node_kind: string; constraint_name: string; @@ -186,11 +247,256 @@ export async function dumpObservableState( })); return { - nodes: nodes.toSorted((left, right) => byNodeIdentity(left, right)), - edges: edges.toSorted((left, right) => byEdgeIdentity(left, right)), + nodes: nodes.map((node) => withoutWindow(node)), + edges: edges.map((edge) => withoutWindow(edge)), uniques: uniques.toSorted((left, right) => byUniqueIdentity(left, right)), fulltext: fulltext.toSorted((left, right) => byFulltextIdentity(left, right), ), }; } + +type SnapshotEdgeClaim = Readonly<{ + axis: string; + key: string; + edgeId: string; +}>; + +type SnapshotIdentityAssertion = Readonly<{ + id: string; + relation: string; + a: string; + b: string; + validFrom: string | undefined; + validTo: string | undefined; + deleted: boolean; + endedBy: string | undefined; +}>; + +type SnapshotIdentityTransition = Readonly<{ + transitionId: string; + recordedRevision: number; + recordedAt: string | undefined; + validAt: string | undefined; + cause: string; + classRef: string; + priorClassRef: string | undefined; + assertionIds: unknown; + decision: unknown; +}>; + +export type RoundTripState = Readonly<{ + nodes: readonly WindowedNode[]; + edges: readonly WindowedEdge[]; + uniques: ObservableState["uniques"]; + fulltext: ObservableState["fulltext"]; + /** The claim rows that still reserve their key (see {@link claimStands}). */ + edgeClaims: readonly SnapshotEdgeClaim[]; + identity: Readonly<{ + assertions: readonly SnapshotIdentityAssertion[]; + /** `member -> class`, one entry per closure row. */ + closure: readonly string[]; + /** `low | high` class-key pairs. */ + separation: readonly string[]; + transitions: readonly SnapshotIdentityTransition[]; + }>; +}>; + +function refKey(kind: unknown, id: unknown): string { + return `${String(kind)}:${String(id)}`; +} + +function sortedStrings(values: readonly string[]): readonly string[] { + return values.toSorted(compareStrings); +} + +/** + * Whether a claim row still reserves its key, by the rule the claim module + * states (src/store/claims/edge-claims.ts): a claim needs no release path, so + * a row whose holder is no longer live — or, on an active-only population, no + * longer open — is a stale row the next claimant takes over, not a + * reservation. Two stores that agree on every live row may disagree on how + * many stale rows they kept; they must agree on the standing ones. + * + * A small reference model rather than the store's predicate: each edge kind + * here declares one population, so "active-only" is a fact of the kind. + */ +function claimStands( + graph: GraphDef, + edges: readonly WindowedEdge[], + holderId: string, + atMs: number, +): boolean { + const holder = edges.find((edge) => edge.id === holderId && !edge.deleted); + if (holder === undefined) return false; + if (holder.validTo === undefined || Date.parse(holder.validTo) > atMs) { + return true; + } + const registration = graph.edges[holder.kind]; + return ( + registration?.cardinality !== "oneActive" && + registration?.targetCardinality !== "oneActive" + ); +} + +/** + * {@link dumpObservableState} plus what an interchange round trip must carry: + * every row's validity window and the ledgers derived from rows — edge claims + * and the identity ledger. Compare two stores' answers with `toEqual`. + * + * `at` is the instant (epoch ms) an active-only claim's holder window is + * judged at; pass the same value when comparing two stores, so a window that + * ends between the two dumps cannot make them disagree. + * + * `transitions` selects which transition-log rows are the graph's history: + * `"all"` on a store that recorded them itself, `"restored"` on an import + * target, whose own import writes log transitions of their own (no + * `restored_at`) that explain the import, not the source's history. + */ +export async function dumpRoundTripState( + store: Store, + options: Readonly<{ transitions: "all" | "restored"; at: number }>, +): Promise { + const backend: GraphBackend = storeBackend(store); + const graphId = store.graphId; + const { nodes, edges } = await collectRows(store); + const { uniques, fulltext } = await dumpObservableState(store); + + const claimRows = await backend.execute<{ + axis: string; + key: string; + edge_id: string; + }>( + asCompiledRowsSql(sql` + SELECT axis, key, edge_id + FROM ${sql.raw("typegraph_edge_claims")} + WHERE graph_id = ${graphId} + `), + ); + const edgeClaims = claimRows + .filter((row) => claimStands(store.graph, edges, row.edge_id, options.at)) + .map((row): SnapshotEdgeClaim => ({ + axis: row.axis, + key: row.key, + edgeId: row.edge_id, + })) + .toSorted( + (left, right) => + compareStrings(left.axis, right.axis) || + compareStrings(left.key, right.key) || + compareStrings(left.edgeId, right.edgeId), + ); + + if (store.graph.identity === undefined) { + return { + nodes, + edges, + uniques, + fulltext, + edgeClaims, + identity: { + assertions: [], + closure: [], + separation: [], + transitions: [], + }, + }; + } + + const assertionRows = await backend.execute>( + asCompiledRowsSql(sql` + SELECT id, rel, a_kind, a_id, b_kind, b_id, valid_from, valid_to, + deleted_at, ended_by_kind, ended_by_id + FROM ${sql.raw("typegraph_identity_assertions")} + WHERE graph_id = ${graphId} + `), + ); + const assertions = assertionRows + .map((row): SnapshotIdentityAssertion => ({ + id: String(row["id"]), + relation: String(row["rel"]), + a: refKey(row["a_kind"], row["a_id"]), + b: refKey(row["b_kind"], row["b_id"]), + validFrom: canonicalInstant(row["valid_from"]), + validTo: canonicalInstant(row["valid_to"]), + deleted: row["deleted_at"] !== null && row["deleted_at"] !== undefined, + endedBy: + row["ended_by_kind"] === null || row["ended_by_kind"] === undefined ? + undefined + : refKey(row["ended_by_kind"], row["ended_by_id"]), + })) + .toSorted((left, right) => compareStrings(left.id, right.id)); + + const closureRows = await backend.execute>( + asCompiledRowsSql(sql` + SELECT member_kind, member_id, class_kind, class_id + FROM ${sql.raw("typegraph_identity_closure")} + WHERE graph_id = ${graphId} + `), + ); + const closure = sortedStrings( + closureRows.map( + (row) => + `${refKey(row["member_kind"], row["member_id"])} -> ${refKey(row["class_kind"], row["class_id"])}`, + ), + ); + + const separationRows = await backend.execute>( + asCompiledRowsSql(sql` + SELECT class_key_low, class_key_high + FROM ${sql.raw("typegraph_identity_separation")} + WHERE graph_id = ${graphId} + `), + ); + const separation = sortedStrings( + separationRows.map( + (row) => + `${String(row["class_key_low"])} | ${String(row["class_key_high"])}`, + ), + ); + + const transitionRows = await backend.execute>( + asCompiledRowsSql(sql` + SELECT transition_id, recorded_revision, recorded_at, valid_at, cause, + class_kind, class_id, prior_class_kind, prior_class_id, + assertion_ids, decision, restored_at + FROM ${sql.raw("typegraph_identity_transitions")} + WHERE graph_id = ${graphId} + `), + ); + const transitions = transitionRows + .filter( + (row) => + options.transitions === "all" || + (row["restored_at"] !== null && row["restored_at"] !== undefined), + ) + .map((row): SnapshotIdentityTransition => ({ + transitionId: String(row["transition_id"]), + recordedRevision: Number(row["recorded_revision"]), + recordedAt: canonicalInstant(row["recorded_at"]), + validAt: canonicalInstant(row["valid_at"]), + cause: String(row["cause"]), + classRef: refKey(row["class_kind"], row["class_id"]), + priorClassRef: + ( + row["prior_class_kind"] === null || + row["prior_class_kind"] === undefined + ) ? + undefined + : refKey(row["prior_class_kind"], row["prior_class_id"]), + assertionIds: jsonColumn(row["assertion_ids"]), + decision: jsonColumn(row["decision"]), + })) + .toSorted((left, right) => + compareStrings(left.transitionId, right.transitionId), + ); + + return { + nodes, + edges, + uniques, + fulltext, + edgeClaims, + identity: { assertions, closure, separation, transitions }, + }; +} diff --git a/packages/typegraph/tests/statement-recorder.ts b/packages/typegraph/tests/statement-recorder.ts index 268efcc6b..d9ccb20bb 100644 --- a/packages/typegraph/tests/statement-recorder.ts +++ b/packages/typegraph/tests/statement-recorder.ts @@ -21,8 +21,9 @@ import { afterEach } from "vitest"; import { type GraphDef } from "../src"; import { generatePostgresDDL } from "../src/backend/drizzle/ddl"; +import { type AnyPgTransaction } from "../src/backend/drizzle/execution/postgres-execution"; import { createPostgresBackend } from "../src/backend/postgres"; -import { type GraphBackend } from "../src/backend/types"; +import { type AdapterBackend } from "../src/backend/types"; import { createStore, type Store } from "../src/store"; /** One statement as drizzle's logger reports it. */ @@ -33,7 +34,7 @@ export type LoggedStatement = Readonly<{ export type RecordedPostgresStore = Readonly<{ store: Store; - backend: GraphBackend; + backend: AdapterBackend; /** Every statement recorded since the last `reset()`, in issue order. */ statements: readonly LoggedStatement[]; /** Drops the recording so the next assertion sees one write's statements. */ diff --git a/packages/typegraph/tests/store-evolve.test.ts b/packages/typegraph/tests/store-evolve.test.ts index 650ef6c74..5b6ebad1c 100644 --- a/packages/typegraph/tests/store-evolve.test.ts +++ b/packages/typegraph/tests/store-evolve.test.ts @@ -28,6 +28,7 @@ import { embedding } from "../src/core/embedding"; import { defineNode } from "../src/core/node"; import { type NodeType } from "../src/core/types"; import { + CardinalityError, ConfigurationError, KindNotFoundError, SchemaContentConflictError, @@ -1025,3 +1026,76 @@ describe("Store.evolve — graph-extension-declared relational indexes", () => { ).rejects.toBeInstanceOf(GraphExtensionValidationError); }); }); + +describe("Store.evolve — runtime-authored edge cardinality (issue #610)", () => { + it("enforces a runtime-declared targetCardinality on a live write", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Tag: { properties: { label: { type: "string" } } } }, + edges: { + ownedBy: { + from: ["Tag"], + to: ["Person"], + properties: {}, + targetCardinality: "one", + }, + }, + }), + ); + + const alice = await evolved.nodes.Person.create({ name: "alice" }); + const bob = await evolved.nodes.Person.create({ name: "bob" }); + const tagCol = requireDefined(evolved.getNodeCollection("Tag")); + const featured = (await tagCol.create({ + label: "featured", + })) as unknown as ExtensionTag; + const important = (await tagCol.create({ + label: "important", + })) as unknown as ExtensionTag; + + const ownedBy = requireDefined(evolved.getEdgeCollection("ownedBy")); + await ownedBy.create( + { kind: "Tag", id: featured.id }, + { kind: "Person", id: alice.id }, + {}, + ); + await expect( + ownedBy.create( + { kind: "Tag", id: important.id }, + { kind: "Person", id: alice.id }, + {}, + ), + ).rejects.toBeInstanceOf(CardinalityError); + // A different target still succeeds — the axis is scoped per target. + await expect( + ownedBy.create( + { kind: "Tag", id: important.id }, + { kind: "Person", id: bob.id }, + {}, + ), + ).resolves.toBeDefined(); + }); + // MUTATION CHECK (verified): drop the + // `targetCardinality: document.targetCardinality` line from `compileEdge` + // (`src/graph-extension/compiler.ts`). `important`'s second create then + // wrongly succeeds instead of refusing. + + it("refuses an unknown cardinality value in the extension document", () => { + expect(() => + defineGraphExtension({ + nodes: { Tag: { properties: { label: { type: "string" } } } }, + edges: { + ownedBy: { + from: ["Tag"], + to: ["Person"], + properties: {}, + // @ts-expect-error -- deliberately invalid for this test + targetCardinality: "atMostFive", + }, + }, + }), + ).toThrow(GraphExtensionValidationError); + }); +}); diff --git a/packages/typegraph/tests/store-introspect.test.ts b/packages/typegraph/tests/store-introspect.test.ts index e8743c46d..ce54a9d43 100644 --- a/packages/typegraph/tests/store-introspect.test.ts +++ b/packages/typegraph/tests/store-introspect.test.ts @@ -12,7 +12,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineGraph, defineNode, KindNotFoundError } from "../src"; +import { + defineEdge, + defineGraph, + defineNode, + KindNotFoundError, + partOf, +} from "../src"; import { defineGraphExtension } from "../src/graph-extension"; import { createStoreWithSchema } from "../src/store/store"; import { requireDefined } from "../src/utils/presence"; @@ -97,6 +103,64 @@ describe("Store.introspect", () => { expect(edge?.origin).toBe("runtime"); expect(edge?.from).toEqual(["Tag"]); expect(edge?.to).toEqual(["Person"]); + expect(edge?.acyclic).toBe(false); + }); + + it("includes a graph-extension edge's acyclic: true declaration", async () => { + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: { Task: { properties: { name: { type: "string" } } } }, + edges: { + dependsOn: { + from: ["Task"], + to: ["Task"], + properties: {}, + acyclic: true, + }, + }, + }), + ); + + const result = evolved.introspect(); + const edge = result.edges.find((entry) => entry.name === "dependsOn"); + expect(edge?.acyclic).toBe(true); + }); + + it("reports a composition-realizing edge kind as acyclic, and an ordinary one as not", async () => { + const Folder = defineNode("Folder", { + schema: z.object({ name: z.string() }), + }); + const parentFolder = defineEdge("parentFolder", { schema: z.object({}) }); + const links = defineEdge("links", { schema: z.object({}) }); + const graph = defineGraph({ + id: "introspect_composition_acyclic", + nodes: { Folder: { type: Folder } }, + edges: { + parentFolder: { + type: parentFolder, + from: [Folder], + to: [Folder], + cardinality: "one", + }, + links: { type: links, from: [Folder], to: [Folder] }, + }, + ontology: [ + partOf(Folder, Folder, { via: parentFolder, partSide: "from" }), + ], + }); + const [store] = await createStoreWithSchema(graph, createTestBackend()); + + // MUTATION CHECK: report `registration.acyclic ?? false` in + // `introspectSchema` (src/store/introspect.ts), as it once did, and the + // composition kind — which every write probes for cycles through the + // composition relation — reads `false` — verified and reverted. + const edges = new Map( + store.introspect().edges.map((edge) => [edge.name, edge.acyclic]), + ); + expect(edges.get("parentFolder")).toBe(true); + expect(edges.get("links")).toBe(false); }); it("includes runtime ontology with origin: runtime", async () => { @@ -119,6 +183,44 @@ describe("Store.introspect", () => { expect(relation?.metaEdge).toBe("subClassOf"); }); + it("reports `via` and `partSide` on a composition relation", async () => { + // The only test of this copy site: OntologyIntrospection is the sole + // one of the five via/partSide representations with no coverage, so a + // dropped spread here is silent public-API data loss even though every + // other copy site (serializer, deserializer, compiler, builders, + // extension-validation) is caught by its own test. + const Section = defineNode("Section", { + schema: z.object({ title: z.string() }), + }); + const parentSection = defineEdge("parentSection", { + schema: z.object({}), + }); + const graph = defineGraph({ + id: "introspect_composition_test", + nodes: { Section: { type: Section } }, + edges: { + parentSection: { + type: parentSection, + from: [Section], + to: [Section], + cardinality: "one", + }, + }, + ontology: [ + partOf(Section, Section, { via: parentSection, partSide: "from" }), + ], + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(graph, backend); + + const relation = store + .introspect() + .ontology.find((entry) => entry.metaEdge === "partOf"); + + expect(relation?.via).toBe("parentSection"); + expect(relation?.partSide).toBe("from"); + }); + it("extension round-trips through defineGraphExtension", async () => { const backend = createTestBackend(); const [store] = await createStoreWithSchema(baseGraph, backend); diff --git a/packages/typegraph/tests/store-removals-fixes.test.ts b/packages/typegraph/tests/store-removals-fixes.test.ts index c2c119c61..94f787ab4 100644 --- a/packages/typegraph/tests/store-removals-fixes.test.ts +++ b/packages/typegraph/tests/store-removals-fixes.test.ts @@ -556,6 +556,7 @@ describe("materializeRemovals edge-claim housekeeping", () => { ); await requireDefined(backend.claimEdgeCardinality)({ graphId: baseGraph.id, + direction: "source", cardinality: "one", edgeKind: "taggedPerson", edgeId: edge.id, diff --git a/packages/typegraph/tests/store-remove-kinds.test.ts b/packages/typegraph/tests/store-remove-kinds.test.ts index 319686871..3f42437b7 100644 --- a/packages/typegraph/tests/store-remove-kinds.test.ts +++ b/packages/typegraph/tests/store-remove-kinds.test.ts @@ -18,7 +18,13 @@ import { describe, expect, it } from "vitest"; import { z } from "zod"; -import { defineEdge, defineGraph, defineNode, KindNotFoundError } from "../src"; +import { + defineEdge, + defineGraph, + defineNode, + KindNotFoundError, + partOf, +} from "../src"; import type { GraphBackend } from "../src/backend/types"; import type { GraphDef } from "../src/core/define-graph"; import { RECORDED_MAX_REVISION } from "../src/core/temporal"; @@ -110,6 +116,16 @@ const baseGraph = defineGraph({ edges: {}, }); +const Section = defineNode("Section", { + schema: z.object({ title: z.string() }), +}); + +const sectionGraph = defineGraph({ + id: "remove_kinds_composition_test", + nodes: { Section: { type: Section } }, + edges: {}, +}); + describe("Store.removeKinds — schema commit", () => { it("removes a graph-extension kind and bumps schema version", async () => { const backend = createTestBackend(); @@ -242,6 +258,98 @@ describe("Store.removeKinds — schema commit", () => { ).toBeUndefined(); }); + it("cascade-removes an ontology relation whose `via` edge is removed", async () => { + // Section is compile-time; parentSection and the partOf relation naming + // it as `via` are both added at runtime through evolve(). Removing the + // edge alone must not leave the relation behind with a dangling `via` — + // planRemovals only ever filtered ontology on from/to, so the relation + // survived unchanged and the next schema commit died in the registry + // layer with ONTOLOGY_COMPOSITION_VIA_UNKNOWN instead. + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(sectionGraph, backend); + const evolved = await store.evolve( + defineGraphExtension({ + nodes: {}, + edges: { + parentSection: { + from: ["Section"], + to: ["Section"], + cardinality: "one", + properties: {}, + }, + }, + ontology: [ + { + metaEdge: "partOf", + from: "Section", + to: "Section", + via: "parentSection", + partSide: "from", + }, + ], + }), + ); + + const removed = await evolved.removeKinds(["parentSection"]); + + expect(removed.registry.hasEdgeType("parentSection")).toBe(false); + const ontology = removed.introspect().ontology; + expect( + ontology.find((entry) => entry.via === "parentSection"), + ).toBeUndefined(); + }); + + it("rejects removing a graph-extension edge referenced by a compile-time composition relation's `via`", () => { + // A compile-time `partOf` can name an edge kind as `via` before that + // edge kind is ever registered — the edge is only added later, as a + // graph-extension edge, by `store.evolve()`. `planRemovals` must treat + // that `via` reference exactly like a compile-time edge endpoint: + // removing the graph-extension edge out from under it would resurrect + // the orphaned reference on the next deploy. + const Section = defineNode("Section", { + schema: z.object({ title: z.string() }), + }); + const parentSection = defineEdge("parentSection", { + schema: z.object({}), + }); + const seedGraph = defineGraph({ + id: "remove_kinds_via_referent_test", + nodes: { Section: { type: Section } }, + edges: {}, + ontology: [ + partOf(Section, Section, { via: parentSection, partSide: "from" }), + ], + }); + + const syntheticGraph = { + ...seedGraph, + extension: { + version: 1, + edges: { + parentSection: { + from: ["Section"], + to: ["Section"], + cardinality: "one" as const, + properties: {}, + }, + }, + }, + } as unknown as typeof seedGraph; + + let caught: unknown; + try { + planRemovals(syntheticGraph, ["parentSection"]); + } catch (error) { + caught = error; + } + if (!(caught instanceof KindHasReferentsError)) { + throw new Error("expected KindHasReferentsError"); + } + expect(caught.referents).toEqual([ + { type: "compile-time-ontology", name: "partOf(Section, Section)" }, + ]); + }); + it("rejects removing a graph-extension kind referenced by a compile-time edge", () => { // Construct the post-evolve scenario through `planRemovals` directly. // The real-world shape this guards against: a developer adds a diff --git a/packages/typegraph/tests/store-update-verdict-fence.test.ts b/packages/typegraph/tests/store-update-verdict-fence.test.ts index 76020b1dc..02e9aeef0 100644 --- a/packages/typegraph/tests/store-update-verdict-fence.test.ts +++ b/packages/typegraph/tests/store-update-verdict-fence.test.ts @@ -52,6 +52,7 @@ import { applyNodeResurrect, createNodeWriteContext, } from "../src/store/operations/node-write-pipeline"; +import { requireDefined } from "../src/utils/presence"; const Person = defineNode("Person", { schema: z.object({ name: z.string(), email: z.string() }), @@ -179,6 +180,79 @@ function spoofProbedUniqueTombstone(backend: GraphBackend): void { ); } +/** + * Makes the first `reads` in-transaction reads of each named row (node or + * edge) report `EARLY_BOUND` as its `valid_from`, through both the point + * and the batch reads: the collection's prefetch and the update batch's own + * read lie, the reads after them see the truth — one concurrent recreate + * per row. Returns the ids of the updates written, in order. + */ +function spoofRowBounds( + backend: GraphBackend, + rowIds: readonly string[], + reads: number, +): readonly string[] { + const written: string[] = []; + const runTransaction = backend.transaction; + const remaining = new Map(rowIds.map((id) => [id, reads])); + const spoof = ( + row: T, + ): T => { + const left = remaining.get(row.id) ?? 0; + if (left <= 0) return row; + remaining.set(row.id, left - 1); + return { ...row, valid_from: EARLY_BOUND }; + }; + vi.spyOn(backend, "transaction").mockImplementation((run, options) => + runTransaction(async (target) => { + const readNode = target.getNode; + const readNodes = target.getNodes; + const writeNode = target.updateNode; + const readEdge = target.getEdge; + const readEdges = target.getEdges; + const writeEdge = target.updateEdge; + return run({ + ...target, + updateNode: async (params) => { + const row = await writeNode(params); + written.push(params.id); + return row; + }, + getNode: async (graphId, kind, id) => { + const row = await readNode(graphId, kind, id); + return row === undefined ? row : spoof(row); + }, + ...(readNodes === undefined ? + {} + : { + getNodes: async (graphId, kind, ids) => { + const rows = await readNodes(graphId, kind, ids); + return rows.map((row) => spoof(row)); + }, + }), + updateEdge: async (params) => { + const row = await writeEdge(params); + written.push(params.id); + return row; + }, + getEdge: async (graphId, id) => { + const row = await readEdge(graphId, id); + return row === undefined ? row : spoof(row); + }, + ...(readEdges === undefined ? + {} + : { + getEdges: async (graphId, ids) => { + const rows = await readEdges(graphId, ids); + return rows.map((row) => spoof(row)); + }, + }), + }); + }, options), + ); + return written; +} + describe("store update verdict fence", () => { const openDatabases: Database.Database[] = []; @@ -368,6 +442,195 @@ describe("store update verdict fence", () => { expect(row.valid_to).toBe(FUTURE_BOUND); }); + // ========================================================== + // The fence in a batch of updates + // ========================================================== + + it("converges a bulk edge update on the row that is really there without writing an earlier row twice", async () => { + const { backend, store, edgeId } = await seed(); + const second = await store.edges.knows.create( + { kind: "Person", id: OTHER_ID }, + { kind: "Person", id: NODE_ID }, + { note: "original" }, + ); + const written = spoofRowBounds(backend, [second.id], 2); + + const results = await store.edges.knows.bulkUpsertById([ + { + id: edgeId, + from: { kind: "Person", id: NODE_ID }, + to: { kind: "Person", id: OTHER_ID }, + props: { note: "first" }, + }, + { + id: second.id, + from: { kind: "Person", id: OTHER_ID }, + to: { kind: "Person", id: NODE_ID }, + props: { note: "second" }, + validTo: FUTURE_BOUND, + }, + ]); + + expect(results.map((edge) => edge.note)).toEqual(["first", "second"]); + const secondAfter = await edgeRow(backend, second.id); + expect(secondAfter.valid_to).toBe(FUTURE_BOUND); + expect(written).toEqual([edgeId, second.id]); + }); + + it("prepares every bulk edge update still to be written again before writing the moved one", async () => { + // The second row's bound moved, so its write matches nothing and the + // batch converges; the third row's verdict was also read off a bound + // that is gone, and against the real one its window inverts. The retry + // prepares both before writing either, so the refusal leaves the second + // row as it was — only the first row, written before anything moved, + // stays written inside the caller's transaction. + // + // MUTATION CHECK: write each entry of `executeEdgeUpsertUpdateBatch` + // with the per-row `performEdgeUpdateConverging` in place of + // `writeEdgeUpdateSet` (src/store/operations/edge-operations.ts), which + // re-prepares only the moved row — the second row is then written before + // the third is refused. + const { backend, store, edgeId } = await seed(); + const second = await store.edges.knows.create( + { kind: "Person", id: OTHER_ID }, + { kind: "Person", id: NODE_ID }, + { note: "original" }, + ); + const third = await store.edges.knows.create( + { kind: "Person", id: NODE_ID }, + { kind: "Person", id: NODE_ID }, + { note: "original" }, + ); + spoofRowBounds(backend, [second.id, third.id], 2); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.edges.knows.bulkUpsertById([ + { + id: edgeId, + from: { kind: "Person", id: NODE_ID }, + to: { kind: "Person", id: OTHER_ID }, + props: { note: "first" }, + }, + { + id: second.id, + from: { kind: "Person", id: OTHER_ID }, + to: { kind: "Person", id: NODE_ID }, + props: { note: "second" }, + validTo: FUTURE_BOUND, + }, + { + id: third.id, + from: { kind: "Person", id: NODE_ID }, + to: { kind: "Person", id: NODE_ID }, + props: { note: "third" }, + validTo: MIDDLE_BOUND, + }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(ValidationError); + const secondAfter = await edgeRow(backend, second.id); + expect(rowPropsToObject(secondAfter.props)["note"]).toBe("original"); + expect(secondAfter.valid_to).toBeUndefined(); + const thirdAfter = await edgeRow(backend, third.id); + expect(thirdAfter.valid_to).toBeUndefined(); + }); + + it("prepares every bulk node update still to be written again before writing the moved one", async () => { + // The node counterpart of the edge case above, through the node batch's + // own converging writer. + // + // MUTATION CHECK: write each entry of `executeNodeUpsertUpdateBatch` with + // the per-row `performNodeUpdateWithResurrectionRecovery` in place of + // `writeNodeUpdateSet` (src/store/operations/node-operations.ts), which + // re-prepares only the moved row — the second node is then written + // before the third is refused. + const { backend, store } = await seed(); + const third = await store.nodes.Person.create({ + name: "Carol", + email: "carol@example.com", + }); + spoofRowBounds(backend, [OTHER_ID, third.id], 2); + + let caught: unknown; + await store.transaction(async (tx) => { + try { + await tx.nodes.Person.bulkUpsertById([ + { + id: NODE_ID, + props: { name: "Alice First", email: "alice@example.com" }, + }, + { + id: OTHER_ID, + props: { name: "Bob Second", email: "bob@example.com" }, + validTo: FUTURE_BOUND, + }, + { + id: third.id, + props: { name: "Carol Third", email: "carol@example.com" }, + validTo: MIDDLE_BOUND, + }, + ]); + } catch (error) { + caught = error; + } + }); + + expect(caught).toBeInstanceOf(ValidationError); + const second = await backend.getNode(graph.id, "Person", OTHER_ID); + expect(rowPropsToObject(requireDefined(second).props)["name"]).toBe("Bob"); + expect(second?.valid_to).toBeUndefined(); + }); + + it("converges a bulk node update on the row that is really there without writing an earlier row twice", async () => { + const { backend, store } = await seed(); + const written = spoofRowBounds(backend, [OTHER_ID], 2); + + const results = await store.nodes.Person.bulkUpsertById([ + { + id: NODE_ID, + props: { name: "Alice First", email: "alice@example.com" }, + }, + { + id: OTHER_ID, + props: { name: "Bob Second", email: "bob@example.com" }, + validTo: FUTURE_BOUND, + }, + ]); + + expect(results.map((node) => node.name)).toEqual([ + "Alice First", + "Bob Second", + ]); + const secondAfter = await backend.getNode(graph.id, "Person", OTHER_ID); + expect(secondAfter?.valid_to).toBe(FUTURE_BOUND); + expect(written).toEqual([NODE_ID, OTHER_ID]); + }); + + it("reports contention when a bulk edge update's row is replaced on every attempt", async () => { + const { backend, store, edgeId } = await seed(); + spoofRowBounds(backend, [edgeId], Number.POSITIVE_INFINITY); + + await expect( + store.edges.knows.bulkUpsertById([ + { + id: edgeId, + from: { kind: "Person", id: NODE_ID }, + to: { kind: "Person", id: OTHER_ID }, + props: { note: "first" }, + validTo: FUTURE_BOUND, + }, + ]), + ).rejects.toThrow(/could not be applied to a stable row/); + const after = await edgeRow(backend, edgeId); + expect(after.valid_to).toBeUndefined(); + }); + // ========================================================== // Sidecar atomicity // ========================================================== diff --git a/packages/typegraph/tests/store-view-sealed-coordinate.test.ts b/packages/typegraph/tests/store-view-sealed-coordinate.test.ts new file mode 100644 index 000000000..ff6a8299c --- /dev/null +++ b/packages/typegraph/tests/store-view-sealed-coordinate.test.ts @@ -0,0 +1,311 @@ +/** + * A StoreView's coordinate is sealed on every read, not only on + * `view.query().temporal(...)`. + * + * The view's option types omit `temporalMode` / `asOf` / `recordedAsOf`, but + * an untyped caller can still state them. A read that spread the pin over + * the caller's options answered at the pin and dropped the stated value; each + * read now refuses instead, on a `current` view and an `asOf` view alike. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStoreWithSchema, + defineEdge, + defineGraph, + defineNode, +} from "../src"; +import { requireDefined } from "../src/utils/presence"; +import { createTestBackend } from "./test-utils"; + +const Person = defineNode("Person", { schema: z.object({ name: z.string() }) }); +const knows = defineEdge("knows", { + schema: z.object({ weight: z.number().optional() }), +}); +const graph = defineGraph({ + id: "store_view_sealed_coordinate", + nodes: { Person: { type: Person } }, + edges: { knows: { type: knows, from: [Person], to: [Person] } }, +}); + +const OTHER_INSTANT = "2020-01-01T00:00:00.000Z"; + +const STATED_COORDINATES = { + "temporalMode and asOf": { temporalMode: "asOf", asOf: OTHER_INSTANT }, + temporalMode: { temporalMode: "includeEnded" }, + asOf: { asOf: OTHER_INSTANT }, + recordedAsOf: { recordedAsOf: OTHER_INSTANT }, +} as const; + +async function seededStore() { + const [store] = await createStoreWithSchema(graph, createTestBackend()); + const alice = await store.nodes.Person.create({ name: "alice" }); + const bob = await store.nodes.Person.create({ name: "bob" }); + const edge = await store.edges.knows.create(alice, bob, {}); + return { store, alice, bob, edgeId: edge.id }; +} + +type Seeded = Awaited>; +type View = ReturnType; + +/** Every view read that takes an options object, called with `stated` merged in. */ +function readsStating( + view: View, + { alice, bob }: Seeded, + stated: Readonly>, +): Readonly Promise>> { + const options = { edges: ["knows"], ...stated } as never; + return { + subgraph: () => view.subgraph(alice, options), + shortestPath: () => view.shortestPath(alice, bob, options), + weightedShortestPath: () => + view.weightedShortestPath(alice, bob, { + edges: ["knows"], + weight: "weight", + ...stated, + } as never), + reachable: () => view.reachable(alice, options), + canReach: () => view.canReach(alice, bob, options), + neighbors: () => view.neighbors(alice, options), + degree: () => view.degree(alice, options), + labelPropagation: () => view.labelPropagation(options), + weaklyConnectedComponents: () => view.weaklyConnectedComponents(options), + pageRank: () => view.pageRank(options), + personalizedPageRank: () => + view.personalizedPageRank({ + edges: ["knows"], + seeds: [alice], + ...stated, + } as never), + "algorithms.reachable": () => view.algorithms.reachable(alice, options), + bulkFindEdgesFrom: () => + view.bulkFindEdgesFrom( + { kinds: ["knows"], from: [alice] } as never, + stated, + ), + bulkFindEdgesTo: () => + view.bulkFindEdgesTo({ kinds: ["knows"], to: [bob] } as never, stated), + "edges.bulkFindFrom": () => view.edges.knows.bulkFindFrom([alice], stated), + "edges.bulkFindTo": () => view.edges.knows.bulkFindTo([bob], stated), + }; +} + +type Reader = Readonly< + Record Promise> +>; + +/** Calls a collection read by name, past the view's declared signatures. */ +function call( + reader: Reader, + method: string, + ...args: readonly unknown[] +): Promise { + return requireDefined(reader[method])(...args); +} + +/** + * Every pinned point read, with `stated` in the argument position the live + * collection takes its own temporal options in. + */ +function pointReadsStating( + view: View, + { alice, bob, edgeId }: Seeded, + stated: Readonly>, +): Readonly Promise>> { + const people: Reader = view.nodes.Person as never; + const knowsEdges: Reader = view.edges.knows as never; + return { + "nodes.getById": () => call(people, "getById", alice.id, stated), + "nodes.getByIds": () => call(people, "getByIds", [alice.id], stated), + "nodes.find": () => call(people, "find", {}, stated), + "nodes.count": () => call(people, "count", stated), + "edges.getById": () => call(knowsEdges, "getById", edgeId, stated), + "edges.getByIds": () => call(knowsEdges, "getByIds", [edgeId], stated), + "edges.find": () => call(knowsEdges, "find", {}, stated), + "edges.count": () => call(knowsEdges, "count", {}, stated), + "edges.findFrom": () => call(knowsEdges, "findFrom", alice, stated), + "edges.findTo": () => call(knowsEdges, "findTo", bob, stated), + "edges.findByEndpoints (options)": () => + call(knowsEdges, "findByEndpoints", alice, bob, stated), + "edges.findByEndpoints (temporal)": () => + call(knowsEdges, "findByEndpoints", alice, bob, {}, stated), + }; +} + +async function refusalCodes( + reads: Readonly Promise>>, +): Promise>> { + const codes: Record = {}; + for (const [name, read] of Object.entries(reads)) { + codes[name] = await read().then( + () => "answered", + (error: unknown) => + error instanceof ConfigurationError ? error.details["code"] : error, + ); + } + return codes; +} + +describe("a StoreView read refuses a caller-stated coordinate", () => { + describe.each(["current", "asOf"] as const)("on a %s view", (mode) => { + it.each(Object.entries(STATED_COORDINATES))( + "refuses every options-taking read that states %s", + async (_label, stated) => { + const seeded = await seededStore(); + const view = + mode === "current" ? + seeded.store.view({ mode: "current" }) + : seeded.store.asOf(new Date().toISOString()); + const reads = readsStating(view, seeded, stated); + + expect(await refusalCodes(reads)).toEqual( + Object.fromEntries( + Object.keys(reads).map((name) => [ + name, + "STORE_VIEW_SEALED_COORDINATE", + ]), + ), + ); + }, + ); + }); + + describe.each(["current", "asOf"] as const)( + "on a %s view's point reads", + (mode) => { + it.each(Object.entries(STATED_COORDINATES))( + "refuses every point read that states %s", + async (_label, stated) => { + const seeded = await seededStore(); + const view = + mode === "current" ? + seeded.store.view({ mode: "current" }) + : seeded.store.asOf(new Date().toISOString()); + const reads = pointReadsStating(view, seeded, stated); + + expect(await refusalCodes(reads)).toEqual( + Object.fromEntries( + Object.keys(reads).map((name) => [ + name, + "STORE_VIEW_SEALED_COORDINATE", + ]), + ), + ); + }, + ); + }, + ); + + it("names the read, the stated keys and the pinned coordinate", async () => { + const seeded = await seededStore(); + const view = seeded.store.asOf(OTHER_INSTANT); + + const refusal = await view + .subgraph(seeded.alice, { + edges: ["knows"], + temporalMode: "current", + asOf: undefined, + } as never) + .then( + () => "answered", + (error: unknown) => error, + ); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toMatchObject({ + code: "STORE_VIEW_SEALED_COORDINATE", + method: "subgraph", + stated: ["temporalMode"], + }); + }); + + it("answers at the pin when no coordinate is stated", async () => { + const seeded = await seededStore(); + const now = seeded.store.view({ mode: "current" }); + const before = seeded.store.asOf(OTHER_INSTANT); + + const names = async (view: View) => { + const result = await view.subgraph(seeded.alice, { + edges: ["knows"], + }); + return result.nodes.map((node) => node.name).toSorted(); + }; + + expect(await names(now)).toEqual(["alice", "bob"]); + expect(await names(before)).toEqual([]); + expect(await now.degree(seeded.alice, { edges: ["knows"] })).toBe(1); + expect(await now.edges.knows.bulkFindFrom([seeded.alice])).toHaveLength(1); + expect(await now.nodes.Person.getById(seeded.alice.id)).toBeDefined(); + expect(await now.edges.knows.findFrom(seeded.alice)).toHaveLength(1); + expect( + await now.edges.knows.findByEndpoints(seeded.alice, seeded.bob), + ).toBeDefined(); + expect(await before.edges.knows.findFrom(seeded.alice)).toHaveLength(0); + }); + + describe("on a recorded view's point reads", () => { + it.each(Object.entries(STATED_COORDINATES))( + "refuses every recorded read that states %s", + async (_label, stated) => { + const [store] = await createStoreWithSchema( + graph, + createTestBackend(), + { history: true }, + ); + const alice = await store.nodes.Person.create({ name: "alice" }); + const bob = await store.nodes.Person.create({ name: "bob" }); + const edge = await store.edges.knows.create(alice, bob, {}); + const pin = requireDefined(await store.recordedNow()); + const view = store.asOfRecorded(pin); + const people: Reader = view.nodes.Person as never; + const knowsEdges: Reader = view.edges.knows as never; + + const reads = { + "nodes.getById": () => call(people, "getById", alice.id, stated), + "nodes.getByIds": () => call(people, "getByIds", [alice.id], stated), + "nodes.scan": () => call(people, "scan", { limit: 10, ...stated }), + "edges.getById": () => call(knowsEdges, "getById", edge.id, stated), + "edges.getByIds": () => + call(knowsEdges, "getByIds", [edge.id], stated), + "edges.scan": () => + call(knowsEdges, "scan", { limit: 10, ...stated }), + degree: () => + view.degree(alice, { edges: ["knows"], ...stated } as never), + }; + + expect(await refusalCodes(reads)).toEqual( + Object.fromEntries( + Object.keys(reads).map((name) => [ + name, + "STORE_VIEW_SEALED_COORDINATE", + ]), + ), + ); + }, + ); + + it("answers at the pin when no coordinate is stated", async () => { + const [store] = await createStoreWithSchema(graph, createTestBackend(), { + history: true, + }); + const alice = await store.nodes.Person.create({ name: "alice" }); + const bob = await store.nodes.Person.create({ name: "bob" }); + const edge = await store.edges.knows.create(alice, bob, {}); + const view = store.asOfRecorded( + requireDefined(await store.recordedNow()), + ); + + const aliceAtPin = await view.nodes.Person.getById(alice.id); + const peopleAtPin = await view.nodes.Person.getByIds([alice.id, bob.id]); + const edgeAtPin = await view.edges.knows.getById(edge.id); + const scanAtPin = await view.nodes.Person.scan({ limit: 10 }); + + expect(aliceAtPin?.name).toBe("alice"); + expect(peopleAtPin).toHaveLength(2); + expect(edgeAtPin?.id).toBe(edge.id); + expect(scanAtPin.data).toHaveLength(2); + }); + }); +}); diff --git a/packages/typegraph/tests/structural-subsumption-enforcement.test.ts b/packages/typegraph/tests/structural-subsumption-enforcement.test.ts new file mode 100644 index 000000000..6131910bd --- /dev/null +++ b/packages/typegraph/tests/structural-subsumption-enforcement.test.ts @@ -0,0 +1,132 @@ +/** + * Construction ratchet: every `buildValidatedKindRegistry(` call in `src/` + * (direct, or through the `buildRegistryFromSerializedSchema(` wrapper) + * supplies `structuralSubsumption`, and exactly the ONE call site documented + * as unenforced (`StructuralSubsumptionMode`, `src/registry/build-validated.ts`) + * is allowed to pass `"unenforced-baseline"`. + * + * A new registry construction that forgets the field is a TypeScript error + * already (the field is required, not optional) — this test instead guards + * against a construction that supplies the field but with the WRONG + * literal, silently widening which registry the structural subsumption check skips. + * + * The scan for BOTH entry points is independent of whether the + * `"unenforced-baseline"` literal and the call itself appear in the same + * text pattern: a direct `buildValidatedKindRegistry({ ..., + * structuralSubsumption: "unenforced-baseline" })` call site is caught even + * though it never mentions `buildRegistryFromSerializedSchema(`. + * + * *Mutation*: change the BEFORE-side call in + * `src/schema/ontology-change.ts` from `"unenforced-baseline"` to the + * default (drop the second argument) — the "unenforced sites" assertion + * fails, since the source no longer contains the literal this test expects. + * *Mutation*: add a second `"unenforced-baseline"` call anywhere in `src/` + * not named in `EXPECTED_UNENFORCED_CALL_SITES`, through EITHER entry + * point (including a direct `buildValidatedKindRegistry(` call with no + * `buildRegistryFromSerializedSchema(` in sight) — the "exactly one" count + * assertion fails, naming the unexpected file. + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +import { describe, expect, it } from "vitest"; + +const SOURCE_ROOT = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + "../src", +); + +/** + * The file that DECLARES `StructuralSubsumptionMode` and its + * `"unenforced-baseline"` member — a type declaration, not a call site, so + * it is excluded from both scans below. + */ +const TYPE_DECLARATION_FILE = "registry/build-validated.ts"; + +/** The two call-site patterns that can reach `buildValidatedKindRegistry`. */ +const CALL_SITE_PATTERNS: readonly string[] = [ + "buildValidatedKindRegistry(", + "buildRegistryFromSerializedSchema(", +]; + +const UNENFORCED_LITERAL = '"unenforced-baseline"'; + +/** The one call site permitted to build an unenforced registry, and why. */ +const EXPECTED_UNENFORCED_CALL_SITES: ReadonlySet = new Set([ + "schema/ontology-change.ts", +]); + +/** The direct `buildValidatedKindRegistry(` call sites — both required to supply `structuralSubsumption` explicitly (it is a required field on the input object, not defaulted). */ +const EXPECTED_DIRECT_CALL_SITES: ReadonlySet = new Set([ + "registry/builders.ts", + "schema/deserializer.ts", +]); + +function walkTsFiles(dir: string): readonly string[] { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkTsFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith(".ts")) { + files.push(fullPath); + } + } + return files; +} + +function relativeToSourceRoot(filePath: string): string { + return path.relative(SOURCE_ROOT, filePath).split(path.sep).join("/"); +} + +function isCallSiteFile(content: string): boolean { + return CALL_SITE_PATTERNS.some((pattern) => content.includes(pattern)); +} + +describe("structural subsumption enforcement construction ratchet", () => { + it("every unenforced-baseline literal in src/ sits in a real registry-builder call site, and the set is exactly the documented one", () => { + const unenforcedSites: string[] = []; + for (const filePath of walkTsFiles(SOURCE_ROOT)) { + const relativePath = relativeToSourceRoot(filePath); + if (relativePath === TYPE_DECLARATION_FILE) continue; + const content = fs.readFileSync(filePath, "utf8"); + if (!content.includes(UNENFORCED_LITERAL)) continue; + + // A file carrying the literal for some other reason (a comment, a + // fixture) with no registry-builder call in sight would otherwise + // silently pass the "exactly one" check below. Failure names the + // offending file via `unenforcedSites`/`callSiteFiles` divergence one + // assertion down, so this only needs to guard, not narrate. + if (!isCallSiteFile(content)) { + throw new Error( + `${relativePath} contains the "unenforced-baseline" literal but neither ` + + `buildValidatedKindRegistry( nor buildRegistryFromSerializedSchema( — ` + + `the construction ratchet cannot verify what consumes this literal`, + ); + } + unenforcedSites.push(relativePath); + } + + expect(new Set(unenforcedSites)).toEqual(EXPECTED_UNENFORCED_CALL_SITES); + }); + + it("every direct buildValidatedKindRegistry( call site supplies structuralSubsumption explicitly", () => { + const directCallSites: string[] = []; + for (const filePath of walkTsFiles(SOURCE_ROOT)) { + const relativePath = relativeToSourceRoot(filePath); + if (relativePath === TYPE_DECLARATION_FILE) continue; + const content = fs.readFileSync(filePath, "utf8"); + if (!content.includes("buildValidatedKindRegistry(")) continue; + directCallSites.push(relativePath); + if (!content.includes("structuralSubsumption")) { + throw new Error( + `${relativePath} calls buildValidatedKindRegistry( without mentioning structuralSubsumption`, + ); + } + } + + expect(new Set(directCallSites)).toEqual(EXPECTED_DIRECT_CALL_SITES); + }); +}); diff --git a/packages/typegraph/tests/structural-subtype-zod-projection.test.ts b/packages/typegraph/tests/structural-subtype-zod-projection.test.ts new file mode 100644 index 000000000..597c4873b --- /dev/null +++ b/packages/typegraph/tests/structural-subtype-zod-projection.test.ts @@ -0,0 +1,935 @@ +/** + * The registry's structural-subsumption check compares a live graph's kinds + * through `structuralSubtypeProjection`, which marks the Zod constraints the + * JSON Schema projection drops. Each refused pair below is one the bare + * projection judged `subtype` while the child admits a value the parent + * refuses; each accepted pair shows the marker does not refuse a pair it + * cannot make unsound. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { defineGraph, defineNode, embedding, subClassOf } from "../src"; +import { ConfigurationError } from "../src/errors"; +import { buildKindRegistry } from "../src/registry/builders"; +import { serializeSchemaProperties } from "../src/schema/serializer"; +import { + isStructuralSubtype, + type StructuralSubtypeResult, + ZOD_ONLY_CONSTRAINT_KEYWORD, + ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD, +} from "../src/schema/structural-subtype"; +import { structuralSubtypeProjection } from "../src/schema/structural-subtype-projection"; + +function subclassRegistryError( + childSchema: z.ZodObject, + parentSchema: z.ZodObject, +): unknown { + const Child = defineNode("ProjectionChild", { schema: childSchema }); + const Parent = defineNode("ProjectionParent", { schema: parentSchema }); + try { + buildKindRegistry( + defineGraph({ + id: "structural_subtype_projection", + nodes: { + ProjectionChild: { type: Child }, + ProjectionParent: { type: Parent }, + }, + edges: {}, + ontology: [subClassOf(Child, Parent)], + }), + ); + return undefined; + } catch (error) { + return error; + } +} + +function expectIncomparable(error: unknown): void { + expect(error).toBeInstanceOf(ConfigurationError); + expect((error as ConfigurationError).details).toMatchObject({ + code: "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + reason: "unsupported-construct", + }); +} + +describe("structural subsumption over constraints the JSON Schema projection drops", () => { + it("refuses a case-insensitive pattern under a case-sensitive one", () => { + const child = z.object({ code: z.string().regex(/^abc$/i) }); + const parent = z.object({ code: z.string().regex(/^abc$/) }); + expect(child.safeParse({ code: "ABC" }).success).toBe(true); + expect(parent.safeParse({ code: "ABC" }).success).toBe(false); + + const error = subclassRegistryError(child, parent); + + expectIncomparable(error); + expect((error as ConfigurationError).details).toMatchObject({ + path: ["code"], + }); + }); + + it("refuses a flagged pattern carried by a custom string format", () => { + const child = z.object({ code: z.stringFormat("vx-code", /^abc$/i) }); + const parent = z.object({ code: z.stringFormat("vx-code", /^abc$/) }); + expect(child.safeParse({ code: "ABC" }).success).toBe(true); + expect(parent.safeParse({ code: "ABC" }).success).toBe(false); + + expectIncomparable(subclassRegistryError(child, parent)); + }); + + it("refuses a flagged pattern overriding a built-in string format", () => { + const child = z.object({ mail: z.email({ pattern: /^[a-z]+@x\.y$/i }) }); + const parent = z.object({ mail: z.email({ pattern: /^[a-z]+@x\.y$/ }) }); + expect(child.safeParse({ mail: "ABC@x.y" }).success).toBe(true); + expect(parent.safeParse({ mail: "ABC@x.y" }).success).toBe(false); + + expectIncomparable(subclassRegistryError(child, parent)); + }); + + it("refuses a flagged pattern on the parent side too", () => { + expectIncomparable( + subclassRegistryError( + z.object({ code: z.string().regex(/^abc$/) }), + z.object({ code: z.string().regex(/^abc$/u) }), + ), + ); + }); + + it("accepts the same flagged pattern on both sides, and a global-only flag", () => { + expect( + subclassRegistryError( + z.object({ code: z.string().regex(/^abc$/i), extra: z.string() }), + z.object({ code: z.string().regex(/^abc$/i) }), + ), + ).toBeUndefined(); + expect( + subclassRegistryError( + z.object({ code: z.string().regex(/^abc$/g).min(1) }), + z.object({ code: z.string().regex(/^abc$/) }), + ), + ).toBeUndefined(); + }); + + it("refuses a parent refinement the child is never compared with", () => { + const child = z.object({ low: z.number(), high: z.number() }); + const parent = z + .object({ low: z.number(), high: z.number() }) + .refine((value) => value.low <= value.high); + expect(child.safeParse({ low: 2, high: 1 }).success).toBe(true); + expect(parent.safeParse({ low: 2, high: 1 }).success).toBe(false); + + expectIncomparable(subclassRegistryError(child, parent)); + }); + + it("accepts a refinement on the child, and a refined field schema both sides share", () => { + const ordered = z.number().refine((value) => value >= 0); + expect( + subclassRegistryError( + z.object({ count: z.number().refine((value) => value > 0) }), + z.object({ count: z.number() }), + ), + ).toBeUndefined(); + expect( + subclassRegistryError( + z.object({ count: ordered, label: z.string() }), + z.object({ count: ordered }), + ), + ).toBeUndefined(); + }); + + it("refuses two different refinements that project identically", () => { + expectIncomparable( + subclassRegistryError( + z.object({ count: z.number().refine((value) => value >= 0) }), + z.object({ count: z.number().refine((value) => value > 10) }), + ), + ); + }); + + it("refuses a child that adds a property under a strict parent", () => { + const child = z.object({ name: z.string(), age: z.number() }); + const parent = z.strictObject({ name: z.string() }); + expect(parent.safeParse(child.parse({ name: "a", age: 1 })).success).toBe( + false, + ); + + expectIncomparable(subclassRegistryError(child, parent)); + expect( + subclassRegistryError(child, z.object({ name: z.string() })), + ).toBeUndefined(); + }); + + it("refuses a .meta() key spelled like a constraint", () => { + const child = z.object({ code: z.string().meta({ maxLength: 2 }) }); + const parent = z.object({ code: z.string().max(2) }); + expect(child.safeParse({ code: "abcdef" }).success).toBe(true); + expect(parent.safeParse({ code: "abcdef" }).success).toBe(false); + + expectIncomparable(subclassRegistryError(child, parent)); + }); + + it("leaves the persisted projection free of comparison markers", () => { + const projection = serializeSchemaProperties( + z.strictObject({ + code: z.string().regex(/^abc$/i), + count: z.number().refine((value) => value > 0), + }), + ); + + const serialized = JSON.stringify(projection); + expect(serialized).not.toContain(ZOD_ONLY_CONSTRAINT_KEYWORD); + expect(serialized).not.toContain(ZOD_ONLY_PARENT_CONSTRAINT_KEYWORD); + }); +}); + +function liveVerdict( + child: z.ZodType, + parent: z.ZodType, +): StructuralSubtypeResult { + return isStructuralSubtype( + structuralSubtypeProjection(z.object({ field: child })), + structuralSubtypeProjection(z.object({ field: parent })), + ); +} + +describe("structural subsumption over formats, overwrites and literals the projection drops", () => { + const refused: readonly { + name: string; + child: z.ZodType; + parent: z.ZodType; + witness: unknown; + }[] = [ + { + name: "z.literal(Infinity) under z.string().min(5)", + child: z.literal(Number.POSITIVE_INFINITY), + parent: z.string().min(5), + witness: Number.POSITIVE_INFINITY, + }, + { + name: "z.literal(NaN) under z.boolean()", + child: z.literal(Number.NaN), + parent: z.boolean(), + witness: Number.NaN, + }, + { + name: "z.literal(-Infinity) under z.null()", + child: z.literal(Number.NEGATIVE_INFINITY), + parent: z.null(), + witness: Number.NEGATIVE_INFINITY, + }, + { + name: "z.literal(Infinity) under z.object({})", + child: z.literal(Number.POSITIVE_INFINITY), + parent: z.object({}), + witness: Number.POSITIVE_INFINITY, + }, + { + name: "z.literal(-Infinity) under z.enum(['a']).or(z.boolean())", + child: z.literal(Number.NEGATIVE_INFINITY), + parent: z.enum(["a"]).or(z.boolean()), + witness: Number.NEGATIVE_INFINITY, + }, + { + name: "z.literal(Infinity).nullable() under z.string().nullable()", + child: z.literal(Number.POSITIVE_INFINITY).nullable(), + parent: z.string().nullable(), + witness: Number.POSITIVE_INFINITY, + }, + { + name: "z.literal([1, Infinity]) under z.int().max(5)", + child: z.literal([1, Number.POSITIVE_INFINITY]), + parent: z.int().max(5), + witness: Number.POSITIVE_INFINITY, + }, + { + name: "z.literal([1, NaN]) under z.number().multipleOf(1)", + child: z.literal([1, Number.NaN]), + parent: z.number().multipleOf(1), + witness: Number.NaN, + }, + { + name: "z.literal(NaN) under z.number()", + child: z.literal(Number.NaN), + parent: z.number(), + witness: Number.NaN, + }, + { + name: "z.literal(['a', NaN]) under z.string().nullable()", + child: z.literal(["a", Number.NaN]), + parent: z.string().nullable(), + witness: Number.NaN, + }, + { + name: "z.literal('https://') under z.url().startsWith('https://')", + child: z.literal("https://"), + parent: z.url().startsWith("https://"), + witness: "https://", + }, + { + name: "z.string().startsWith('https://') under z.url().startsWith('https://')", + child: z.string().startsWith("https://"), + parent: z.url().startsWith("https://"), + witness: "https://", + }, + { + name: "z.literal('abc') under z.url().lowercase()", + child: z.literal("abc"), + parent: z.url().lowercase(), + witness: "abc", + }, + { + name: "z.literal('eyX') under z.jwt().startsWith('ey')", + child: z.literal("eyX"), + parent: z.jwt().startsWith("ey"), + witness: "eyX", + }, + { + name: "z.literal('ABC') under z.httpUrl().uppercase()", + child: z.literal("ABC"), + parent: z.httpUrl().uppercase(), + witness: "ABC", + }, + { + name: "z.literal('x') under z.string().url().endsWith('x')", + child: z.literal("x"), + // eslint-disable-next-line @typescript-eslint/no-deprecated -- the format then arrives as a check, not on the schema + parent: z.string().url().endsWith("x"), + witness: "x", + }, + { + name: "z.literal('a') under z.stringFormat(fn).includes('a')", + child: z.literal("a"), + parent: z + .stringFormat("even-length", (value) => value.length % 2 === 0) + .includes("a"), + witness: "a", + }, + { + name: "z.literal(' a') under z.string().trim().min(2)", + child: z.literal(" a"), + parent: z.string().trim().min(2), + witness: " a", + }, + { + name: "z.string().min(2) under z.string().trim().min(2)", + child: z.string().min(2), + parent: z.string().trim().min(2), + witness: " a", + }, + { + name: "z.literal(' ab') under z.string().trim().length(3)", + child: z.literal(" ab"), + parent: z.string().trim().length(3), + witness: " ab", + }, + { + name: "z.literal('abc') under z.string().toUpperCase().regex(/^[a-z]+$/)", + child: z.literal("abc"), + parent: z + .string() + .toUpperCase() + .regex(/^[a-z]+$/), + witness: "abc", + }, + { + name: "z.literal('A') under z.string().toLowerCase().regex(/^A$/)", + child: z.literal("A"), + parent: z.string().toLowerCase().regex(/^A$/), + witness: "A", + }, + { + name: "z.literal(U+0958) under z.string().normalize().max(1) (NFC decomposes it into two units)", + child: z.literal("\u0958"), + parent: z.string().normalize().max(1), + witness: "\u0958", + }, + { + name: "z.string().min(2).trim() under z.string().min(2) (the child stores what min(2) never saw)", + child: z.string().min(2).trim(), + parent: z.string().min(2), + witness: " a", + }, + { + name: "z.string().min(2).trim().max(5) under z.string().min(2).max(5) (an overwrite between two stated checks)", + child: z.string().min(2).trim().max(5), + parent: z.string().min(2).max(5), + witness: " a", + }, + { + name: "z.literal(' a') under z.string().max(5).trim().min(2) (a check after the parent's interleaved overwrite)", + child: z.literal(" a"), + parent: z.string().max(5).trim().min(2), + witness: " a", + }, + { + name: "record z.enum([' a']) keys under z.string().trim().min(2) keys", + child: z.record(z.enum([" a"]), z.number()), + parent: z.record(z.string().trim().min(2), z.number()), + witness: { " a": 1 }, + }, + { + name: "record z.enum(['abc']) keys under z.url().lowercase() keys", + child: z.record(z.enum(["abc"]), z.number()), + parent: z.record(z.url().lowercase(), z.number()), + witness: { abc: 1 }, + }, + ]; + + it.each(refused.map((row) => [row.name, row] as const))( + "refuses %s", + (_name, row) => { + const stored = row.child.safeParse(row.witness); + expect(stored.success).toBe(true); + expect(row.parent.safeParse(stored.data).success).toBe(false); + expect(liveVerdict(row.child, row.parent).verdict).not.toBe("subtype"); + }, + ); + + it("still accepts an overwrite that cannot make the pair unsound", () => { + expect(liveVerdict(z.string().trim(), z.string())).toEqual({ + verdict: "subtype", + }); + expect(liveVerdict(z.string(), z.string().trim())).toEqual({ + verdict: "subtype", + }); + expect(liveVerdict(z.string().min(2), z.string().min(2).trim())).toEqual({ + verdict: "incomparable", + reason: "unsupported-construct", + path: ["field"], + }); + expect(liveVerdict(z.string().trim().min(3), z.string().min(2))).toEqual({ + verdict: "subtype", + }); + const shared = z.string().trim().min(2); + expect(liveVerdict(shared, shared)).toEqual({ verdict: "subtype" }); + }); + + it("judges a finite union member on its own beside an unprojectable one", () => { + // The `z.date()` member matches the parent's own `z.date()` member; the + // literal member answers exactly as a bounded string would. + const parent = z.union([z.date(), z.string().min(1)]); + expect(liveVerdict(z.union([z.date(), z.literal("x")]), parent)).toEqual({ + verdict: "subtype", + }); + expect( + liveVerdict(z.union([z.date(), z.literal("")]), parent).verdict, + ).not.toBe("subtype"); + // Under a parent with no unconvertible member, the `z.date()` member + // matches nothing that can be judged. + expect( + liveVerdict(z.union([z.date(), z.literal("x")]), z.string().min(1)), + ).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-construct", + }); + }); + + it("still accepts a single format under a bare string, and a pattern-stated format alone", () => { + expect(liveVerdict(z.url(), z.string())).toEqual({ verdict: "subtype" }); + expect( + liveVerdict( + z.url().startsWith("https://"), + z.string().startsWith("https://"), + ), + ).toEqual({ verdict: "subtype" }); + expect(liveVerdict(z.literal("ab"), z.string().startsWith("a"))).toEqual({ + verdict: "subtype", + }); + }); +}); + +function upper(value: string): string { + return value.toUpperCase(); +} + +describe("structural subsumption over a field the projection cannot convert", () => { + const sharedTransform = z.string().transform((value) => upper(value)); + + // The unconvertible side states nothing, so nothing shows it narrows a + // parent that constrains values, or satisfies a parent it stands for. + const refused: readonly Readonly<{ + name: string; + child: () => z.ZodType; + parent: () => z.ZodType; + path?: readonly string[]; + }>[] = [ + { + name: "a transformed child under a bounded string", + child: () => z.string().transform((value) => upper(value)), + parent: () => z.string().min(3), + }, + { + name: "a plain child under a parent whose transform hides its bound", + child: () => z.string(), + parent: () => + z + .string() + .min(3) + .transform((value) => upper(value)), + }, + { + name: "a custom child under a bounded number", + child: () => z.custom(), + parent: () => z.number().min(5), + }, + { + name: "a date child under a bare string, which constrains by type", + child: () => z.date(), + parent: () => z.string(), + }, + { + name: "a bigint child under a bare number", + child: () => z.bigint(), + parent: () => z.number(), + }, + { + name: "a date child under an empty object", + child: () => z.date(), + parent: () => z.object({}), + }, + { + name: "a date child under a refined parent that projects as {}", + child: () => z.date(), + parent: () => z.unknown().refine((value) => value !== undefined), + }, + { + name: "an unknown child under a date parent", + child: () => z.unknown(), + parent: () => z.date(), + }, + { + name: "a date child under a custom parent, a different node type", + child: () => z.date(), + parent: () => z.custom(), + }, + { + name: "a date child under a nullable string", + child: () => z.date(), + parent: () => z.string().nullable(), + }, + { + name: "a nullable date child under a date parent", + child: () => z.date().nullable(), + parent: () => z.date(), + path: ["field", "anyOf[1]"], + }, + { + name: "two independently declared transforms with different checks", + child: () => z.string().transform((value) => upper(value)), + parent: () => + z + .string() + .min(3) + .transform((value) => upper(value)), + }, + { + name: "two independently declared identical transforms", + child: () => z.string().transform((value) => upper(value)), + parent: () => z.string().transform((value) => upper(value)), + }, + { + name: "a described embedding, a fresh instance without its dimensions", + child: () => embedding(3).describe("vector"), + parent: () => embedding(3), + }, + { + name: "a bounded date under a plain one", + child: () => z.date(), + parent: () => z.date().min(new Date(0)), + }, + { + name: "a refined date under a plain one", + child: () => z.date().refine((value) => value.getTime() > 0), + parent: () => z.date(), + }, + { + name: "two independently declared custom checks", + child: () => z.custom((value) => typeof value === "string"), + parent: () => z.custom((value) => typeof value === "string"), + }, + { + name: "two independently declared sets", + child: () => z.set(z.number()), + parent: () => z.set(z.string()), + }, + { + name: "a defaulted transform under the transform it wraps", + child: () => sharedTransform.default("x"), + parent: () => sharedTransform, + }, + ]; + + it.each(refused)( + "refuses $name as incomparable", + ({ child, parent, path }) => { + expect(liveVerdict(child(), parent())).toEqual({ + verdict: "incomparable", + reason: "unsupported-construct", + path: path ?? ["field"], + }); + }, + ); + + const accepted: readonly Readonly<{ + name: string; + child: () => z.ZodType; + parent: () => z.ZodType; + }>[] = [ + { + name: "a transformed child under z.unknown()", + child: () => z.string().transform((value) => upper(value)), + parent: () => z.unknown(), + }, + { + name: "a date child under z.any() carrying only a description", + child: () => z.date(), + parent: () => z.any().describe("anything"), + }, + { + name: "independently declared dates", + child: () => z.date(), + parent: () => z.date(), + }, + { + name: "a described date under a plain one", + child: () => z.date().describe("when"), + parent: () => z.date().readonly(), + }, + { + name: "a date child under a nullable date parent", + child: () => z.date(), + parent: () => z.date().nullable(), + }, + { + name: "plain bigints", + child: () => z.bigint(), + parent: () => z.bigint(), + }, + { + name: "an exhaustive record narrowing its parent's optional values", + child: () => z.record(z.enum(["a", "b"]), z.string().min(1).optional()), + parent: () => z.record(z.enum(["a", "b"]), z.string().optional()), + }, + { + name: "a transform both sides share, as `.extend()` does", + child: () => sharedTransform, + parent: () => sharedTransform, + }, + { + name: "an optional wrapper around a shared transform", + child: () => sharedTransform, + parent: () => sharedTransform.optional(), + }, + { + name: "a separately redeclared embedding of equal dimensions", + child: () => embedding(384), + parent: () => embedding(384), + }, + { + name: "embeddings of different metrics, which validate the same values", + child: () => embedding(3, { metric: "l2" }), + parent: () => embedding(3), + }, + { + name: "an embedding under a plain number array", + child: () => embedding(3), + parent: () => z.array(z.number()), + }, + { + name: "a number array of the embedding's length under the embedding", + child: () => z.array(z.number()).length(3), + parent: () => embedding(3), + }, + ]; + + it.each([ + { + name: "embeddings of different dimensions", + child: () => embedding(3), + parent: () => embedding(4), + }, + { + name: "a plain number array under an embedding", + child: () => z.array(z.number()), + parent: () => embedding(3), + }, + { + name: "a number array one longer than the embedding", + child: () => z.array(z.number()).min(3).max(4), + parent: () => embedding(3), + }, + { + name: "a number array one shorter than the embedding", + child: () => z.array(z.number()).min(2).max(3), + parent: () => embedding(3), + }, + ])("refuses $name as a bounds mismatch", ({ child, parent }) => { + expect(liveVerdict(child(), parent())).toEqual({ + verdict: "not-subtype", + reason: "array-bounds-not-tighter", + path: ["field"], + }); + }); + + it("refuses a string array of the embedding's length under the embedding", () => { + expect( + liveVerdict(z.array(z.string()).length(3), embedding(3)), + ).toMatchObject({ verdict: "not-subtype", path: ["field", "[]"] }); + }); + + it.each(accepted)("accepts $name", ({ child, parent }) => { + expect(liveVerdict(child(), parent())).toEqual({ verdict: "subtype" }); + }); +}); + +/** A stored value as a row holds it: an `undefined` member is absent. */ +function asStoredRow(value: unknown): unknown { + if (value === null || typeof value !== "object" || value instanceof Date) { + return value; + } + return Object.fromEntries( + Object.entries(value) + .filter(([, member]) => member !== undefined) + .map(([key, member]) => [key, asStoredRow(member)]), + ); +} + +function trim(value: unknown): unknown { + return typeof value === "string" ? value.trim() : value; +} + +describe("structural subsumption over what a node stores beyond its projection", () => { + const epoch = new Date(0); + + // Each child stores `witness` from `input`, and the parent refuses it. + const refused: readonly Readonly<{ + name: string; + child: () => z.ZodType; + parent: () => z.ZodType; + input: unknown; + }>[] = [ + { + name: "z.bigint() under z.uint64(), whose bound is its own check", + child: () => z.bigint(), + parent: () => z.uint64(), + input: -1n, + }, + { + name: "z.bigint() under z.int64()", + child: () => z.bigint(), + parent: () => z.int64(), + input: 2n ** 63n, + }, + { + name: "z.string() under a pipe whose input side is bounded", + child: () => z.string(), + parent: () => z.string().min(5).pipe(z.string()), + input: "a", + }, + { + name: "z.string().min(3) under a preprocessed z.string().min(3)", + child: () => z.string().min(3), + parent: () => z.preprocess((value) => trim(value), z.string().min(3)), + input: " ab ", + }, + { + name: "z.date() under a bounded date piped into z.any()", + child: () => z.date(), + parent: () => z.date().min(epoch).pipe(z.any()), + input: new Date(-1), + }, + { + name: "a child defaulting to a value its parent refuses", + child: () => z.string().min(1).default(""), + parent: () => z.string().min(1), + input: undefined, + }, + { + name: "a child catching to a value its parent refuses", + child: () => z.string().min(1).catch(""), + parent: () => z.string().min(1), + input: 5, + }, + { + name: "a child whose wrapped default the outer catch hides", + // The default stores a number, unvalidated; only the catch value + // reaches the projection's `default`. + child: () => + z + .string() + .default(5 as unknown as string) + .catch("ok"), + parent: () => z.string(), + input: undefined, + }, + { + name: "z.string().min(3) under a codec whose decode shortens the value", + child: () => z.string().min(3), + parent: () => + z.codec(z.string(), z.string().min(3), { + decode: (value) => value.slice(1), + encode: (value) => value, + }), + input: "abc", + }, + { + name: "an omitted field under a pipe that refuses a missing key it does not list as required", + child: () => z.string().optional(), + parent: () => z.unknown().pipe(z.string().optional()), + input: undefined, + }, + { + name: "a defaulted union whose first member admits a missing value", + child: () => z.union([z.unknown(), z.boolean().default(true)]), + parent: () => z.unknown(), + input: undefined, + }, + { + name: "a separately redeclared field whose default fails the parent", + child: () => z.string().min(1).default(""), + parent: () => sharedBadDefault, + input: undefined, + }, + { + name: "an exhaustive record with optional values under one with required values", + child: () => z.record(z.enum(["a", "b"]), z.string().optional()), + parent: () => z.record(z.enum(["a", "b"]), z.string()), + input: {}, + }, + { + name: "a field Zod leaves out of what it stores", + child: () => z.preprocess((value) => value, z.unknown()), + parent: () => z.unknown(), + input: undefined, + }, + ]; + + const sharedBadDefault = z.string().min(1).default(""); + const sharedEmailDefault = z.email().default("team@example.com"); + const sharedDateDefault = z.date().default(() => new Date(0)); + + it.each(refused)("refuses $name", ({ child, parent, input }) => { + const childSchema = z.object({ field: child() }); + const parentSchema = z.object({ field: parent() }); + const stored = childSchema.safeParse( + input === undefined ? {} : { field: input }, + ); + expect(stored.success).toBe(true); + expect(parentSchema.safeParse(asStoredRow(stored.data)).success).toBe( + false, + ); + expect( + isStructuralSubtype( + structuralSubtypeProjection(childSchema), + structuralSubtypeProjection(parentSchema), + ).verdict, + ).not.toBe("subtype"); + }); + + it.each([ + { + name: "a default its parent admits", + child: () => z.string().min(1).default("x"), + parent: () => z.string().min(1), + }, + { + name: "a structured default its parent admits", + child: () => z.object({ x: z.number() }).default({ x: 1 }), + parent: () => z.object({ x: z.number() }), + }, + { + name: "a bounded child under a parent whose default is never stored for it", + child: () => z.string().min(3), + parent: () => z.string().min(3).default("abc"), + }, + { + name: "a pipe whose input side the output judges in full", + child: () => z.string().min(3), + parent: () => z.string().pipe(z.string().min(2)), + }, + { + name: "an exhaustive record with required values under one with optional values", + child: () => z.record(z.enum(["a", "b"]), z.string()), + parent: () => z.record(z.enum(["a", "b"]), z.string().optional()), + }, + { + name: "plain bigints", + child: () => z.bigint(), + parent: () => z.bigint(), + }, + { + // Both sides store `""` for an absent input: the child stores nothing + // the parent could not store itself. + name: "a shared field whose default fails its own check", + child: () => sharedBadDefault, + parent: () => sharedBadDefault, + }, + { + name: "a shared format field with a default", + child: () => sharedEmailDefault, + parent: () => sharedEmailDefault, + }, + { + name: "a shared date field with a default factory", + child: () => sharedDateDefault, + parent: () => sharedDateDefault, + }, + ])("accepts $name", ({ child, parent }) => { + expect(liveVerdict(child(), parent())).toEqual({ verdict: "subtype" }); + }); + + it("refuses an exhaustive record widening its parent's optional values", () => { + expect( + liveVerdict( + z.record(z.enum(["a", "b"]), z.string().optional()), + z.record(z.enum(["a", "b"]), z.string().min(1).optional()), + ), + ).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + }); + }); + + it("accepts a shared date field whose default factory differs on every projection", () => { + // Zod evaluates a default factory each time it projects, so the parent's + // and the child's projections list different dates for one field. + let tick = 0; + const createdAt = z.date().default(() => new Date(tick++)); + const parentSchema = z.object({ createdAt }); + const Ledger = defineNode("FactoryLedger", { schema: parentSchema }); + const Entry = defineNode("FactoryEntry", { + schema: parentSchema.extend({ amount: z.number() }), + }); + for (let build = 0; build < 3; build += 1) { + expect(() => + buildKindRegistry( + defineGraph({ + id: `shared_date_factory_${build}`, + nodes: { + FactoryLedger: { type: Ledger }, + FactoryEntry: { type: Entry }, + }, + edges: {}, + ontology: [subClassOf(Entry, Ledger)], + }), + ), + ).not.toThrow(); + } + expect(tick).toBeGreaterThan(1); + }); + + it("refuses a separately redeclared format default it cannot judge by value", () => { + expect( + liveVerdict(z.email().default("a@b.co"), z.email()).verdict, + ).not.toBe("subtype"); + }); + + it("refuses a parent whose present value its default does not widen", () => { + expect( + liveVerdict(z.string(), z.string().min(3).default("abc")), + ).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + }); + }); +}); diff --git a/packages/typegraph/tests/structural-subtype.test.ts b/packages/typegraph/tests/structural-subtype.test.ts new file mode 100644 index 000000000..513c79ec6 --- /dev/null +++ b/packages/typegraph/tests/structural-subtype.test.ts @@ -0,0 +1,1980 @@ +/** + * Structural subtyping predicate tests. + * + * `isStructuralSubtype` answers a different question than + * `isBreakingPropertyChange` / `computeSchemaDiff` (schema/migration.ts): + * "is every value satisfying `child` guaranteed to satisfy `parent`", judged + * purely on the projected JSON Schema. The "divergence from the migration + * predicate" block below pins the two cases where the two predicates give + * opposite verdicts over the same pair of schemas. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { searchable } from "../src/core/searchable"; +import { + computeSchemaDiff, + isStructuralSubtype, + type StructuralSubtypeResult, +} from "../src/schema"; +import { NON_CONSTRAINING_KEYWORDS } from "../src/schema/migration"; +import { serializeSchemaProperties } from "../src/schema/serializer"; +import { + COMPARABLE_KEYWORDS, + KNOWN_IGNORED_KEYWORDS, + UNMODELED_CONSTRAINING_KEYWORDS, +} from "../src/schema/structural-subtype"; +import { + type JsonSchema, + type SerializedNodeDef, + type SerializedOntology, + type SerializedSchema, +} from "../src/schema/types"; +import { requireDefined } from "../src/utils/presence"; + +// ============================================================ +// Test Helpers +// ============================================================ + +function projected(schema: z.ZodType): JsonSchema { + return serializeSchemaProperties(schema); +} + +/** + * Every keyword `schema` carries, recursing only through JSON-Schema + * COMPOSITION edges (`properties` values, an object-valued + * `additionalProperties`, `propertyNames`, `items`, `prefixItems` members, + * `anyOf`/`oneOf` members) — never through a property NAME itself, which is + * data, not a keyword. Used by the "projection coverage" ratchet below. + */ +function collectProjectedKeywords(schema: JsonSchema, into: Set): void { + for (const key of Object.keys(schema)) into.add(key); + if (schema.properties !== undefined) { + for (const propertySchema of Object.values(schema.properties)) { + collectProjectedKeywords(propertySchema, into); + } + } + if (typeof schema.additionalProperties === "object") { + collectProjectedKeywords(schema.additionalProperties, into); + } + if (schema.propertyNames !== undefined) { + collectProjectedKeywords(schema.propertyNames, into); + } + if (typeof schema.items === "object") { + collectProjectedKeywords(schema.items, into); + } + for (const member of schema.prefixItems ?? []) { + collectProjectedKeywords(member, into); + } + for (const member of schema.anyOf ?? schema.oneOf ?? []) { + collectProjectedKeywords(member, into); + } +} + +function verdict(child: z.ZodType, parent: z.ZodType): StructuralSubtypeResult { + return isStructuralSubtype(projected(child), projected(parent)); +} + +type ExpectedVerdict = + "subtype" | Readonly<{ reason: string }> | Readonly<{ incomparable: string }>; + +function assertVerdict( + result: StructuralSubtypeResult, + expected: ExpectedVerdict, +): void { + if (expected === "subtype") { + expect(result).toEqual({ verdict: "subtype" }); + return; + } + if ("incomparable" in expected) { + expect(result).toMatchObject({ + verdict: "incomparable", + reason: expected.incomparable, + }); + return; + } + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: expected.reason, + }); +} + +function emptyOntology(): SerializedOntology { + return { + metaEdges: {}, + relations: [], + closures: { + subClassAncestors: {}, + subClassDescendants: {}, + broaderClosure: {}, + narrowerClosure: {}, + equivalenceSets: {}, + disjointPairs: [], + partOfClosure: {}, + hasPartClosure: {}, + iriToKind: {}, + edgeInverses: {}, + edgeImplicationsClosure: {}, + edgeImplyingClosure: {}, + }, + }; +} + +function nodeDef(kind: string, schema: z.ZodType): SerializedNodeDef { + return { + kind, + properties: projected(schema), + uniqueConstraints: [], + onDelete: "restrict", + description: undefined, + }; +} + +function schemaWith(kind: string, schema: z.ZodType): SerializedSchema { + return { + graphId: "test", + version: 1, + generatedAt: "2024-01-01T00:00:00Z", + nodes: { [kind]: nodeDef(kind, schema) }, + edges: {}, + ontology: emptyOntology(), + defaults: { + onNodeDelete: "restrict", + temporalMode: "current", + }, + }; +} + +// A nested object schema `depth` levels deep, differing only at the leaf via +// `leafMinLength` — used to force recursion past `MAX_STRUCTURAL_SUBTYPE_DEPTH` +// without the top-level equality fast path short-circuiting the walk. +function nestedObjectSchema(depth: number, leafMinLength: number): JsonSchema { + if (depth <= 0) { + return { type: "string", minLength: leafMinLength }; + } + return { + type: "object", + properties: { child: nestedObjectSchema(depth - 1, leafMinLength) }, + required: ["child"], + additionalProperties: false, + }; +} + +// ============================================================ +// Object width and depth +// ============================================================ + +describe("object width and depth", () => { + const rows: readonly { + name: string; + child: z.ZodType; + parent: z.ZodType; + expected: ExpectedVerdict; + }[] = [ + { + name: "child adds an optional property", + child: z.object({ name: z.string(), nickname: z.string().optional() }), + parent: z.object({ name: z.string() }), + expected: "subtype", + }, + { + name: "child adds a required property (the reviewer's case)", + child: z.object({ name: z.string(), employeeId: z.string() }), + parent: z.object({ name: z.string() }), + expected: "subtype", + }, + { + name: "child omits an optional parent property", + child: z.object({ name: z.string() }), + parent: z.object({ name: z.string(), nickname: z.string().optional() }), + expected: "subtype", + }, + { + name: "child omits a required parent property", + child: z.object({ name: z.string() }), + parent: z.object({ name: z.string(), nickname: z.string() }), + expected: { reason: "missing-required-property" }, + }, + { + name: "child declares a parent-required property as optional", + child: z.object({ name: z.string().optional() }), + parent: z.object({ name: z.string() }), + expected: { reason: "optional-in-child-required-in-parent" }, + }, + { + name: "nested object: child tightens a nested property", + child: z.object({ address: z.object({ zip: z.string().min(5) }) }), + parent: z.object({ address: z.object({ zip: z.string() }) }), + expected: "subtype", + }, + { + name: "nested object: child loosens a nested property", + child: z.object({ address: z.object({ zip: z.string() }) }), + parent: z.object({ address: z.object({ zip: z.string().min(5) }) }), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "child re-declares a parent property with a mismatched type", + child: z.object({ age: z.string() }), + parent: z.object({ age: z.number() }), + expected: { reason: "type-token-mismatch" }, + }, + ]; + + it.each(rows.map((row) => [row.name, row] as const))("%s", (_name, row) => { + assertVerdict(verdict(row.child, row.parent), row.expected); + }); + + it('has path ["nickname"] for a missing required property', () => { + const result = verdict( + z.object({ name: z.string() }), + z.object({ name: z.string(), nickname: z.string() }), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "missing-required-property", + path: ["nickname"], + }); + }); + + it('has path ["address","zip"] for a loosened nested property', () => { + const result = verdict( + z.object({ address: z.object({ zip: z.string() }) }), + z.object({ address: z.object({ zip: z.string().min(5) }) }), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + path: ["address", "zip"], + }); + }); + + it("a required child property that the parent leaves optional is a subtype (reverse of the optional-in-child row)", () => { + // Mirror of "child declares a parent-required property as optional": + // swap child/parent and expect the reverse (subtype) verdict. + assertVerdict( + verdict( + z.object({ name: z.string() }), + z.object({ name: z.string().optional() }), + ), + "subtype", + ); + }); + + it("reverse of 'child re-declares a parent property with a mismatched type' is also type-token-mismatch", () => { + assertVerdict( + verdict(z.object({ age: z.number() }), z.object({ age: z.string() })), + { reason: "type-token-mismatch" }, + ); + }); +}); + +// ============================================================ +// additionalProperties +// ============================================================ + +describe("additionalProperties", () => { + it("z.object child adds a field beyond a z.object parent -> subtype (parent's additionalProperties:false not applied)", () => { + assertVerdict( + verdict( + z.object({ a: z.string(), b: z.number() }), + z.object({ a: z.string() }), + ), + "subtype", + ); + }); + + it("parent .catchall(number), child adds a matching extra field -> subtype", () => { + assertVerdict( + verdict( + z.object({ a: z.string(), extra: z.number() }), + z.object({ a: z.string() }).catchall(z.number()), + ), + "subtype", + ); + }); + + it("parent .catchall(number), child adds a mismatched extra field -> type-token-mismatch at extra", () => { + const result = verdict( + z.object({ a: z.string(), extra: z.string() }), + z.object({ a: z.string() }).catchall(z.number()), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "type-token-mismatch", + path: ["extra"], + }); + }); + + it("parent .catchall(number), child z.looseObject -> failure at {additionalProperties}", () => { + const result = verdict( + z.looseObject({ a: z.string() }), + z.object({ a: z.string() }).catchall(z.number()), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + path: ["{additionalProperties}"], + }); + }); + + it("parent z.looseObject, child z.object -> subtype (child forbids extras, tighter)", () => { + assertVerdict( + verdict(z.object({ a: z.string() }), z.looseObject({ a: z.string() })), + "subtype", + ); + }); + + // An OPEN child does not actually omit a parent-declared property it + // leaves unnamed — any value satisfying the child could still carry that + // key, so the child's additional-properties schema must narrow the + // parent's declared property rather than being skipped. Before the fix, + // each of these three constructors evaded the parent's typed `b` entirely. + it("z.looseObject child evading a parent's optional typed property is refused, not accepted", () => { + const result = verdict( + z.looseObject({ a: z.string() }), + z.object({ a: z.string(), b: z.number().optional() }), + ); + expect(result.verdict).not.toBe("subtype"); + expect(result).toMatchObject({ path: ["b"] }); + }); + + it("z.record child evading a parent's optional typed property is refused, not accepted", () => { + const result = verdict( + z.record(z.string(), z.string()), + z.object({ b: z.number().optional() }), + ); + expect(result.verdict).not.toBe("subtype"); + expect(result).toMatchObject({ path: ["b"] }); + }); + + it(".catchall child evading a parent's optional typed property is refused, not accepted", () => { + const result = verdict( + z.object({ a: z.string() }).catchall(z.string()), + z.object({ a: z.string(), b: z.number().optional() }), + ); + expect(result.verdict).not.toBe("subtype"); + expect(result).toMatchObject({ path: ["b"] }); + }); +}); + +// ============================================================ +// records +// ============================================================ + +describe("records", () => { + it("record ⊑ record with the same value type -> subtype", () => { + assertVerdict( + verdict( + z.record(z.string(), z.number()), + z.record(z.string(), z.number()), + ), + "subtype", + ); + }); + + it("record ⊑ record with a mismatched value type -> type-token-mismatch", () => { + const result = verdict( + z.record(z.string(), z.string()), + z.record(z.string(), z.number()), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "type-token-mismatch", + path: ["{additionalProperties}"], + }); + }); + + it("a closed object whose properties fit the record's value type ⊑ record -> subtype", () => { + assertVerdict( + verdict( + z.object({ a: z.number(), b: z.number() }), + z.record(z.string(), z.number()), + ), + "subtype", + ); + }); + + it("record over an enum key set ⊑ record over string keys -> subtype", () => { + assertVerdict( + verdict( + z.record(z.enum(["a", "b"]), z.string()), + z.record(z.string(), z.string()), + ), + "subtype", + ); + assertVerdict( + verdict( + z.partialRecord(z.enum(["a", "b"]), z.string()), + z.record(z.string().max(1), z.string()), + ), + "subtype", + ); + }); + + it("record over tighter string keys ⊑ record over looser string keys -> subtype", () => { + assertVerdict( + verdict( + z.record(z.string().max(3), z.number()), + z.record(z.string().max(10), z.number()), + ), + "subtype", + ); + }); + + it("record whose enum key set has one key the parent's key pattern refuses -> property-names-mismatch", () => { + expect( + verdict( + z.record(z.enum(["a", "Bad"]), z.string()), + z.record(z.string().regex(/^[a-z]+$/), z.string()), + ), + ).toEqual({ + verdict: "not-subtype", + reason: "property-names-mismatch", + path: ["{propertyNames}"], + }); + }); + + it("record over string keys ⊑ record over an enum key set -> property-names-mismatch", () => { + expect( + verdict( + z.record(z.string(), z.string()), + z.partialRecord(z.enum(["a", "b"]), z.string()), + ), + ).toEqual({ + verdict: "not-subtype", + reason: "property-names-mismatch", + path: ["{propertyNames}"], + }); + }); + + it("record ⊑ an object requiring a specific field -> missing-required-property (the record does not guarantee it)", () => { + const result = verdict( + z.record(z.string(), z.number()), + z.object({ a: z.number() }), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "missing-required-property", + }); + }); +}); + +// ============================================================ +// Scalars +// ============================================================ + +describe("scalars", () => { + const rows: readonly { + name: string; + child: z.ZodType; + parent: z.ZodType; + expected: ExpectedVerdict; + }[] = [ + { + name: "z.string().min(3) subtype z.string().min(1)", + child: z.string().min(3), + parent: z.string().min(1), + expected: "subtype", + }, + { + name: "z.string().min(1) not subtype z.string().min(3)", + child: z.string().min(1), + parent: z.string().min(3), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "z.string().max(5) subtype z.string().max(10)", + child: z.string().max(5), + parent: z.string().max(10), + expected: "subtype", + }, + { + name: "z.string().max(10) not subtype z.string().max(5)", + child: z.string().max(10), + parent: z.string().max(5), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "child adds a pattern the parent lacks -> subtype", + child: z.string().regex(/^[a-z]+$/), + parent: z.string(), + expected: "subtype", + }, + { + name: "child changes an existing parent pattern -> pattern-mismatch", + child: z.string().regex(/^[0-9]+$/), + parent: z.string().regex(/^[a-z]+$/), + expected: { reason: "pattern-mismatch" }, + }, + { + name: "child drops the parent's pattern -> pattern-mismatch", + child: z.string(), + parent: z.string().regex(/^[a-z]+$/), + expected: { reason: "pattern-mismatch" }, + }, + { + name: "z.email() subtype z.string()", + child: z.email(), + parent: z.string(), + expected: "subtype", + }, + { + name: "z.string() not subtype z.email()", + child: z.string(), + parent: z.email(), + expected: { reason: "pattern-mismatch" }, + }, + { + name: "z.number().min(2).max(8) subtype z.number().min(1).max(10)", + child: z.number().min(2).max(8), + parent: z.number().min(1).max(10), + expected: "subtype", + }, + { + name: "z.number().min(0).max(11) not subtype z.number().min(1).max(10) (widened lower)", + child: z.number().min(0).max(10), + parent: z.number().min(1).max(10), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.number().min(1).max(11) not subtype z.number().min(1).max(10) (widened upper)", + child: z.number().min(1).max(11), + parent: z.number().min(1).max(10), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.number().gt(1) subtype z.number().min(1) (exclusive tighter at equal value)", + child: z.number().gt(1), + parent: z.number().min(1), + expected: "subtype", + }, + { + name: "z.number().min(1) not subtype z.number().gt(1)", + child: z.number().min(1), + parent: z.number().gt(1), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.number().max(1) not subtype z.number().lt(1) (inclusive upper bound at the exclusive one's value)", + child: z.number().max(1), + parent: z.number().lt(1), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.number().lt(1) subtype z.number().max(1) (exclusive tighter at equal value)", + child: z.number().lt(1), + parent: z.number().max(1), + expected: "subtype", + }, + { + name: "z.number().lt(1).int() subtype z.number().lt(1)", + child: z.number().lt(1).int(), + parent: z.number().lt(1), + expected: "subtype", + }, + { + name: "child drops the parent's multipleOf -> multiple-of-mismatch", + child: z.number(), + parent: z.number().multipleOf(2), + expected: { reason: "multiple-of-mismatch" }, + }, + { + name: "z.enum(['a']) subtype z.enum(['a','b'])", + child: z.enum(["a"]), + parent: z.enum(["a", "b"]), + expected: "subtype", + }, + { + name: "z.enum(['a','b']) not subtype z.enum(['a'])", + child: z.enum(["a", "b"]), + parent: z.enum(["a"]), + expected: { reason: "value-set-not-subset" }, + }, + { + name: "z.literal('a') subtype z.enum(['a','b'])", + child: z.literal("a"), + parent: z.enum(["a", "b"]), + expected: "subtype", + }, + { + name: "z.literal('c') not subtype z.enum(['a','b'])", + child: z.literal("c"), + parent: z.enum(["a", "b"]), + expected: { reason: "value-set-not-subset" }, + }, + { + name: "z.string() not subtype z.enum(['a','b'])", + child: z.string(), + parent: z.enum(["a", "b"]), + expected: { reason: "value-set-not-subset" }, + }, + { + name: "z.boolean() subtype z.boolean()", + child: z.boolean(), + parent: z.boolean(), + expected: "subtype", + }, + { + name: "z.boolean() not subtype z.string()", + child: z.boolean(), + parent: z.string(), + expected: { reason: "type-token-mismatch" }, + }, + { + name: "z.int() subtype z.number() (integer narrows number)", + child: z.int(), + parent: z.number(), + expected: "subtype", + }, + // `z.url()` and `z.jwt()` project `format` with NO accompanying + // `pattern` (unlike every other format-bearing construct, e.g. + // `z.email()`/`z.uuid()`), so `format` must itself be a constraint or a + // bare `z.string()` is unsoundly accepted as a subtype of either. + { + name: "z.url() subtype z.string()", + child: z.url(), + parent: z.string(), + expected: "subtype", + }, + { + name: "z.string() not subtype z.url() -> format-mismatch", + child: z.string(), + parent: z.url(), + expected: { reason: "format-mismatch" }, + }, + { + name: "z.jwt() subtype z.string()", + child: z.jwt(), + parent: z.string(), + expected: "subtype", + }, + { + name: "z.string() not subtype z.jwt() -> format-mismatch", + child: z.string(), + parent: z.jwt(), + expected: { reason: "format-mismatch" }, + }, + { + name: "z.url() not subtype z.jwt() -> format-mismatch (both format-only, no pattern)", + child: z.url(), + parent: z.jwt(), + expected: { reason: "format-mismatch" }, + }, + ]; + + it.each(rows.map((row) => [row.name, row] as const))("%s", (_name, row) => { + assertVerdict(verdict(row.child, row.parent), row.expected); + }); + + it("format equal on both sides does not block subtyping", () => { + const withEmailFormat: JsonSchema = { + type: "string", + format: "email", + pattern: "^a$", + }; + const withSameFormatLooserPattern: JsonSchema = { + type: "string", + format: "email", + }; + // child (no pattern) is NOT tighter than a parent requiring "^a$", so + // only the reverse direction is a subtype. + expect( + isStructuralSubtype(withEmailFormat, withSameFormatLooserPattern), + ).toEqual({ verdict: "subtype" }); + }); +}); + +// ============================================================ +// Finite value sets under bounded parents +// ============================================================ + +/** Every value a projected schema pins with `const`/`enum`, through unions. */ +function pinnedValuesOf(schema: JsonSchema): readonly unknown[] { + if (Object.hasOwn(schema, "const")) return [schema.const]; + if (schema.enum !== undefined) return schema.enum; + return (schema.anyOf ?? []).flatMap((member) => pinnedValuesOf(member)); +} + +describe("finite value sets under bounded parents", () => { + const rows: readonly { + name: string; + child: z.ZodType; + parent: z.ZodType; + expected: ExpectedVerdict; + }[] = [ + { + name: "z.literal('x') subtype z.string().min(1)", + child: z.literal("x"), + parent: z.string().min(1), + expected: "subtype", + }, + { + name: "z.enum(['a','b']) subtype z.string().max(10)", + child: z.enum(["a", "b"]), + parent: z.string().max(10), + expected: "subtype", + }, + { + name: "z.literal(5) subtype z.number().min(0)", + child: z.literal(5), + parent: z.number().min(0), + expected: "subtype", + }, + { + name: "z.literal(5) subtype z.int() (an integer value satisfies the integer token)", + child: z.literal(5), + parent: z.int(), + expected: "subtype", + }, + { + name: "z.literal([1, 2]) subtype z.number().gt(0).lt(3)", + child: z.literal([1, 2]), + parent: z.number().gt(0).lt(3), + expected: "subtype", + }, + { + name: "z.enum(['abc','abd']) subtype z.string().regex(/^ab/)", + child: z.enum(["abc", "abd"]), + parent: z.string().regex(/^ab/), + expected: "subtype", + }, + { + name: "z.literal('apple') subtype z.string().startsWith('a') (a pattern-stated format)", + child: z.literal("apple"), + parent: z.string().startsWith("a"), + expected: "subtype", + }, + { + name: "z.enum(['ab','cd']) subtype z.string().lowercase()", + child: z.enum(["ab", "cd"]), + parent: z.string().lowercase(), + expected: "subtype", + }, + { + name: "z.literal([2, 4]) subtype z.number().multipleOf(2)", + child: z.literal([2, 4]), + parent: z.number().multipleOf(2), + expected: "subtype", + }, + { + name: "z.literal(0.3) subtype z.number().multipleOf(0.1) (decided on decimals, not floats)", + child: z.literal(0.3), + parent: z.number().multipleOf(0.1), + expected: "subtype", + }, + { + name: "z.literal('😀') subtype z.string().min(1).max(2) (one code point, two UTF-16 units)", + child: z.literal("😀"), + parent: z.string().min(1).max(2), + expected: "subtype", + }, + { + name: "z.enum(['a','b']).nullable() subtype z.string().max(1).nullable()", + child: z.enum(["a", "b"]).nullable(), + parent: z.string().max(1).nullable(), + expected: "subtype", + }, + { + name: "z.literal('') not subtype z.string().min(1)", + child: z.literal(""), + parent: z.string().min(1), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "z.enum(['a','toolong']) not subtype z.string().max(3) (one member out of bounds)", + child: z.enum(["a", "toolong"]), + parent: z.string().max(3), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "z.literal('😀') not subtype z.string().max(1) (Zod counts two UTF-16 units)", + child: z.literal("😀"), + parent: z.string().max(1), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "z.literal('😀') not subtype z.string().min(2) (JSON Schema counts one code point)", + child: z.literal("😀"), + parent: z.string().min(2), + expected: { reason: "string-length-not-tighter" }, + }, + { + name: "z.literal('ba') not subtype z.string().regex(/^a/)", + child: z.literal("ba"), + parent: z.string().regex(/^a/), + expected: { reason: "pattern-mismatch" }, + }, + { + name: "z.literal('A') not subtype z.string().lowercase()", + child: z.literal("A"), + parent: z.string().lowercase(), + expected: { reason: "pattern-mismatch" }, + }, + { + name: "z.literal('a@b.co') not subtype z.email() (a standard format is not evaluated)", + child: z.literal("a@b.co"), + parent: z.email(), + expected: { reason: "format-mismatch" }, + }, + { + name: "z.literal(-1) not subtype z.number().min(0)", + child: z.literal(-1), + parent: z.number().min(0), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.literal(0) not subtype z.number().gt(0)", + child: z.literal(0), + parent: z.number().gt(0), + expected: { reason: "numeric-bound-not-tighter" }, + }, + { + name: "z.literal([2, 3]) not subtype z.number().multipleOf(2) (one member not a multiple)", + child: z.literal([2, 3]), + parent: z.number().multipleOf(2), + expected: { reason: "multiple-of-mismatch" }, + }, + { + name: "z.literal(0.1 + 0.2) not subtype z.number().multipleOf(0.1) (not a decimal multiple)", + child: z.literal(0.1 + 0.2), + parent: z.number().multipleOf(0.1), + expected: { reason: "multiple-of-mismatch" }, + }, + { + name: "z.literal(1.5) not subtype z.int()", + child: z.literal(1.5), + parent: z.int(), + expected: { reason: "type-token-mismatch" }, + }, + { + name: "z.literal('5') not subtype z.number()", + child: z.literal("5"), + parent: z.number(), + expected: { reason: "type-token-mismatch" }, + }, + ]; + + it.each(rows.map((row) => [row.name, row] as const))("%s", (_name, row) => { + const result = verdict(row.child, row.parent); + assertVerdict(result, row.expected); + if (row.expected !== "subtype") return; + // A positive row is only meaningful if Zod itself admits every value. + for (const value of pinnedValuesOf(projected(row.child))) { + expect(row.parent.safeParse(value).success).toBe(true); + } + }); + + it("judges an untyped const against the parent's type token", () => { + expect( + isStructuralSubtype({ const: "x" }, { type: "string", minLength: 1 }), + ).toEqual({ verdict: "subtype" }); + expect( + isStructuralSubtype({ enum: ["a", 1] }, { type: "string" }), + ).toMatchObject({ + verdict: "not-subtype", + reason: "unconstrained-where-parent-constrains", + }); + }); + + it("refuses a pattern one reading cannot compile, even for a value the other reading accepts", () => { + // `\-` is an identity escape without `u` and a syntax error with it. + expect(new RegExp(String.raw`^\-$`).test("-")).toBe(true); + expect( + isStructuralSubtype( + { type: "string", const: "-" }, + { type: "string", pattern: String.raw`^\-$` }, + ), + ).toMatchObject({ verdict: "not-subtype", reason: "pattern-mismatch" }); + }); + + it("requires a value to match the pattern with and without Unicode semantics", () => { + // Flagless, `.` is one UTF-16 unit and `😀` is two; with `u` it is one. + expect( + isStructuralSubtype( + { type: "string", const: "😀" }, + { type: "string", pattern: "^.$" }, + ), + ).toMatchObject({ verdict: "not-subtype", reason: "pattern-mismatch" }); + expect( + isStructuralSubtype( + { type: "string", const: "😀" }, + { type: "string", pattern: "^..$" }, + ), + ).toMatchObject({ verdict: "not-subtype", reason: "pattern-mismatch" }); + }); + + it("does not judge a value set holding a value of another type than the child's own", () => { + expect( + isStructuralSubtype( + { type: "string", enum: ["a", 99] }, + { type: "string", maxLength: 1 }, + ), + ).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + }); + // How a persisted document spells `z.literal(Infinity)`. + // eslint-disable-next-line unicorn/no-null -- JSON spells Infinity as null + const infinityLiteral: JsonSchema = { type: "number", const: null }; + expect( + isStructuralSubtype(infinityLiteral, { type: "string", minLength: 5 }), + ).toMatchObject({ verdict: "not-subtype", reason: "type-token-mismatch" }); + expect( + isStructuralSubtype(infinityLiteral, { type: "number", minimum: 0 }), + ).toMatchObject({ + verdict: "not-subtype", + reason: "numeric-bound-not-tighter", + }); + }); + + it("does not judge multipleOf where Zod's floating-point reading departs from the exact one", () => { + const rows = [ + // A subnormal divisor: Zod's relative tolerance no longer holds. + { value: 1e-320, divisor: 1e-321 }, + // An exact multiple whose floating-point quotient overflows to Infinity. + { value: 1e308, divisor: 1e-10 }, + ]; + for (const { value, divisor } of rows) { + expect(z.number().multipleOf(divisor).safeParse(value).success).toBe( + false, + ); + expect( + isStructuralSubtype( + { type: "number", const: value }, + { type: "number", multipleOf: divisor }, + ), + ).toMatchObject({ + verdict: "not-subtype", + reason: "multiple-of-mismatch", + }); + } + }); + + it("never satisfies a keyword vacuously through an empty value set", () => { + expect( + isStructuralSubtype( + { type: "string", enum: [] }, + { type: "string", minLength: 5 }, + ), + ).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + }); + expect( + isStructuralSubtype({ enum: [] }, { type: "boolean" }), + ).toMatchObject({ + verdict: "not-subtype", + reason: "unconstrained-where-parent-constrains", + }); + }); +}); + +// ============================================================ +// Arrays and tuples +// ============================================================ + +describe("a numeric bound stated both inclusively and exclusively", () => { + // Zod never projects both keywords on one side; a hand-written or + // persisted document can. The tighter of the two is the bound. + const rows: readonly Readonly<{ + name: string; + child: JsonSchema; + parent: JsonSchema; + expected: StructuralSubtypeResult["verdict"]; + }>[] = [ + { + name: "child minimum 2 beside exclusiveMinimum 1 is bounded at 2", + child: { type: "number", minimum: 2, exclusiveMinimum: 1 }, + parent: { type: "number", minimum: 2 }, + expected: "subtype", + }, + { + name: "child minimum 1 beside exclusiveMinimum 1 is bounded exclusively at 1", + child: { type: "number", minimum: 1, exclusiveMinimum: 1 }, + parent: { type: "number", exclusiveMinimum: 1 }, + expected: "subtype", + }, + { + name: "parent minimum 0 beside exclusiveMinimum 1 refuses an inclusive 1", + child: { type: "number", minimum: 1 }, + parent: { type: "number", minimum: 0, exclusiveMinimum: 1 }, + expected: "not-subtype", + }, + { + name: "child maximum 2 beside exclusiveMaximum 3 is bounded at 2", + child: { type: "number", maximum: 2, exclusiveMaximum: 3 }, + parent: { type: "number", maximum: 2 }, + expected: "subtype", + }, + { + name: "child maximum 1 beside exclusiveMaximum 1 is bounded exclusively at 1", + child: { type: "number", maximum: 1, exclusiveMaximum: 1 }, + parent: { type: "number", exclusiveMaximum: 1 }, + expected: "subtype", + }, + { + name: "parent maximum 5 beside exclusiveMaximum 1 refuses an inclusive 1", + child: { type: "number", maximum: 1 }, + parent: { type: "number", maximum: 5, exclusiveMaximum: 1 }, + expected: "not-subtype", + }, + ]; + + it.each(rows)("$name", ({ child, parent, expected }) => { + expect(isStructuralSubtype(child, parent).verdict).toBe(expected); + }); +}); + +describe("arrays and tuples", () => { + it("a tuple whose member does not narrow the parent tuple's member is not a subtype", () => { + expect(verdict(z.tuple([z.number()]), z.tuple([z.string()]))).toMatchObject( + { + verdict: "not-subtype", + reason: "type-token-mismatch", + path: ["[0]"], + }, + ); + }); + + it("a tuple whose member does not narrow a homogeneous parent's items is not a subtype", () => { + expect(verdict(z.tuple([z.number()]), z.array(z.string()))).toMatchObject({ + verdict: "not-subtype", + reason: "type-token-mismatch", + path: ["[0]"], + }); + }); + + it("an array with no items schema is not a subtype of one whose items are typed", () => { + expect( + isStructuralSubtype( + { type: "array" }, + { type: "array", items: { type: "string" } }, + ), + ).toEqual({ + verdict: "not-subtype", + reason: "unconstrained-where-parent-constrains", + path: ["[]"], + }); + }); + + it("z.array(z.string().min(2)) subtype z.array(z.string())", () => { + assertVerdict( + verdict(z.array(z.string().min(2)), z.array(z.string())), + "subtype", + ); + }); + + it("z.array(z.string()) not subtype z.array(z.string().min(2)) at []", () => { + const result = verdict(z.array(z.string()), z.array(z.string().min(2))); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + path: ["[]"], + }); + }); + + it("z.array(z.string()).min(2) subtype z.array(z.string()).min(1)", () => { + assertVerdict( + verdict(z.array(z.string()).min(2), z.array(z.string()).min(1)), + "subtype", + ); + }); + + it("z.array(z.string()).min(1) not subtype z.array(z.string()).min(2)", () => { + assertVerdict( + verdict(z.array(z.string()).min(1), z.array(z.string()).min(2)), + { reason: "array-bounds-not-tighter" }, + ); + }); + + it("z.array(z.string()).max(3) subtype z.array(z.string()).max(9)", () => { + assertVerdict( + verdict(z.array(z.string()).max(3), z.array(z.string()).max(9)), + "subtype", + ); + }); + + it("z.array(z.string()).max(9) not subtype z.array(z.string()).max(3)", () => { + assertVerdict( + verdict(z.array(z.string()).max(9), z.array(z.string()).max(3)), + { reason: "array-bounds-not-tighter" }, + ); + }); + + it("same-arity tuple with looser members -> subtype", () => { + assertVerdict( + verdict( + z.tuple([z.string().min(3), z.number()]), + z.tuple([z.string().min(1), z.number()]), + ), + "subtype", + ); + }); + + it("different-arity tuples -> tuple-arity-mismatch", () => { + assertVerdict( + verdict(z.tuple([z.string()]), z.tuple([z.string(), z.number()])), + { reason: "tuple-arity-mismatch" }, + ); + }); + + // A tuple-with-rest child (`z.tuple([...], rest)`) has the SAME + // `prefixItems` length as a closed parent tuple here, so the arity check + // above cannot catch it — only checking that the parent is also closed + // (no `items`) does. + it("a tuple with a rest element is not a subtype of a same-arity closed tuple", () => { + const result = verdict( + z.tuple([z.string()], z.number()), + z.tuple([z.string()]), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "tuple-arity-mismatch", + }); + }); + + it("a same-arity closed tuple is still a subtype of an equally closed tuple", () => { + assertVerdict( + verdict(z.tuple([z.string()]), z.tuple([z.string()])), + "subtype", + ); + }); + + it("z.tuple([z.string()]) subtype z.array(z.string())", () => { + assertVerdict( + verdict(z.tuple([z.string()]), z.array(z.string())), + "subtype", + ); + }); +}); + +// ============================================================ +// Unions and nullability +// ============================================================ + +describe("unions and nullability", () => { + it("z.string() subtype z.union([z.string(), z.number()])", () => { + assertVerdict( + verdict(z.string(), z.union([z.string(), z.number()])), + "subtype", + ); + }); + + // A union of bare primitives projects as a `type` token array, so the + // offending member is named by its token's position. + it("z.union([z.string(), z.number()]) not subtype z.string() at type[1]", () => { + const result = verdict(z.union([z.string(), z.number()]), z.string()); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "no-matching-union-member", + path: ["type[1]"], + }); + }); + + it("a union with a constrained member not subtype z.string() at anyOf[1]", () => { + const result = verdict( + z.union([z.string().min(2), z.number()]), + z.string(), + ); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "no-matching-union-member", + path: ["anyOf[1]"], + }); + }); + + // A stored document may spell the same union either way: `anyOf` members + // from an earlier projection, a `type` token array from the current one. + it("reads a type token array and the equivalent anyOf as the same union, in both directions", () => { + const tokens: JsonSchema = { type: ["string", "null"] }; + const members: JsonSchema = { + anyOf: [{ type: "string" }, { type: "null" }], + }; + expect(isStructuralSubtype(tokens, members)).toEqual({ + verdict: "subtype", + }); + expect(isStructuralSubtype(members, tokens)).toEqual({ + verdict: "subtype", + }); + }); + + it("carries a token array's sibling keywords into every member", () => { + const loose: JsonSchema = { type: ["string", "null"] }; + const tight: JsonSchema = { type: ["string", "null"], minLength: 3 }; + expect(isStructuralSubtype(tight, loose)).toEqual({ verdict: "subtype" }); + expect(isStructuralSubtype(loose, tight)).toMatchObject({ + verdict: "not-subtype", + reason: "no-matching-union-member", + path: ["type[0]"], + }); + }); + + it("z.string() subtype z.string().nullable()", () => { + assertVerdict(verdict(z.string(), z.string().nullable()), "subtype"); + }); + + it("z.string().nullable() not subtype z.string()", () => { + assertVerdict(verdict(z.string().nullable(), z.string()), { + reason: "no-matching-union-member", + }); + }); + + it("z.string().nullable() subtype z.string().nullish() (projection collapses both to the same anyOf)", () => { + assertVerdict( + verdict(z.string().nullable(), z.string().nullish()), + "subtype", + ); + }); + + it("a discriminated-union child whose members are a subset of the parent's oneOf -> subtype", () => { + const parent = z.discriminatedUnion("type", [ + z.object({ type: z.literal("a"), a: z.string() }), + z.object({ type: z.literal("b"), b: z.number() }), + ]); + const child = z.discriminatedUnion("type", [ + z.object({ type: z.literal("a"), a: z.string() }), + ]); + assertVerdict(verdict(child, parent), "subtype"); + }); + + // `oneOf` admits a value matching EXACTLY one member. Reading it as `anyOf` + // is sound only when no value can match two, which a discriminated union + // guarantees and `z.xor` does not. + it("an object satisfying two members of a z.xor parent is not judged a subtype", () => { + const parent = z.xor([ + z.object({ a: z.string() }), + z.object({ b: z.string() }), + ]); + const child = z.object({ a: z.string(), b: z.string() }); + const value = { a: "x", b: "y" }; + console.log( + "xor parent projection:", + JSON.stringify(projected(parent)), + "child accepts:", + child.safeParse(value).success, + "parent accepts:", + parent.safeParse(value).success, + ); + expect(child.safeParse(value).success).toBe(true); + expect(parent.safeParse(value).success).toBe(false); + + expect(verdict(child, parent)).toEqual({ + verdict: "incomparable", + reason: "unsupported-construct", + path: [], + }); + }); + + it("a z.xor parent whose members differ by type token is read member-wise", () => { + const parent = z.xor([z.string(), z.number()]); + assertVerdict(verdict(z.string().min(2), parent), "subtype"); + assertVerdict(verdict(z.boolean(), parent), { + reason: "no-matching-union-member", + }); + }); + + it("a parent oneOf whose members overlap through integer and number is incomparable", () => { + const parent: JsonSchema = { + oneOf: [{ type: "integer" }, { type: "number" }], + }; + expect(isStructuralSubtype({ type: "integer" }, parent)).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-construct", + }); + }); + + it("a parent carrying both anyOf and oneOf is incomparable rather than read through one of them", () => { + const parent: JsonSchema = { + anyOf: [{ type: "string" }, { type: "number" }], + oneOf: [{ type: "string", minLength: 3 }, { type: "number" }], + }; + expect(isStructuralSubtype({ type: "string" }, parent)).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-construct", + }); + }); + + it("a z.xor child is read as the union of its members", () => { + const child = z.xor([ + z.object({ a: z.string() }), + z.object({ b: z.string() }), + ]); + const parent = z.union([ + z.object({ a: z.string() }), + z.object({ b: z.string() }), + ]); + assertVerdict(verdict(child, parent), "subtype"); + }); + + it("a union member that is itself incomparable propagates incomparable, not no-matching-union-member", () => { + const result = verdict(z.union([z.string(), z.never()]), z.string()); + expect(result.verdict).toBe("incomparable"); + }); + + // A hand-written schema carrying both `type` and `anyOf` must still + // be read as a union — `propertyTypeSignature` (migration's construct + // SELECTOR, not a union detector) would read `type` first and hide the + // `anyOf` entirely, silently dropping the parent's union constraint. + it("a parent carrying both type and anyOf still enforces the union", () => { + const child: JsonSchema = { type: "string" }; + const parent: JsonSchema = { + type: "string", + anyOf: [{ const: "a" }, { const: "b" }], + }; + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "no-matching-union-member", + // The child isn't itself a union, so no `anyOf[i]` segment is + // synthesized onto a path the child schema doesn't actually have. + path: [], + }); + }); +}); + +// ============================================================ +// Union sibling keywords +// ============================================================ + +describe("union sibling keywords", () => { + // A union keyword is ANDed with any sibling keyword on the same + // schema object, not replaced by it. `compareUnion` alone only checks the + // OR half of that AND (does the child match some member); dropping the + // parent's sibling keywords entirely — as the predicate did before this + // fix — silently discards the AND half and wrongly accepts pairs the + // parent actually excludes. + it("a parent union member match does not excuse the parent's own type sibling", () => { + // Every value satisfying the child (a number) fails the parent's own + // `type: "string"`, regardless of what the parent's `anyOf` allows. + const child: JsonSchema = { type: "number" }; + const parent: JsonSchema = { + type: "string", + anyOf: [{ type: "number" }], + }; + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "type-token-mismatch", + }); + }); + + it("a parent union match does not excuse the parent's own minLength sibling", () => { + // The child (a bare string) admits "" — the parent's `anyOf` matches + // strings, but the parent ALSO requires minLength >= 5, which "" fails. + const child: JsonSchema = { type: "string" }; + const parent: JsonSchema = { + anyOf: [{ type: "string" }, { type: "number" }], + minLength: 5, + }; + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "string-length-not-tighter", + }); + }); + + it("a parent union match does not excuse the parent's own required property sibling", () => { + // The child object admits `{}` — the parent's `anyOf` matches plain + // objects, but the parent ALSO requires property "a", which `{}` lacks. + const child: JsonSchema = { + type: "object", + properties: {}, + required: [], + additionalProperties: false, + }; + const parent: JsonSchema = { + anyOf: [{ type: "object" }, { type: "null" }], + properties: { a: { type: "string" } }, + required: ["a"], + }; + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "missing-required-property", + path: ["a"], + }); + }); + + it("a parent union with no sibling keywords is unaffected (regression guard)", () => { + assertVerdict( + verdict(z.string(), z.union([z.string(), z.number()])), + "subtype", + ); + }); + + // The verdict must not depend on the declared order of the + // parent's union members — an `incomparable` member earlier in the list + // must not stop the search for a matching member later in the list. + it("compareUnion's verdict is independent of parent-member order", () => { + const child: JsonSchema = { type: "string" }; + const incomparableFirst: JsonSchema = { + anyOf: [{ not: {} }, { type: "string" }], + }; + const incomparableLast: JsonSchema = { + anyOf: [{ type: "string" }, { not: {} }], + }; + const resultA = isStructuralSubtype(child, incomparableFirst); + const resultB = isStructuralSubtype(child, incomparableLast); + expect(resultA).toEqual(resultB); + expect(resultA).toEqual({ verdict: "subtype" }); + }); +}); + +// ============================================================ +// Incomparable constructs +// ============================================================ + +describe("incomparable constructs", () => { + // A self-recursive schema's `$ref` sits at a nested position + // (`properties.children.items`), not at the schema's own top level, and an + // identical pair short-circuits through the equality fast path before ever + // visiting it (rule 2) — so the partner here must share the "children" + // shape but differ in its element type, forcing real recursion down to the + // `$ref` node instead of exercising a path that never reaches it. + function recursiveListSchema(): z.ZodType { + interface Recursive { + readonly children: readonly Recursive[]; + } + const recursive: z.ZodType = z.lazy(() => + z.object({ children: z.array(recursive) }), + ); + return recursive; + } + const nonRecursiveListSchema = z.object({ children: z.array(z.string()) }); + + it("a self-recursive schema is incomparable via schema-reference (child position)", () => { + const result = isStructuralSubtype( + projected(recursiveListSchema()), + projected(nonRecursiveListSchema), + ); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "schema-reference", + }); + }); + + it("a self-recursive schema is incomparable via schema-reference (parent position)", () => { + const result = isStructuralSubtype( + projected(nonRecursiveListSchema), + projected(recursiveListSchema()), + ); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "schema-reference", + }); + }); + + it("z.never() is incomparable via unsupported-keyword", () => { + const result = verdict(z.never(), z.string()); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-keyword", + }); + }); + + // An intersection the projection cannot merge into one schema stays an + // `allOf`, which this predicate does not model. + it("z.intersection(...) that projects as allOf is incomparable via unsupported-keyword", () => { + const result = verdict( + z.intersection(z.string(), z.string().min(3)), + z.string(), + ); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-keyword", + }); + }); + + // The projection merges an intersection of two object schemas into one + // ordinary object schema, which is judged like any other. + it("z.intersection(...) of two objects is judged as the merged object", () => { + assertVerdict( + verdict( + z.intersection( + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ), + z.object({ a: z.string() }), + ), + "subtype", + ); + }); + + it("z.file() is incomparable via unsupported-keyword", () => { + const result = verdict(z.file(), z.string()); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-keyword", + }); + }); + + it("a hand-written schema nested past MAX_STRUCTURAL_SUBTYPE_DEPTH is incomparable via max-depth-exceeded", () => { + const child = nestedObjectSchema(70, 2); + const parent = nestedObjectSchema(70, 1); + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "max-depth-exceeded", + }); + }); + + it("a searchable() field does not make a pair incomparable (src/core/searchable.ts:120)", () => { + // Built from the real `searchable()` tag (not a hand-written literal) so + // this test ratchets against a change to `SEARCHABLE_FIELD_KEY` or to the + // tag's projected shape. + const withSearchable = projected(searchable()); + assertVerdict( + isStructuralSubtype(withSearchable, withSearchable), + "subtype", + ); + assertVerdict( + isStructuralSubtype(withSearchable, projected(z.string())), + "subtype", + ); + }); + + it("an arbitrary .meta() key does not make a pair incomparable (unknown keywords are ignored for subtyping)", () => { + const child = z.object({ a: z.string().meta({ myKey: 1 }) }); + const parent = z.object({ a: z.string() }); + assertVerdict(verdict(child, parent), "subtype"); + assertVerdict(verdict(parent, child), "subtype"); + }); + + // Standard JSON Schema 2020-12 vocabulary keywords this predicate's + // rule set does not model must refuse rather than silently accept, even + // though the Zod projection never emits them — the public + // `isStructuralSubtype` surface takes an arbitrary hand-written schema. + it.each([ + [ + "uniqueItems", + { type: "array", items: { type: "string" }, uniqueItems: true }, + ], + ["minProperties", { type: "object", minProperties: 3 }], + [ + "patternProperties", + { type: "object", patternProperties: { "^x": { type: "string" } } }, + ], + ] as const)( + "a parent carrying an unmodeled %s keyword is incomparable", + (_label, parent) => { + const child: JsonSchema = + parent.type === "array" ? + { type: "array", items: { type: "string" } } + : { type: "object" }; + const result = isStructuralSubtype(child, parent); + expect(result).toMatchObject({ + verdict: "incomparable", + reason: "unsupported-keyword", + }); + }, + ); +}); + +// ============================================================ +// Divergence from the migration predicate +// ============================================================ + +describe("divergence from the migration predicate", () => { + it("accepts a child adding a required field that computeSchemaDiff calls breaking", () => { + const parent = z.object({ name: z.string() }); + const child = z.object({ name: z.string(), employeeId: z.string() }); + + // migration's question: "do rows valid before stay valid after?" -> no + const diff = computeSchemaDiff( + schemaWith("Person", parent), + schemaWith("Person", child), + ); + const change = requireDefined( + diff.nodes.find((entry) => entry.kind === "Person"), + ); + expect(change.severity).toBe("breaking"); + + // The registry check's question: "is every child value a valid parent value?" -> yes + expect(isStructuralSubtype(projected(child), projected(parent))).toEqual({ + verdict: "subtype", + }); + }); + + it("accepts a child making a required parent field optional, which computeSchemaDiff calls safe", () => { + const parent = z.object({ name: z.string(), nickname: z.string() }); + const child = z.object({ + name: z.string(), + nickname: z.string().optional(), + }); + + // migration's question: existing rows (nickname present) still validate -> safe + const diff = computeSchemaDiff( + schemaWith("Person", parent), + schemaWith("Person", child), + ); + const change = requireDefined( + diff.nodes.find((entry) => entry.kind === "Person"), + ); + expect(change.severity).toBe("safe"); + + // The registry check's question: the parent's guarantee that `nickname` is present no + // longer holds for every child value -> not a subtype + const result = isStructuralSubtype(projected(child), projected(parent)); + expect(result).toMatchObject({ + verdict: "not-subtype", + reason: "optional-in-child-required-in-parent", + }); + }); +}); + +// ============================================================ +// Projection coverage (ratchet against a future Zod upgrade) +// ============================================================ + +describe("projection coverage", () => { + // Every schema here is checked for reflexivity below (a sanity baseline — + // it holds for any schema this predicate can even parse, since equal + // projections always hit the equality fast path before any construct is + // examined, so it CANNOT catch a future Zod upgrade emitting an + // unclassified keyword; see the `it` below the `it.each` for the test that + // actually ratchets that). The list itself stays useful as the record of + // every Zod construct this predicate has been checked against. + const PROJECTION_CASES: readonly [string, z.ZodType][] = [ + ["z.string()", z.string()], + ["z.string().min(1)", z.string().min(1)], + ["z.string().max(10)", z.string().max(10)], + ["z.string().regex(...)", z.string().regex(/^[a-z]+$/)], + ["z.email()", z.email()], + ["z.uuid()", z.uuid()], + ["z.url()", z.url()], + ["z.jwt()", z.jwt()], + ["z.iso.date()", z.iso.date()], + ["z.templateLiteral", z.templateLiteral(["a", z.string()])], + ["z.string().startsWith", z.string().startsWith("x")], + ["z.string().includes", z.string().includes("x")], + ["z.string().nonempty()", z.string().nonempty()], + ["z.number()", z.number()], + ["z.number().min(1).max(10)", z.number().min(1).max(10)], + ["z.number().gt(1).lt(10)", z.number().gt(1).lt(10)], + ["z.number().multipleOf(2)", z.number().multipleOf(2)], + ["z.int()", z.int()], + ["z.boolean()", z.boolean()], + ["z.literal(scalar)", z.literal("x")], + // eslint-disable-next-line unicorn/no-null -- z.literal(null) is a real JSON Schema `const: null` construct under test + ["z.literal(null)", z.literal(null)], + ["z.enum homogeneous", z.enum(["a", "b"])], + ["z.literal([...]) mixed", z.literal(["a", 1])], + ["z.object", z.object({ a: z.string() })], + ["z.looseObject", z.looseObject({ a: z.string() })], + ["z.object.catchall", z.object({ a: z.string() }).catchall(z.number())], + ["z.record", z.record(z.string(), z.number())], + ["z.array", z.array(z.string())], + ["z.array bounded", z.array(z.string()).min(1).max(10)], + ["z.tuple no rest", z.tuple([z.string(), z.number()])], + ["z.tuple with rest", z.tuple([z.string()], z.number())], + ["z.union", z.union([z.string(), z.number()])], + ["z.string().nullable()", z.string().nullable()], + ["z.string().nullish()", z.string().nullish()], + [ + "z.discriminatedUnion", + z.discriminatedUnion("t", [z.object({ t: z.literal("a") })]), + ], + ["z.any()", z.any()], + ["z.unknown()", z.unknown()], + ["z.string().readonly()", z.string().readonly()], + [ + "z.string() with .meta title/description", + z.string().meta({ title: "T", description: "D" }), + ], + ["z.string().default('x')", z.string().default("x")], + ["z.string().catch('x')", z.string().catch("x")], + ]; + + it.each(PROJECTION_CASES)("%s is reflexive", (_label, schema) => { + const schemaProjection = projected(schema); + expect(isStructuralSubtype(schemaProjection, schemaProjection)).toEqual({ + verdict: "subtype", + }); + }); + + // The actual ratchet: walks every keyword the projection emits + // across all of PROJECTION_CASES — recursing only through JSON-Schema + // COMPOSITION edges (`properties` values, `items`, `prefixItems` members, + // `propertyNames`, an object-valued `additionalProperties`, `anyOf`/`oneOf` + // members), never through a property NAME itself — and asserts every one + // of them is a keyword this module has actually classified: comparable, + // known-but-unmodeled, known-non-constraining migration metadata, or a + // named-but-ignored annotation. A keyword outside all four buckets means a + // Zod upgrade started emitting something no one has looked at yet, and this + // is where that surfaces — reflexivity (above) cannot: it always hits the + // equality fast path before any keyword is examined, so it stays green even + // when `compareLeaf`'s entire rule set is gutted (confirmed by mutation). + // If this fails: add the reported keyword to COMPARABLE_KEYWORDS (if it + // should be compared for subtyping), UNMODELED_CONSTRAINING_KEYWORDS (if it + // constrains but is out of scope), or KNOWN_IGNORED_KEYWORDS (if it is a + // genuine non-constraining annotation) in structural-subtype.ts, and only + // then update this test. + it("every keyword the projection emits across PROJECTION_CASES has been classified", () => { + const observedKeywords = new Set(); + for (const [, schema] of PROJECTION_CASES) { + collectProjectedKeywords(projected(schema), observedKeywords); + } + const classifiedKeywords = new Set([ + ...COMPARABLE_KEYWORDS, + ...UNMODELED_CONSTRAINING_KEYWORDS, + ...NON_CONSTRAINING_KEYWORDS, + ...KNOWN_IGNORED_KEYWORDS, + ]); + const unclassifiedKeywords = [...observedKeywords] + .filter((keyword) => !classifiedKeywords.has(keyword)) + .toSorted(); + expect(unclassifiedKeywords).toEqual([]); + }); + + // Each entry supplies its own [child, parent] pair, DIFFERING enough that + // rule 2's full-schema identity check cannot fire — otherwise + // the pair would short-circuit to `subtype` before the walk ever reaches + // the construct under test. A self-recursive schema compared to an + // IDENTICAL copy of itself is exactly this trap for a NESTED `$ref` (the + // top-level shapes are equal even though the nested `$ref` targets are + // not resolved), so that entry needs a partner that differs enough to + // force real recursion (see the `schema-reference` tests above for why). + const INCOMPARABLE_BY_DESIGN: readonly [ + string, + () => readonly [JsonSchema, JsonSchema], + ][] = [ + [ + "self-recursive object vs. a same-shape non-recursive schema", + () => { + interface Recursive { + readonly children: readonly Recursive[]; + } + const recursive: z.ZodType = z.lazy(() => + z.object({ children: z.array(recursive) }), + ); + const nonRecursive = z.object({ children: z.array(z.string()) }); + return [projected(recursive), projected(nonRecursive)]; + }, + ], + [ + "z.never() vs. a DIFFERENT z.never()-shaped never", + () => { + // Both project to `{ not: {} }`, which is genuinely identical — + // z.never() carries no parameters to differ on. Compare it instead + // against a schema this predicate can't equate it to, so the + // unsupported-keyword rule is what's under test, not rule 2. + return [projected(z.never()), projected(z.string())]; + }, + ], + [ + "two DIFFERENT z.intersection(...) schemas", + () => { + // Intersections of two object schemas project as one merged object; + // these stay `allOf`. + const a = projected(z.intersection(z.string(), z.string().min(3))); + const b = projected(z.intersection(z.string(), z.string().min(5))); + return [a, b]; + }, + ], + [ + "z.file() vs. an unrelated schema", + () => { + return [projected(z.file()), projected(z.string())]; + }, + ], + ]; + + it.each(INCOMPARABLE_BY_DESIGN)("%s is incomparable", (_label, buildPair) => { + const [child, parent] = buildPair(); + const result = isStructuralSubtype(child, parent); + expect(result.verdict).toBe("incomparable"); + }); + + // An unmodeled construct (`$ref`/`allOf`/`not`/`contentEncoding` + // and friends) is still refused between two DIFFERENT schemas — see + // INCOMPARABLE_BY_DESIGN above — but a schema is trivially a subtype of + // itself regardless of which keywords it carries (rule 2), so an + // IDENTICAL pair carrying one of these constructs must compare as + // `subtype`, not `incomparable`. This is what lets a `subClassOf` child + // copy a recursive (`z.lazy`) or intersection (`z.intersection`) parent + // property VERBATIM without the pair being refused as + // SCHEMA_INCOMPARABLE. + describe("identical unmodeled constructs are reflexive", () => { + it("z.never() self-comparison is a subtype", () => { + const schema = projected(z.never()); + expect(isStructuralSubtype(schema, schema).verdict).toBe("subtype"); + }); + + it("z.intersection(...) self-comparison is a subtype", () => { + const schema = projected( + z.intersection( + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ), + ); + expect(isStructuralSubtype(schema, schema).verdict).toBe("subtype"); + }); + + it("z.file() self-comparison is a subtype", () => { + const schema = projected(z.file()); + expect(isStructuralSubtype(schema, schema).verdict).toBe("subtype"); + }); + + it("a self-recursive (z.lazy) schema self-comparison is a subtype", () => { + interface Recursive { + readonly children: readonly Recursive[]; + } + const recursive: z.ZodType = z.lazy(() => + z.object({ children: z.array(recursive) }), + ); + const schema = projected(recursive); + expect(isStructuralSubtype(schema, schema).verdict).toBe("subtype"); + }); + + it("a child copying a parent's z.intersection property VERBATIM, plus an added field, is a subtype", () => { + const sharedIntersection = z.intersection( + z.object({ a: z.string() }), + z.object({ b: z.number() }), + ); + const parent = projected(z.object({ x: sharedIntersection })); + const child = projected( + z.object({ x: sharedIntersection, extra: z.string() }), + ); + expect(isStructuralSubtype(child, parent).verdict).toBe("subtype"); + }); + }); +}); + +// ============================================================ +// References resolve against each side's own definitions +// ============================================================ + +/** A schema whose `nested` property reaches `inner` through `outer`. */ +function chainedReferenceSchema(minLength: number, extra: boolean): JsonSchema { + return { + type: "object", + properties: { + nested: { $ref: "#/$defs/outer" }, + ...(extra ? { extra: { type: "string" } } : {}), + }, + $defs: { + outer: { + type: "object", + properties: { inner: { $ref: "#/$defs/inner" } }, + }, + inner: { type: "string", minLength }, + }, + }; +} + +function recordWithKeyLimit(maxLength: number): JsonSchema { + return { + type: "object", + propertyNames: { $ref: "#/$defs/key" }, + additionalProperties: { type: "string" }, + $defs: { key: { type: "string", maxLength } }, + }; +} + +describe("a `$ref` is identical only when what it resolves to is", () => { + interface Tree { + readonly value: string; + readonly children: readonly Tree[]; + } + + function treeOf(value: z.ZodType): z.ZodType { + const tree: z.ZodType = z.lazy(() => + z.object({ value, children: z.array(tree) }), + ); + return tree; + } + + const looseTree = treeOf(z.string()); + const tightTree = treeOf(z.string().min(3)); + + it("refuses two nested references that share a name but not a definition", () => { + const parentSchema = z.object({ tree: tightTree }); + const childSchema = z.object({ tree: looseTree }); + const parent = projected(parentSchema); + const child = projected(childSchema); + const value = { tree: { value: "a", children: [] } }; + // The trap: the property schemas are textually the same reference. + expect(child.properties?.["tree"]).toEqual(parent.properties?.["tree"]); + expect(child["$defs"]).not.toEqual(parent["$defs"]); + expect(childSchema.safeParse(value).success).toBe(true); + expect(parentSchema.safeParse(value).success).toBe(false); + + expect(isStructuralSubtype(child, parent)).toEqual({ + verdict: "incomparable", + reason: "schema-reference", + path: ["tree"], + }); + expect(isStructuralSubtype(parent, child)).toMatchObject({ + verdict: "incomparable", + reason: "schema-reference", + }); + }); + + it("accepts a child copying the parent's recursive property verbatim beside an added field", () => { + const parent = projected(z.object({ tree: tightTree })); + const child = projected(z.object({ tree: tightTree, extra: z.string() })); + expect(isStructuralSubtype(child, parent)).toEqual({ verdict: "subtype" }); + }); + + it("refuses a root self-reference shared by two different roots", () => { + const child: JsonSchema = { + type: "object", + properties: { next: { $ref: "#" }, label: { type: "string" } }, + }; + const parent: JsonSchema = { + type: "object", + properties: { + next: { $ref: "#" }, + label: { type: "string", minLength: 3 }, + }, + }; + expect(isStructuralSubtype(child, parent)).toMatchObject({ + verdict: "incomparable", + reason: "schema-reference", + path: ["next"], + }); + }); + + it("refuses a shared reference neither side can resolve", () => { + const schema: JsonSchema = { + type: "object", + properties: { tree: { $ref: "#/$defs/missing" } }, + }; + const widened: JsonSchema = { + ...schema, + properties: { ...schema.properties, extra: { type: "string" } }, + }; + expect(isStructuralSubtype(widened, schema)).toMatchObject({ + verdict: "incomparable", + reason: "schema-reference", + }); + }); + + it("compares `propertyNames` by what its reference resolves to, not by its name", () => { + expect( + isStructuralSubtype(recordWithKeyLimit(10), recordWithKeyLimit(3)), + ).toEqual({ + verdict: "incomparable", + reason: "schema-reference", + path: ["{propertyNames}"], + }); + expect( + isStructuralSubtype(recordWithKeyLimit(3), recordWithKeyLimit(3)), + ).toEqual({ verdict: "subtype" }); + }); + + it("follows a definition that references another definition", () => { + expect( + isStructuralSubtype( + chainedReferenceSchema(3, true), + chainedReferenceSchema(3, false), + ), + ).toEqual({ verdict: "subtype" }); + expect( + isStructuralSubtype( + chainedReferenceSchema(1, true), + chainedReferenceSchema(3, false), + ), + ).toMatchObject({ verdict: "incomparable", reason: "schema-reference" }); + }); +}); diff --git a/packages/typegraph/tests/subgraph.test.ts b/packages/typegraph/tests/subgraph.test.ts index 1cfb7ec37..f3df9f007 100644 --- a/packages/typegraph/tests/subgraph.test.ts +++ b/packages/typegraph/tests/subgraph.test.ts @@ -25,11 +25,13 @@ import { indexSubgraph, MAX_EXPLICIT_RECURSIVE_DEPTH, type Node, + partOf, } from "../src"; import type { GraphBackend } from "../src/backend/types"; import type { NodeId } from "../src/core/types"; import { KindNotFoundError, ValidationError } from "../src/errors"; import { createStore, type Store } from "../src/store"; +import { type SubgraphResult } from "../src/store/subgraph"; import { requireDefined } from "../src/utils/presence"; import { createTestBackend, TEMPORAL_ANCHORS } from "./test-utils"; @@ -155,6 +157,119 @@ async function seedTestGraph(store: Store): Promise { }; } +// ============================================================ +// Composition widens the subgraph result's edge-kind type +// ============================================================ +// +// Type-only fixtures: `compositionStore` is never a real store and none of +// the `subgraph*` helpers below is ever CALLED — each exists so a test can +// read its return type. The runtime behavior of `composition: true` lives +// in the cross-backend composition-navigation suite. + +const CompositionWhole = defineNode("CompositionWhole", { + schema: z.object({ title: z.string() }), +}); +const CompositionPart = defineNode("CompositionPart", { + schema: z.object({ title: z.string() }), +}); +const partOfWhole = defineEdge("part_of_whole", { schema: z.object({}) }); +const mentions = defineEdge("mentions", { schema: z.object({}) }); + +const compositionGraph = defineGraph({ + id: "subgraph_composition_typing", + nodes: { + CompositionWhole: { type: CompositionWhole }, + CompositionPart: { type: CompositionPart }, + }, + edges: { + part_of_whole: { + type: partOfWhole, + from: [CompositionPart], + to: [CompositionWhole], + cardinality: "one", + }, + mentions: { + type: mentions, + from: [CompositionWhole], + to: [CompositionWhole], + }, + }, + ontology: [partOf(CompositionPart, CompositionWhole, { via: partOfWhole })], +}); + +declare const compositionStore: Store; +declare const compositionRoot: Readonly<{ + kind: "CompositionWhole"; + id: NodeId; +}>; + +type ResultEdgeKindOf = + R["edges"][number]["kind"]; + +function subgraphWithoutComposition() { + return compositionStore.subgraph(compositionRoot, { edges: ["mentions"] }); +} + +function subgraphWithCompositionFalse() { + return compositionStore.subgraph(compositionRoot, { + edges: ["mentions"], + composition: false, + }); +} + +function subgraphWithCompositionTrue() { + return compositionStore.subgraph(compositionRoot, { + edges: ["mentions"], + composition: true, + }); +} + +function subgraphWithUnknownComposition(composition: boolean) { + return compositionStore.subgraph(compositionRoot, { + edges: ["mentions"], + composition, + }); +} + +// A `composition: true` call receives composition edge rows, so it must be +// able to project them: the option that widened the result's edge-kind type +// widens the matching projection input through the same owner +// (`SubgraphProjectFor`). +function subgraphProjectingCompositionEdges() { + return compositionStore.subgraph(compositionRoot, { + edges: ["mentions"], + composition: true, + project: { edges: { part_of_whole: ["meta"] } }, + }); +} + +function subgraphProjectingAnUntraversedEdge() { + return compositionStore.subgraph(compositionRoot, { + edges: ["mentions"], + // @ts-expect-error - without `composition: true` the traversal never + // produces `part_of_whole` rows, so projecting that kind stays refused. + project: { edges: { part_of_whole: ["meta"] } }, + }); +} + +/** A projection over the edge kinds the result carries is a valid `P`. */ +type ProjectedCompositionSubgraph = SubgraphResult< + typeof compositionGraph, + "CompositionWhole", + "mentions" | "part_of_whole", + { edges: { part_of_whole: readonly ["meta"] } } +>; + +type NonProjectionSubgraphResult = SubgraphResult< + typeof compositionGraph, + "CompositionWhole", + "mentions", + // @ts-expect-error - the fourth argument is a projection for this graph, not + // an arbitrary object; without the constraint the selection silently + // resolves to `undefined` and every row comes back fully hydrated. + { nonsense: true } +>; + // ============================================================ // Tests // ============================================================ @@ -870,6 +985,69 @@ describe("store.subgraph()", () => { // ── Type-level tests ──────────────────────────────────────── + describe("composition widens the result's edge-kind type", () => { + it("keeps the declared edges list when composition is absent or false", () => { + expectTypeOf< + ResultEdgeKindOf>> + >().toEqualTypeOf<"mentions">(); + expectTypeOf< + ResultEdgeKindOf< + Awaited> + > + >().toEqualTypeOf<"mentions">(); + void subgraphWithoutComposition; + void subgraphWithCompositionFalse; + expect(compositionGraph.id).toBe("subgraph_composition_typing"); + }); + + it("widens to the graph's whole edge-kind union under composition: true", () => { + // `part_of_whole` is never named in `edges`, but the composition + // closure can put its rows in `edges`, so the kind type must admit + // it. + expectTypeOf< + ResultEdgeKindOf< + Awaited> + > + >().toEqualTypeOf<"mentions" | "part_of_whole">(); + void subgraphWithCompositionTrue; + expect(compositionGraph.id).toBe("subgraph_composition_typing"); + }); + + it("widens the matching projection input, not only the result", () => { + // Compiling IS the assertion: `project.edges.part_of_whole` is outside + // the declared `edges` list, and is admissible only because + // `composition: true` widened the projection's key set too. + expectTypeOf< + ResultEdgeKindOf< + Awaited> + > + >().toEqualTypeOf<"mentions" | "part_of_whole">(); + void subgraphProjectingCompositionEdges; + void subgraphProjectingAnUntraversedEdge; + expect(compositionGraph.id).toBe("subgraph_composition_typing"); + }); + + it("keeps SubgraphResult's projection argument constrained", () => { + expectTypeOf< + ProjectedCompositionSubgraph["root"] + >().not.toEqualTypeOf(); + expectTypeOf< + NonProjectionSubgraphResult["root"] + >().not.toEqualTypeOf(); + expect(compositionGraph.id).toBe("subgraph_composition_typing"); + }); + + it("widens for a composition flag that is only known to be a boolean", () => { + expectTypeOf< + ResultEdgeKindOf< + Awaited> + > + >().toEqualTypeOf<"mentions" | "part_of_whole">(); + void subgraphWithUnknownComposition; + expect(compositionGraph.id).toBe("subgraph_composition_typing"); + }); + }); + describe("indexSubgraph lookup typing", () => { type WideNode = Readonly<{ kind: string; id: string; name: string }>; type WideEdge = Readonly<{ @@ -983,23 +1161,27 @@ describe("store.subgraph()", () => { }); describe("compile-time type safety", () => { - it("rejects invalid edge kinds at compile time", async () => { - await store.subgraph( - { kind: "Run", id: ids.runId }, - // @ts-expect-error - "nonexistent_edge" is not a valid edge kind - { edges: ["nonexistent_edge"] }, - ); + it("rejects invalid edge kinds at compile time and at runtime", async () => { + await expect( + store.subgraph( + { kind: "Run", id: ids.runId }, + // @ts-expect-error - "nonexistent_edge" is not a valid edge kind + { edges: ["nonexistent_edge"] }, + ), + ).rejects.toBeInstanceOf(KindNotFoundError); }); - it("rejects invalid includeKinds at compile time", async () => { - await store.subgraph( - { kind: "Run", id: ids.runId }, - { - edges: ["has_task"], - // @ts-expect-error - "NonExistent" is not a valid node kind - includeKinds: ["NonExistent"], - }, - ); + it("rejects invalid includeKinds at compile time and at runtime", async () => { + await expect( + store.subgraph( + { kind: "Run", id: ids.runId }, + { + edges: ["has_task"], + // @ts-expect-error - "NonExistent" is not a valid node kind + includeKinds: ["NonExistent"], + }, + ), + ).rejects.toBeInstanceOf(KindNotFoundError); }); it("accepts a { kind, id } reference root on every subgraph surface", async () => { diff --git a/packages/typegraph/tests/subquery-utils.test.ts b/packages/typegraph/tests/subquery-utils.test.ts index a7abb1f05..940d90e63 100644 --- a/packages/typegraph/tests/subquery-utils.test.ts +++ b/packages/typegraph/tests/subquery-utils.test.ts @@ -32,7 +32,7 @@ function makeSubqueryAst( }[], ): QueryAst { return { - start: { alias: "q", kinds: ["Item"], includeSubClasses: false }, + start: { alias: "q", kinds: ["Item"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { @@ -56,7 +56,7 @@ function makeAggregateSubqueryAst( fieldValueType?: ValueType, ): QueryAst { return { - start: { alias: "q", kinds: ["Item"], includeSubClasses: false }, + start: { alias: "q", kinds: ["Item"], expansion: "exact" as const }, traversals: [], predicates: [], projection: { diff --git a/packages/typegraph/tests/temporal-builder-inventory.test.ts b/packages/typegraph/tests/temporal-builder-inventory.test.ts index d9d4fb080..05683c60f 100644 --- a/packages/typegraph/tests/temporal-builder-inventory.test.ts +++ b/packages/typegraph/tests/temporal-builder-inventory.test.ts @@ -19,7 +19,7 @@ * WITHOUT calling an owner, which is precisely how the duplicate resolver * in `trusted-import.ts` survived the last two rounds of review. * (c) FILE SET — the set of files under `src/backend/**` that name the column at - * all equals the fifteen declared here, each with the role that earns it. (a) + * all equals the sixteen declared here, each with the role that earns it. (a) * and (b) only see five files; a NEW file that binds `valid_from` would be * invisible to them, and I5 is quantified over the whole directory. * @@ -163,7 +163,7 @@ const BACKEND_COLUMN_FILES: Readonly> = { "drizzle/trusted-import.ts": "writer — four stamping sites (native per-dialect INSERT)", "drizzle/operations/shared.ts": - "expectedValidFromPredicate — the NULL-safe fence", + "expectedValidFromPredicate — the NULL-safe fence; currentWindowPredicate — the constraint-fence audit's current-window liveness filter", "drizzle/operations/collections.ts": "read predicate", "live-node-candidates.ts": "read predicate (search currency)", "drizzle/operations/hybrid.ts": "projection into the hybrid-search statement", diff --git a/packages/typegraph/tests/transaction-receipt.test.ts b/packages/typegraph/tests/transaction-receipt.test.ts index a9df0169d..81801b7ab 100644 --- a/packages/typegraph/tests/transaction-receipt.test.ts +++ b/packages/typegraph/tests/transaction-receipt.test.ts @@ -268,4 +268,44 @@ describe("transaction receipt recorder", () => { expect(Object.entries(receipt.writes.nodes)).toEqual([]); expect(receipt.writes.total).toBe(0); }); + + // Load-bearing: `transitions` sits BESIDE `total` on `IdentityWriteSummary`, + // never inside it — `total` stays the count of ledger truth rows a + // transaction produced. Revert check: fold + // `recordIdentityTransitions` into `recordIdentity`'s bucket (so it also + // bumps `counters.identity.total` and the receipt's overall `total`) and + // both `toEqual` assertions below fail (`total` reports 8, not 3). + it("records identity transitions beside total, never inside it (enabled + history)", () => { + const recorder = createTransactionReceiptRecorder(); + recorder.recordIdentity("sameAssertions", 2); + recorder.recordIdentity("retractions", 1); + recorder.recordIdentityTransitions(5); + + const receipt = recorder.snapshot(); + + expect(receipt.writes.identity).toEqual({ + sameAssertions: 2, + differentAssertions: 0, + retractions: 1, + transitions: 5, + total: 3, + }); + expect(receipt.writes.total).toBe(3); + }); + + // Companion to the above: the shared frozen zero-valued summary (identity + // disabled, or enabled without any writes) carries `transitions` too, so a + // disabled graph's receipt shape never diverges from an enabled one's. + it("carries transitions: 0 on the zero-valued identity summary (disabled or untouched)", () => { + const recorder = createTransactionReceiptRecorder(); + const receipt = recorder.snapshot(); + + expect(receipt.writes.identity).toEqual({ + sameAssertions: 0, + differentAssertions: 0, + retractions: 0, + transitions: 0, + total: 0, + }); + }); }); diff --git a/packages/typegraph/tests/transaction-retry.test.ts b/packages/typegraph/tests/transaction-retry.test.ts index c2f5d6649..fa4115033 100644 --- a/packages/typegraph/tests/transaction-retry.test.ts +++ b/packages/typegraph/tests/transaction-retry.test.ts @@ -766,6 +766,8 @@ describe.each(ENGINES)("import write-plan attempt state (%s)", (engine) => { const options = ImportOptionsSchema.parse({ onConflict: "skip" }); const attemptInputs: ImportAttemptInputs = { graphId, + graph: store.graph, + schema: store.revisionSchema, registry, data, nodeSchemas: buildNodeSchemaMap(store.graph), @@ -800,13 +802,14 @@ describe.each(ENGINES)("import write-plan attempt state (%s)", (engine) => { }, mixedWritePlan(undefined, true), backend, - (session, target, overlaidSession, _lock, transactionMode) => + (session, target, overlaidSession, lock, transactionMode) => runImportWritePlanAttempt( attemptInputs, session, target, overlaidSession, transactionMode, + lock, ), ), ); diff --git a/packages/typegraph/tests/transaction-surface-honesty.test.ts b/packages/typegraph/tests/transaction-surface-honesty.test.ts index 6ecf826fe..d5dbaed72 100644 --- a/packages/typegraph/tests/transaction-surface-honesty.test.ts +++ b/packages/typegraph/tests/transaction-surface-honesty.test.ts @@ -209,6 +209,48 @@ describe("portable runtime capability boundaries", () => { }); }); + it("gives no transaction context a statement-execution handle on tx.backend", async () => { + // A rollback to a savepoint issued between two TypeGraph writes releases + // the fence the first write took while the transaction keeps reusing it, + // so `tx.sql` stays the one raw handle a context can carry and the write + // session's fence decision keys on it alone. + const statementExecution = [ + "execute", + "executeRaw", + "executeStatement", + "executeDdl", + ] as const; + const backend = createTestBackend(); + const portable = createStore(graph, backend); + const adapter = createAdapterStore(graph, backend); + const [history] = await createAdapterStoreWithSchema(graph, backend, { + history: true, + }); + const observed: string[] = []; + const recordExposed = ( + surface: string, + projection: object, + ): Promise => { + for (const member of statementExecution) { + if ( + member in projection || + Reflect.get(projection, member) !== undefined + ) + observed.push(`${surface}.${member}`); + } + return Promise.resolve(); + }; + + await portable.transaction((tx) => recordExposed("portable", tx.backend)); + await adapter.transaction((tx) => recordExposed("adapter", tx.backend)); + await history.transaction((tx) => recordExposed("history", tx.backend)); + await backend.transactionWithNative((_target, native) => + recordExposed("adopted", adapter.withTransaction(native).backend), + ); + + expect(observed).toEqual([]); + }); + it("projects adapter-native methods out of the public AdapterStore backend", () => { const adapterBackend = createTestBackend(); const store = createAdapterStore(graph, adapterBackend); @@ -487,6 +529,7 @@ function portableFactoryTypeAssertions(backend: GraphBackend): void { expectTypeOf(tx.backend).toEqualTypeOf(); expectTypeOf(tx.backend).not.toHaveProperty("insertNode"); expectTypeOf(tx.backend).not.toHaveProperty("executeRaw"); + expectTypeOf(tx.backend).not.toHaveProperty("execute"); return Promise.resolve(); }); } diff --git a/packages/typegraph/tests/traversal-stage-validation.test.ts b/packages/typegraph/tests/traversal-stage-validation.test.ts index 7007f9ec8..af2473c4e 100644 --- a/packages/typegraph/tests/traversal-stage-validation.test.ts +++ b/packages/typegraph/tests/traversal-stage-validation.test.ts @@ -28,7 +28,7 @@ function queryWith(expression: PredicateExpression): QueryAst { return { projection: { fields: [] }, predicates: [{ expression, targetAlias: "target" }], - start: { alias: "root", includeSubClasses: false, kinds: ["Root"] }, + start: { alias: "root", expansion: "exact", kinds: ["Root"] }, temporalMode: { mode: "current" }, traversals: [traversal], } satisfies QueryAst; @@ -117,7 +117,7 @@ describe("isolated traversal-stage validation", () => { targetAlias: "root", }, ], - start: { alias: "root", includeSubClasses: false, kinds: ["Root"] }, + start: { alias: "root", expansion: "exact", kinds: ["Root"] }, temporalMode: { mode: "current" }, traversals: [traversal], } satisfies QueryAst; diff --git a/packages/typegraph/tests/trusted-import.test.ts b/packages/typegraph/tests/trusted-import.test.ts index 6a792996f..26c0c512c 100644 --- a/packages/typegraph/tests/trusted-import.test.ts +++ b/packages/typegraph/tests/trusted-import.test.ts @@ -11,6 +11,8 @@ import { defineGraphExtension, defineNode, embedding, + hasPart, + partOf, searchable, StaleVersionError, TrustedImportError, @@ -347,6 +349,44 @@ describe("trusted import", () => { ).toBeUndefined(); }); + it("refuses an identity-transitions chunk instead of dropping identity history", async () => { + const backend = createTestBackend(); + const store = createStore(trustedGraph, backend); + const data = graphData([ + { kind: "TrustedPerson", id: "alice", properties: { name: "Alice" } }, + ]); + const { nodes, edges, ...header } = data; + + await expect( + trustedImportGraphStream( + store, + chunkStream([ + { type: "header", header }, + { type: "nodes", nodes }, + { + type: "identity-transitions", + transitions: [ + { + transitionId: "transition-1", + cause: "assert", + recordedRevision: 1, + recordedAt: "2026-01-01T00:00:00.000Z", + validAt: "2026-01-01T00:00:00.000Z", + class: { kind: "TrustedPerson", id: "alice" }, + assertionIds: ["assertion-1"], + }, + ], + }, + { type: "edges", edges }, + ]), + ), + ).rejects.toEqual(expectReason("invalid_stream")); + + expect( + await store.nodes.TrustedPerson.getById(asNodeId("alice")), + ).toBeUndefined(); + }); + it("refuses a node whose validity window is inverted", async () => { // The trusted path skips schema validation for throughput, but a window of // negative width is a stream SHAPE fault: the row would be observable at no @@ -708,6 +748,139 @@ describe("trusted import", () => { ); }); + it("rejects an acyclic edge kind", async () => { + const Task = defineNode("TrustedTask", { schema: z.object({}) }); + const dependsOn = defineEdge("trustedDependsOn", { + schema: z.object({}), + }); + const graph = defineGraph({ + id: "trusted_import_reject_acyclicity", + nodes: { TrustedTask: { type: Task } }, + edges: { + trustedDependsOn: { + type: dependsOn, + from: [Task], + to: [Task], + acyclic: true, + }, + }, + }); + const store = createStore(graph, createTestBackend()); + await expect(trustedImportGraph(store, graphData([]))).rejects.toEqual( + expectReason("acyclicity_unsupported"), + ); + }); + + it.each([ + { name: "source-only", cardinality: { cardinality: "one" as const } }, + { + name: "target-only", + cardinality: { targetCardinality: "one" as const }, + }, + { + name: "both axes", + cardinality: { + cardinality: "one" as const, + targetCardinality: "one" as const, + }, + }, + ])( + "rejects a declared edge cardinality ($name) — trusted import maintains no claim", + async ({ cardinality }) => { + const node = defineNode("CardinalityPerson", { schema: z.object({}) }); + const relation = defineEdge("cardinalityKnows", { + schema: z.object({}), + }); + const graph = defineGraph({ + id: "trusted_import_reject_cardinality", + nodes: { CardinalityPerson: { type: node } }, + edges: { + cardinalityKnows: { + type: relation, + from: [node], + to: [node], + ...cardinality, + }, + }, + }); + const store = createStore(graph, createTestBackend()); + await expect(trustedImportGraph(store, graphData([]))).rejects.toEqual( + expectReason("cardinality_unsupported"), + ); + }, + ); + + it.each([ + { + name: "partOf with cardinality one", + cardinality: "one" as const, + existence: "optional" as const, + }, + { + name: "required partOf with cardinality oneActive", + cardinality: "oneActive" as const, + existence: "required" as const, + }, + ])( + "rejects a composition pair ($name) as composition, not as the cardinality it must declare", + async ({ cardinality, existence }) => { + // A composition edge has to declare a part-side cardinality, so the + // cardinality refusal would always fire first and name advice — make + // the kind unconstrained — the ontology itself refuses. + const part = defineNode("CompositionPart", { schema: z.object({}) }); + const whole = defineNode("CompositionWhole", { schema: z.object({}) }); + const holds = defineEdge("compositionHolds", { schema: z.object({}) }); + const graph = defineGraph({ + id: "trusted_import_reject_composition", + nodes: { + CompositionPart: { type: part }, + CompositionWhole: { type: whole }, + }, + edges: { + compositionHolds: { + type: holds, + from: [part], + to: [whole], + cardinality, + }, + }, + ontology: [partOf(part, whole, { via: holds, existence })], + }); + const store = createStore(graph, createTestBackend()); + const refusal = await trustedImportGraph(store, graphData([])).catch( + (error: unknown) => error, + ); + expect(refusal).toBeInstanceOf(TrustedImportError); + const error = refusal as TrustedImportError; + console.info("trusted composition refusal", error.details); + expect(error.details["reason"]).toBe("composition_unsupported"); + expect(error.details["edgeKinds"]).toEqual(["compositionHolds"]); + }, + ); + + it("rejects a hasPart pair with a target cardinality as composition", async () => { + const part = defineNode("HasPartPart", { schema: z.object({}) }); + const whole = defineNode("HasPartWhole", { schema: z.object({}) }); + const contains = defineEdge("hasPartContains", { schema: z.object({}) }); + const graph = defineGraph({ + id: "trusted_import_reject_has_part", + nodes: { HasPartPart: { type: part }, HasPartWhole: { type: whole } }, + edges: { + hasPartContains: { + type: contains, + from: [whole], + to: [part], + targetCardinality: "one", + }, + }, + ontology: [hasPart(whole, part, { via: contains })], + }); + const store = createStore(graph, createTestBackend()); + await expect(trustedImportGraph(store, graphData([]))).rejects.toEqual( + expectReason("composition_unsupported"), + ); + }); + it.each([ { reason: "fulltext_unsupported", diff --git a/packages/typegraph/tests/typed-subsumption-registry.test.ts b/packages/typegraph/tests/typed-subsumption-registry.test.ts new file mode 100644 index 000000000..7a2bdaba8 --- /dev/null +++ b/packages/typegraph/tests/typed-subsumption-registry.test.ts @@ -0,0 +1,1016 @@ +/** + * The registry-build-time structural subsumption check. + * + * The compile-time check refuses everything TypeScript's structural + * assignability over `z.infer` can see; it is blind to value-level + * constraints (`z.string().min(3)` tightening a bare `z.string()`), so most + * fixtures here deliberately construct pairs that are TYPE-compatible (so + * `subClassOf`/`equivalentTo` compiles) but VALUE-level incompatible — the + * "the compile-time check accepts strictly more than the registry check + * refuses" gap, and exactly the case only the runtime check can catch. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + defineGraph, + defineNode, + embedding, + equivalentTo, + type GraphDef, + subClassOf, +} from "../src"; +import { ConfigurationError } from "../src/errors"; +import { defineGraphExtension } from "../src/graph-extension"; +import { buildKindRegistry } from "../src/registry/builders"; +import { + computeSchemaHash, + deserializeSchema, + serializeSchema, +} from "../src/schema"; +import { createStoreWithSchema } from "../src/store/store"; +import { createTestBackend } from "./test-utils"; + +describe("registry check — subClassOf refused when the child is not a structural subtype", () => { + it("refuses a direct value-constraint mismatch, naming the codes and fields", () => { + const Loose = defineNode("Loose", { + schema: z.object({ code: z.string() }), + }); + const Tight = defineNode("Tight", { + schema: z.object({ code: z.string().min(5) }), + }); + const relation = subClassOf(Loose, Tight); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "subclass_value_mismatch", + nodes: { Loose: { type: Loose }, Tight: { type: Tight } }, + edges: {}, + ontology: [relation], + }), + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigurationError); + const configError = caught as ConfigurationError; + expect(configError.details["code"]).toBe( + "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + ); + expect(configError.details["childKind"]).toBe("Loose"); + expect(configError.details["parentKind"]).toBe("Tight"); + expect(configError.details["declared"]).toBe(true); + expect(configError.details["violations"]).toBeInstanceOf(Array); + }); + + it("refuses a pair reached only transitively, with declared: false", () => { + // A and B are structurally equal (subtype trivially); B does not extend + // C. The transitive closure still puts C in A's ancestors, and THAT + // pair was never directly declared. + const A = defineNode("A", { schema: z.object({ code: z.string() }) }); + const B = defineNode("B", { schema: z.object({ code: z.string() }) }); + const C = defineNode("C", { + schema: z.object({ code: z.string().min(5) }), + }); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "subclass_transitive", + nodes: { A: { type: A }, B: { type: B }, C: { type: C } }, + edges: {}, + ontology: [subClassOf(A, B), subClassOf(B, C)], + }), + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigurationError); + const violations = (caught as ConfigurationError).details[ + "violations" + ] as readonly Readonly<{ + childKind: string; + parentKind: string; + declared: boolean; + }>[]; + const transitiveViolation = violations.find( + (violation) => + violation.childKind === "A" && violation.parentKind === "C", + ); + expect(transitiveViolation).toBeDefined(); + expect(transitiveViolation?.declared).toBe(false); + }); + + it("skips an unregistered parent kind — the documented external-parent pattern", () => { + const Child = defineNode("Child", { + schema: z.object({ code: z.string() }), + }); + // Parent is never added to `nodes` — a supported "referenced but not + // registered" pattern; subsumption cannot apply to a kind whose shape + // this registry does not know. + const UnregisteredParent = defineNode("UnregisteredParent", { + schema: z.object({ code: z.string().min(5) }), + }); + + expect(() => + buildKindRegistry( + defineGraph({ + id: "subclass_unregistered_parent", + nodes: { Child: { type: Child } }, + edges: {}, + ontology: [subClassOf(Child, UnregisteredParent)], + }), + ), + ).not.toThrow(); + }); + + it("skips an external IRI on equivalentTo", () => { + const Kind = defineNode("Kind", { + schema: z.object({ code: z.string() }), + }); + + expect(() => + buildKindRegistry( + defineGraph({ + id: "subclass_external_iri", + nodes: { Kind: { type: Kind } }, + edges: {}, + ontology: [equivalentTo(Kind, "https://schema.org/Kind")], + }), + ), + ).not.toThrow(); + }); + + it("refuses an opaque construct (z.intersection) as incomparable, never silently accepted", () => { + // Child's `tag` differs from Parent's `tag` ONLY by a value-level + // constraint (`minLength: 3`) TypeScript's z.infer cannot see, so the + // pair still compiles under the compile-time check (both infer `string`) — but the + // projected JSON Schema is NOT byte-identical, so the identity rule + // cannot fire, and the opaque `allOf` keyword must still + // surface the refusal. Width subtyping (the child adding "note") must + // not mask it either. Two string schemas, because the projection merges + // an intersection of two object schemas into one comparable object. + const Child = defineNode("Child", { + schema: z.object({ + tag: z.intersection(z.string().min(3), z.string().max(10)), + note: z.string(), + }), + }); + const Parent = defineNode("Parent", { + schema: z.object({ + tag: z.intersection(z.string(), z.string().max(10)), + }), + }); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "subclass_opaque", + nodes: { Child: { type: Child }, Parent: { type: Parent } }, + edges: {}, + ontology: [subClassOf(Child, Parent)], + }), + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).details["code"]).toBe( + "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + ); + }); + + it("accepts a child that copies a recursive/intersection parent property VERBATIM and adds a field", () => { + // A schema is trivially a subtype of itself regardless of which + // keywords it carries — including `$ref` (recursive z.lazy) and `allOf` + // (z.intersection), which the structural-subtype predicate otherwise + // cannot judge at all. Before the fix, compareSchemas ran the + // $ref/unmodeled guards BEFORE the identity shortcut, so this pair was + // refused as SCHEMA_INCOMPARABLE even though the child copies the + // parent's property verbatim. + const sharedIntersection = z.intersection( + z.object({ a: z.string() }), + z.object({ b: z.string() }), + ); + interface RecursiveTree { + readonly children: readonly RecursiveTree[]; + } + const sharedRecursive: z.ZodType = z.lazy(() => + z.object({ children: z.array(sharedRecursive) }), + ); + + const IntersectionParent = defineNode("IntersectionParent", { + schema: z.object({ tag: sharedIntersection }), + }); + const IntersectionChild = defineNode("IntersectionChild", { + schema: z.object({ tag: sharedIntersection, note: z.string() }), + }); + const RecursiveParent = defineNode("RecursiveParent", { + schema: z.object({ tree: sharedRecursive }), + }); + const RecursiveChild = defineNode("RecursiveChild", { + schema: z.object({ tree: sharedRecursive, note: z.string() }), + }); + + const registry = buildKindRegistry( + defineGraph({ + id: "subclass_verbatim_copy", + nodes: { + IntersectionParent: { type: IntersectionParent }, + IntersectionChild: { type: IntersectionChild }, + RecursiveParent: { type: RecursiveParent }, + RecursiveChild: { type: RecursiveChild }, + }, + edges: {}, + ontology: [ + subClassOf(IntersectionChild, IntersectionParent), + subClassOf(RecursiveChild, RecursiveParent), + ], + }), + ); + expect( + registry.isAssignableTo("IntersectionChild", "IntersectionParent"), + ).toBe(true); + expect(registry.isAssignableTo("RecursiveChild", "RecursiveParent")).toBe( + true, + ); + }); + + it("orders violations in stable compareCodePoints order, astral names included", () => { + // U+10000 sorts ABOVE any BMP character in code-point order but BELOW + // most BMP characters in UTF-16 code-UNIT order — a real ordering + // divergence, not a coincidence. + const AstralKind = "\u{10000}Astral"; + const Astral = defineNode(AstralKind, { + schema: z.object({ code: z.string() }), + }); + const Bmp = defineNode("BmpOnly", { + schema: z.object({ code: z.string() }), + }); + // Both children fail against a strict target so both are violations; + // the registry itself only surfaces the FIRST in code-point order via + // its top-level `childKind`. + const Target = defineNode("StrictTarget", { + schema: z.object({ code: z.string().min(5) }), + }); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "subclass_ordering", + nodes: { + [AstralKind]: { type: Astral }, + BmpOnly: { type: Bmp }, + StrictTarget: { type: Target }, + }, + edges: {}, + ontology: [subClassOf(Astral, Target), subClassOf(Bmp, Target)], + }), + ); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + // "BmpOnly" < "\u{10000}Astral" in code-point order (BMP code points are + // numerically smaller than any astral code point). + expect((caught as ConfigurationError).details["childKind"]).toBe("BmpOnly"); + }); +}); + +describe("registry check — a finite value set narrows a bounded parent", () => { + const Document = defineNode("Document", { + schema: z.object({ + status: z.string().min(1).max(16), + priority: z.int().min(0).max(10), + labels: z.record(z.string(), z.string()), + }), + }); + + it("builds a hierarchy whose child pins literals, an enum and an enum-keyed record under bounded parent fields", () => { + const Invoice = defineNode("Invoice", { + schema: z.object({ + status: z.enum(["draft", "sent", "paid"]), + priority: z.literal(5), + labels: z.partialRecord(z.enum(["currency", "region"]), z.string()), + }), + }); + const PaidInvoice = defineNode("PaidInvoice", { + schema: z.object({ + status: z.literal("paid"), + priority: z.literal(5), + labels: z.partialRecord(z.enum(["currency"]), z.string()), + }), + }); + + const registry = buildKindRegistry( + defineGraph({ + id: "subclass_finite_values", + nodes: { + Document: { type: Document }, + Invoice: { type: Invoice }, + PaidInvoice: { type: PaidInvoice }, + }, + edges: {}, + ontology: [ + subClassOf(Invoice, Document), + subClassOf(PaidInvoice, Invoice), + ], + }), + ); + + expect(registry.isAssignableTo("Invoice", "Document")).toBe(true); + expect(registry.isAssignableTo("PaidInvoice", "Document")).toBe(true); + }); + + it("still refuses a child whose enum has one member outside the parent's bound", () => { + const Overlong = defineNode("Overlong", { + schema: z.object({ + status: z.enum(["draft", "awaiting-countersignature"]), + priority: z.literal(5), + labels: z.record(z.string(), z.string()), + }), + }); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "subclass_finite_values_overlong", + nodes: { Document: { type: Document }, Overlong: { type: Overlong } }, + edges: {}, + ontology: [subClassOf(Overlong, Document)], + }), + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigurationError); + const details = (caught as ConfigurationError).details; + expect(details["code"]).toBe("ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE"); + expect(details["reason"]).toBe("string-length-not-tighter"); + expect(details["path"]).toEqual(["status"]); + }); +}); + +describe("registry check — equivalentTo refused in the failing direction only", () => { + it("refuses the direction whose schema is not a structural subtype, with the equivalence code", () => { + // X requires a tighter constraint than Y; X -> Y holds (X asks more), + // Y -> X does not (a short Y-valid string fails X's bound). + const X = defineNode("X", { + schema: z.object({ a: z.string().min(3) }), + }); + const Y = defineNode("Y", { + schema: z.object({ a: z.string() }), + }); + + let caught: unknown; + try { + buildKindRegistry( + defineGraph({ + id: "equivalence_direction", + nodes: { X: { type: X }, Y: { type: Y } }, + edges: {}, + ontology: [equivalentTo(X, Y)], + }), + ); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigurationError); + const configError = caught as ConfigurationError; + expect(configError.details["code"]).toBe( + "ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE", + ); + expect(configError.details["childKind"]).toBe("Y"); + expect(configError.details["parentKind"]).toBe("X"); + expect(configError.details["viaEquivalence"]).toBe(true); + expect(configError.details["relation"]).toBe("equivalentTo"); + expect(configError.message).toContain("equivalentTo(Y, X) (declared)"); + }); + + it("names a persisted sameAs as the relation it declared", () => { + const X = defineNode("X", { + schema: z.object({ a: z.string().min(3) }), + }); + const Y = defineNode("Y", { + schema: z.object({ a: z.string() }), + }); + const serialized = serializeSchema( + defineGraph({ + id: "same_as_direction", + nodes: { X: { type: X }, Y: { type: Y } }, + edges: {}, + }), + 1, + ); + const withSameAs = { + ...serialized, + ontology: { + ...serialized.ontology, + relations: [{ metaEdge: "sameAs", from: "X", to: "Y" }], + }, + }; + + let caught: unknown; + try { + deserializeSchema(withSameAs).buildRegistry(); + } catch (error) { + caught = error; + } + + expect(caught).toBeInstanceOf(ConfigurationError); + const configError = caught as ConfigurationError; + expect(configError.details).toMatchObject({ + code: "ONTOLOGY_EQUIVALENCE_NOT_STRUCTURAL_SUBTYPE", + relation: "sameAs", + childKind: "Y", + parentKind: "X", + viaEquivalence: true, + declared: true, + }); + expect(configError.message).toContain("sameAs(Y, X) (declared)"); + }); + // MUTATION CHECK: naming every equivalence violation `equivalentTo` (the + // relation-blind spelling `violatedRelationName` replaces) reports + // `equivalentTo(Y, X)` for a relation the document never declared. +}); + +describe("registry check — deserialized documents are checked identically to live graphs", () => { + it("refuses a persisted document carrying an incompatible subClassOf", () => { + const Loose = defineNode("Loose", { + schema: z.object({ code: z.string() }), + }); + const Tight = defineNode("Tight", { + schema: z.object({ code: z.string().min(5) }), + }); + const graph = defineGraph({ + id: "subclass_persisted", + nodes: { Loose: { type: Loose }, Tight: { type: Tight } }, + edges: {}, + // Bypass buildKindRegistry (which would refuse at defineGraph-adjacent + // build time) by serializing a graph with no ontology, then hand- + // crafting the persisted relation the way an older, laxer validator + // could have written it. + ontology: [], + }); + const serialized = serializeSchema(graph, 1); + const withIncompatibleRelation = { + ...serialized, + ontology: { + ...serialized.ontology, + relations: [ + ...serialized.ontology.relations, + { metaEdge: "subClassOf", from: "Loose", to: "Tight" }, + ], + }, + }; + + expect(() => + deserializeSchema(withIncompatibleRelation).buildRegistry(), + ).toThrow(ConfigurationError); + }); +}); + +describe("registry check — evolve() checks an authored extension before any write", () => { + it("refuses an extension declaring an incompatible child, before the store accepts the kind", async () => { + const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), + }); + const baseGraph = defineGraph({ + id: "evolve_incompatible_child", + nodes: { Person: { type: Person } }, + edges: {}, + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + + const extension = defineGraphExtension({ + nodes: { + Base: { properties: { code: { type: "string", minLength: 5 } } }, + Derived: { properties: { code: { type: "string" } } }, + }, + ontology: [{ metaEdge: "subClassOf", from: "Derived", to: "Base" }], + }); + + await expect(store.evolve(extension)).rejects.toThrow(ConfigurationError); + // No partial write: the extension kind never became reachable. + expect(store.getNodeCollection("Derived")).toBeUndefined(); + }); + + it("refuses evolve() redeclaring an existing kind so it stops extending its parent", async () => { + const Media = defineNode("Media", { + schema: z.object({ title: z.string().min(3) }), + }); + const baseGraph = defineGraph({ + id: "evolve_redeclare_breaks_parent", + nodes: { Media: { type: Media } }, + edges: {}, + }); + const backend = createTestBackend(); + const [store] = await createStoreWithSchema(baseGraph, backend); + + // First evolve declares a compatible Podcast subclass. + const compatibleExtension = defineGraphExtension({ + nodes: { + Podcast: { + properties: { + title: { type: "string", minLength: 3 }, + rssUrl: { type: "string" }, + }, + }, + }, + ontology: [{ metaEdge: "subClassOf", from: "Podcast", to: "Media" }], + }); + const evolved = await store.evolve(compatibleExtension); + expect(evolved.getNodeCollection("Podcast")).toBeDefined(); + + // Redeclaring Podcast with a looser title breaks the already-declared + // subClassOf(Podcast, Media) — refused, even though nothing about the + // relation itself changed. + const breakingExtension = defineGraphExtension({ + nodes: { + Podcast: { + properties: { + title: { type: "string" }, + rssUrl: { type: "string" }, + }, + }, + }, + ontology: [{ metaEdge: "subClassOf", from: "Podcast", to: "Media" }], + }); + + await expect(evolved.evolve(breakingExtension)).rejects.toThrow( + ConfigurationError, + ); + }); +}); + +/** Pinned before per-field comparison existed: it must never move. */ +const PERSISTED_EMBEDDING_KIND_PROJECTION = JSON.stringify({ type: "object" }); +const PERSISTED_EMBEDDING_GRAPH_HASH = "061d57c95847595f"; + +/** The `ConfigurationError` a registry build throws, or `undefined` when it builds. */ +function registryRefusal(graph: GraphDef): ConfigurationError | undefined { + try { + buildKindRegistry(graph); + return undefined; + } catch (error) { + if (error instanceof ConfigurationError) return error; + throw error; + } +} + +describe("registry check — a field the projection cannot convert is judged on its own, every other field is still compared", () => { + // `z.toJSONSchema` has no spelling for `embedding()` (a `z.custom()`), + // `z.date()`, `z.set()`, `z.map()`, `z.bigint()` or a `.transform()`. A + // live graph's comparison projection marks each such node on its own, so + // that node is accepted only under a parent field that admits every value + // or is itself unconvertible, while its siblings are judged. + it("refuses a subclass that adds embedding() when an unrelated field is incompatible", () => { + const Tight = defineNode("Tight", { + schema: z.object({ code: z.string().min(5) }), + }); + const Embedded = defineNode("Embedded", { + schema: z.object({ code: z.string(), vector: embedding(3) }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: "subclass_embedding_with_mismatch", + nodes: { Tight: { type: Tight }, Embedded: { type: Embedded } }, + edges: {}, + ontology: [subClassOf(Embedded, Tight)], + }), + ); + + expect(refusal?.details["code"]).toBe( + "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + ); + expect(refusal?.details["childKind"]).toBe("Embedded"); + expect(refusal?.details["parentKind"]).toBe("Tight"); + expect(refusal?.details["reason"]).toBe("string-length-not-tighter"); + expect(refusal?.details["path"]).toEqual(["code"]); + }); + + it("refuses a live pair whose incompatibility sits beside a z.set() field both sides declare", () => { + const SetChildLive = defineNode("SetChildLive", { + schema: z.object({ code: z.string(), tags: z.set(z.string()) }), + }); + const SetParentLive = defineNode("SetParentLive", { + schema: z.object({ + code: z.string().min(5), + tags: z.set(z.string()), + }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: "subclass_set_with_mismatch", + nodes: { + SetChildLive: { type: SetChildLive }, + SetParentLive: { type: SetParentLive }, + }, + edges: {}, + ontology: [subClassOf(SetChildLive, SetParentLive)], + }), + ); + + expect(refusal?.details["reason"]).toBe("string-length-not-tighter"); + expect(refusal?.details["path"]).toEqual(["code"]); + }); + + it("refuses a nested object holding an opaque field beside an incompatible sibling", () => { + const Parent = defineNode("Parent", { + schema: z.object({ + details: z.object({ at: z.date(), code: z.string().min(5) }), + }), + }); + const Child = defineNode("Child", { + schema: z.object({ + details: z.object({ at: z.date(), code: z.string() }), + }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: "subclass_nested_opaque_with_mismatch", + nodes: { Parent: { type: Parent }, Child: { type: Child } }, + edges: {}, + ontology: [subClassOf(Child, Parent)], + }), + ); + + expect(refusal?.details["reason"]).toBe("string-length-not-tighter"); + expect(refusal?.details["path"]).toEqual(["details", "code"]); + }); + + it("accepts independently declared z.date() fields on both sides", () => { + const Event = defineNode("Event", { + schema: z.object({ title: z.string(), at: z.date() }), + }); + const Meeting = defineNode("Meeting", { + schema: z.object({ + title: z.string().min(1), + at: z.date(), + room: z.string(), + }), + }); + + const graph = defineGraph({ + id: "subclass_independent_dates", + nodes: { Event: { type: Event }, Meeting: { type: Meeting } }, + edges: {}, + ontology: [subClassOf(Meeting, Event)], + }); + + expect(registryRefusal(graph)).toBeUndefined(); + expect(buildKindRegistry(graph).isAssignableTo("Meeting", "Event")).toBe( + true, + ); + }); + + it("refuses a subclass that redeclares a constrained parent field through a .transform()", () => { + const Tagged = defineNode("Tagged", { + schema: z.object({ label: z.string().min(2) }), + }); + const Normalized = defineNode("Normalized", { + schema: z.object({ + label: z.string().transform((value) => value.trim()), + }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: "subclass_transform_redeclaration", + nodes: { Tagged: { type: Tagged }, Normalized: { type: Normalized } }, + edges: {}, + ontology: [subClassOf(Normalized, Tagged)], + }), + ); + + // `" a"` stores as `"a"`, which `min(2)` refuses: nothing the projection + // states shows the transform's output satisfies the parent. + expect(refusal?.details).toMatchObject({ + code: "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + childKind: "Normalized", + parentKind: "Tagged", + reason: "unsupported-construct", + path: ["label"], + }); + }); + + it("refuses a parent field whose own transform hides a constraint from a plain child", () => { + const Trimmed = defineNode("Trimmed", { + schema: z.object({ + label: z + .string() + .min(3) + .transform((value) => value.trim()), + }), + }); + const Plain = defineNode("Plain", { + schema: z.object({ label: z.string() }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: "subclass_under_transformed_parent", + nodes: { Trimmed: { type: Trimmed }, Plain: { type: Plain } }, + edges: {}, + ontology: [subClassOf(Plain, Trimmed)], + }), + ); + + expect(refusal?.details).toMatchObject({ + code: "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + reason: "unsupported-construct", + path: ["label"], + }); + }); + + it("accepts an unconvertible child field under a parent field that admits every value", () => { + const Loose = defineNode("Loose", { + schema: z.object({ label: z.unknown() }), + }); + const Normalized = defineNode("NormalizedUnderLoose", { + schema: z.object({ + label: z.string().transform((value) => value.trim()), + }), + }); + + expect( + registryRefusal( + defineGraph({ + id: "subclass_transform_under_unknown", + nodes: { + Loose: { type: Loose }, + NormalizedUnderLoose: { type: Normalized }, + }, + edges: {}, + ontology: [subClassOf(Normalized, Loose)], + }), + ), + ).toBeUndefined(); + }); + + // Zod also reports as unconvertible a fact of a node it otherwise converts: + // a dynamic `.catch()` keeps its inner `type` and loses only the catch. Such + // a node is compared as one carrying a Zod-only constraint, not accepted + // unjudged, so the `type` it still states is never skipped. + const dynamicCatchSides = { + child: { + child: () => z.number().catch((error) => Number(error.value)), + parent: () => z.string().min(5), + }, + parent: { + child: () => z.number(), + parent: () => z.string().catch((error) => String(error.value)), + }, + } as const; + for (const [side, fields] of Object.entries(dynamicCatchSides)) { + it(`refuses a pair whose ${side} field holds a dynamic .catch() over a different type`, () => { + const Parent = defineNode("Parent", { + schema: z.object({ name: fields.parent() }), + }); + const Child = defineNode("Child", { + schema: z.object({ name: fields.child() }), + }); + + const refusal = registryRefusal( + defineGraph({ + id: `subclass_dynamic_catch_${side}`, + nodes: { Parent: { type: Parent }, Child: { type: Child } }, + edges: {}, + // The compile-time check refuses this pair; the cast stands in for + // an untyped caller so the registry check is the one exercised. + ontology: [subClassOf(Child as unknown as typeof Parent, Parent)], + }), + ); + + console.log(`dynamic .catch() on the ${side}:`, refusal?.details); + // A child `.catch()` may also leave the key out of what it stores, a + // refusal of its own; either way the pair is refused at the field. + expect([ + "ONTOLOGY_SUBCLASS_SCHEMA_INCOMPARABLE", + "ONTOLOGY_SUBCLASS_NOT_STRUCTURAL_SUBTYPE", + ]).toContain(refusal?.details["code"]); + expect(refusal?.details["path"]).toEqual(["name"]); + }); + } + + it("accepts a subclass that extends a parent with defaulted format and date fields", () => { + // `.extend()` shares each field: the child stores exactly what the parent + // stores, defaults included, even where they cannot be judged by value. + const parentSchema = z.object({ + contact: z.email().default("team@example.com"), + createdAt: z.date().default(() => new Date(0)), + label: z.string().min(1).default(""), + }); + const Ticket = defineNode("Ticket", { schema: parentSchema }); + const Escalation = defineNode("Escalation", { + schema: parentSchema.extend({ level: z.number() }), + }); + + expect( + registryRefusal( + defineGraph({ + id: "subclass_extended_defaults", + nodes: { Ticket: { type: Ticket }, Escalation: { type: Escalation } }, + edges: {}, + ontology: [subClassOf(Escalation, Ticket)], + }), + ), + ).toBeUndefined(); + }); + + it("accepts a subclass that shares its parent's dynamic .catch() field schema", () => { + const name = z.string().catch((error) => String(error.value)); + const Parent = defineNode("Parent", { schema: z.object({ name }) }); + const Child = defineNode("Child", { + schema: z.object({ name, extra: z.string() }), + }); + + expect( + registryRefusal( + defineGraph({ + id: "subclass_shared_dynamic_catch", + nodes: { Parent: { type: Parent }, Child: { type: Child } }, + edges: {}, + ontology: [subClassOf(Child, Parent)], + }), + ), + ).toBeUndefined(); + }); + + // Only the CHILD holds an unprojectable field, and nothing else differs. + const UNPROJECTABLE_FIELDS = { + embedding: () => embedding(3), + "optional embedding": () => embedding(3).optional(), + "z.set": () => z.set(z.string()), + "z.map": () => z.map(z.string(), z.number()), + "z.date": () => z.date(), + "z.bigint": () => z.bigint(), + transform: () => z.string().transform((value) => value.length), + } as const; + + const PROJECTABLE_PARENTS = { + "a required property": () => z.object({ title: z.string() }), + "an optional property": () => z.object({ title: z.string().optional() }), + } as const; + + for (const [parentLabel, parentSchema] of Object.entries( + PROJECTABLE_PARENTS, + )) { + for (const [fieldLabel, field] of Object.entries(UNPROJECTABLE_FIELDS)) { + it(`accepts a subclass adding ${fieldLabel} under a parent with ${parentLabel}`, () => { + const Document = defineNode("Document", { schema: parentSchema() }); + const Article = defineNode("Article", { + schema: z.object({ + title: z.string(), + body: z.string(), + extra: field(), + }), + }); + const graph = defineGraph({ + id: "subclass_unprojectable_child", + nodes: { Document: { type: Document }, Article: { type: Article } }, + edges: {}, + ontology: [subClassOf(Article, Document)], + }); + + expect(registryRefusal(graph)).toBeUndefined(); + // The persisted document reaches the same verdict as the live graph. + expect(() => + deserializeSchema(serializeSchema(graph, 1)).buildRegistry(), + ).not.toThrow(); + }); + } + } + + it("accepts an equivalentTo pair when one side holds an unprojectable field", () => { + const Plain = defineNode("Plain", { + schema: z.object({ title: z.string() }), + }); + const Dated = defineNode("Dated", { + schema: z.object({ title: z.string(), at: z.date().optional() }), + }); + + expect( + registryRefusal( + defineGraph({ + id: "equivalence_unprojectable_side", + nodes: { Plain: { type: Plain }, Dated: { type: Dated } }, + edges: {}, + ontology: [equivalentTo(Plain, Dated)], + }), + ), + ).toBeUndefined(); + }); + + it("opens a store whose subclass adds an embedding to its parent", async () => { + const Content = defineNode("Content", { + schema: z.object({ title: z.string(), createdBy: z.string() }), + }); + const Document = defineNode("Document", { + schema: z.object({ + title: z.string(), + createdBy: z.string(), + embedding: embedding(8).optional(), + }), + }); + const [store] = await createStoreWithSchema( + defineGraph({ + id: "subclass_embedding_store", + nodes: { Content: { type: Content }, Document: { type: Document } }, + edges: {}, + ontology: [subClassOf(Document, Content)], + }), + createTestBackend(), + ); + + await store.nodes.Document.create({ title: "Spec", createdBy: "ada" }); + const contents = await store + .query() + .from("Content", "content") + .select((context) => context.content) + .execute(); + expect(contents.map((content) => content.kind)).toEqual(["Document"]); + }); + + // The per-field markers exist only in the comparison input: the persisted + // projection of a kind holding embedding() is still the whole-kind + // placeholder, so neither the stored document nor its hash moves. + it("leaves the persisted projection and the schema hash of a kind with embedding() unchanged", async () => { + const Content = defineNode("Content", { + schema: z.object({ title: z.string() }), + }); + const Document = defineNode("Document", { + schema: z.object({ title: z.string(), embedding: embedding(4) }), + }); + const graph = defineGraph({ + id: "subclass_embedding_hash", + nodes: { Content: { type: Content }, Document: { type: Document } }, + edges: {}, + ontology: [subClassOf(Document, Content)], + }); + + buildKindRegistry(graph); + const serialized = serializeSchema(graph, 1); + + expect(JSON.stringify(serialized.nodes["Document"]?.properties)).toBe( + PERSISTED_EMBEDDING_KIND_PROJECTION, + ); + expect(await computeSchemaHash(serialized)).toBe( + PERSISTED_EMBEDDING_GRAPH_HASH, + ); + }); +}); + +describe("registry check — a persisted document compares an unprojectable kind as a whole", () => { + // A deserialized document has no Zod schema, only the persisted + // projection, and a kind holding a construct `z.toJSONSchema` cannot + // convert is persisted as the whole-kind placeholder. There is nothing to + // compare, so a pair with such a kind on either side is skipped. This + // pair is genuinely incompatible (Parent requires a "tags" set the Child + // does not declare) and is accepted. The compile-time check would refuse + // it, so it reaches the registry through a hand-assembled document, the + // way an older, laxer validator could have written it. + it("does not refuse a persisted subClassOf pair that differs only inside a z.set()/z.map() field", () => { + const SetChild = defineNode("SetChild", { + schema: z.object({ note: z.string() }), + }); + const SetParent = defineNode("SetParent", { + schema: z.object({ note: z.string(), tags: z.set(z.string()) }), + }); + const graph = defineGraph({ + id: "subclass_unconvertible_gap", + nodes: { SetChild: { type: SetChild }, SetParent: { type: SetParent } }, + edges: {}, + ontology: [], + }); + const serialized = serializeSchema(graph, 1); + const withUnconvertibleRelation = { + ...serialized, + ontology: { + ...serialized.ontology, + relations: [ + ...serialized.ontology.relations, + { metaEdge: "subClassOf", from: "SetChild", to: "SetParent" }, + ], + }, + }; + + expect(() => + deserializeSchema(withUnconvertibleRelation).buildRegistry(), + ).not.toThrow(); + }); +}); diff --git a/packages/typegraph/tests/typed-subsumption-types.test.ts b/packages/typegraph/tests/typed-subsumption-types.test.ts new file mode 100644 index 000000000..31e55e9b2 --- /dev/null +++ b/packages/typegraph/tests/typed-subsumption-types.test.ts @@ -0,0 +1,1708 @@ +/** + * Compile-time type tests for typed subsumption. + * + * Tests marked with @ts-expect-error verify that an incompatible + * `subClassOf` / `equivalentTo` pair is refused AT COMPILE TIME, + * with the failure carrier (`StructuralSubtypeMismatch`) naming the + * offending fields. Load-bearing: see + * tests/property/typed-subsumption-agreement.test.ts for the runtime-side + * agreement pin. + */ +import { describe, expect, expectTypeOf, it } from "vitest"; +import { z } from "zod"; + +import { + type AssignableNodeKinds, + type BaseStoreOptions, + broader, + createQueryBuilder, + defineEdge, + defineGraph, + defineNode, + equivalentTo, + type FulltextSearchOptions, + type GraphNodeReferenceInput, + type HybridSearchOptions, + type Node, + type NodeId, + type NodeReferenceLookup, + type NodeType, + relatedTo, + type Store, + subClassOf, + type SubsumedNodeReference, + type TransactionContext, + type VectorSearchOptions, +} from "../src"; +import { ConfigurationError } from "../src/errors"; +import type { + IncompatibleKeys, + OntologyRelation, + StructuralSubtypeMismatch, + TypedOntologyRelation, +} from "../src/ontology/types"; +import { + type AliasExpansionOptions, + type DefaultAliasExpansionAxis, +} from "../src/query/builder/alias-expansion"; +import { buildKindRegistry } from "../src/registry/builders"; + +// ============================================================ +// Fixtures +// ============================================================ + +const Media = defineNode("Media", { + schema: z.object({ title: z.string() }), +}); + +const Podcast = defineNode("Podcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), +}); + +const Person = defineNode("Person", { + schema: z.object({ name: z.string() }), +}); + +const Employee = defineNode("Employee", { + schema: z.object({ name: z.string(), employeeId: z.string() }), +}); + +const Status = defineNode("Status", { + schema: z.object({ state: z.enum(["active", "inactive", "pending"]) }), +}); + +const ActiveStatus = defineNode("ActiveStatus", { + schema: z.object({ state: z.literal("active") }), +}); + +const RequiredWidget = defineNode("RequiredWidget", { + schema: z.object({ label: z.string() }), +}); + +const OptionalWidget = defineNode("OptionalWidget", { + schema: z.object({ label: z.string().optional() }), +}); + +/** + * A caller's own taxonomy helper, annotated with bare `NodeType` endpoints: + * the shape that keeps the `subClassOf` meta-edge name literal and loses the + * endpoint kind literals. + */ +function declareTaxonomy(child: NodeType, parent: NodeType) { + return subClassOf(child, parent); +} + +function first(items: readonly T[]): T { + const [item] = items; + if (item === undefined) throw new Error("expected a nonempty list"); + return item; +} + +describe("subClassOf structural contract", () => { + it("compiles when the child's schema extends the parent's", () => { + const relation = subClassOf(Podcast, Media); + expect(relation.metaEdge.name).toBe("subClassOf"); + expect(relation.from).toBe(Podcast); + expect(relation.to).toBe(Media); + }); + + it("refuses a child missing a required parent property, naming the field", () => { + expectTypeOf< + IncompatibleKeys< + z.infer<(typeof Person)["schema"]>, + z.infer<(typeof Employee)["schema"]> + > + >().toEqualTypeOf<"employeeId">(); + + // @ts-expect-error - Person lacks Employee's required employeeId field + subClassOf(Person, Employee); + }); + + it("compiles for an empty-schema parent (a regression pin for the earlier false-positive fix)", () => { + // A parent with an EMPTY schema (`z.object({})`) has `keyof {} = never`, + // which is the exact inference trap `SubClassOfCheck`'s docblock + // documents (`src/ontology/types.ts`): the intersection-parameter form + // (`parent: P & SubClassOfCheck`) is what keeps this compiling. + const EmptySchemaParent = defineNode("EmptySchemaParent", { + schema: z.object({}), + }); + const AnyChild = defineNode("AnyChildOfEmptyParent", { + schema: z.object({ note: z.string() }), + }); + const relation = subClassOf(AnyChild, EmptySchemaParent); + expect(relation.to).toBe(EmptySchemaParent); + }); + + it("accepts a child narrowing a parent enum to a literal", () => { + const relation = subClassOf(ActiveStatus, Status); + expect(relation.from).toBe(ActiveStatus); + }); + + it("refuses a child relaxing a parent-required property to optional", () => { + // @ts-expect-error - OptionalWidget relaxes RequiredWidget's required label + subClassOf(OptionalWidget, RequiredWidget); + }); + + it("accepts width subtyping — the child may add properties", () => { + // Podcast adds rssUrl on top of Media's title; already exercised above, + // pinned again here for the width-subtyping-by-name test. + const relation = subClassOf(Podcast, Media); + expect(relation.to).toBe(Media); + }); + + it("names the message in StructuralSubtypeMismatch", () => { + expectTypeOf< + StructuralSubtypeMismatch<"Person", "Employee", "employeeId"> + >().toHaveProperty("__typegraphSubClassOfError"); + }); +}); + +describe("equivalentTo mutual structural contract", () => { + it("compiles both directions for an identical pair", () => { + const Alpha = defineNode("Alpha", { schema: z.object({ x: z.string() }) }); + const Beta = defineNode("Beta", { schema: z.object({ x: z.string() }) }); + const forward = equivalentTo(Alpha, Beta); + const backward = equivalentTo(Beta, Alpha); + expect(forward.from).toBe(Alpha); + expect(backward.from).toBe(Beta); + }); + + it("refuses the direction that fails for an asymmetric pair", () => { + // Employee has an extra required field over Person; Employee -> Person + // holds (Employee narrows to Person's shape is false, but + // Person -> Employee holds mutual-subtype only if BOTH directions + // extend). Person lacks employeeId, so neither direction is a full + // equivalence. + // @ts-expect-error - Person is missing Employee's employeeId field + equivalentTo(Person, Employee); + }); + + it("allows the IRI form with no schema check", () => { + const relation = equivalentTo(Person, "https://schema.org/Person"); + expect(relation.to).toBe("https://schema.org/Person"); + }); +}); + +describe("alias typing under the polymorphic axis", () => { + const MediaKind = defineNode("MediaAliasTest", { + schema: z.object({ title: z.string() }), + }); + const PodcastKind = defineNode("PodcastAliasTest", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), + }); + const cites = defineEdge("citesAliasTest", { schema: z.object({}) }); + + const affectedGraph = defineGraph({ + id: "alias_typing_affected", + nodes: { + MediaAliasTest: { type: MediaKind }, + PodcastAliasTest: { type: PodcastKind }, + }, + edges: {}, + ontology: [subClassOf(PodcastKind, MediaKind)], + }); + const affectedRegistry = buildKindRegistry(affectedGraph); + + const plainGraph = defineGraph({ + id: "alias_typing_plain", + nodes: { MediaAliasTest: { type: MediaKind } }, + edges: {}, + ontology: [], + }); + const plainRegistry = buildKindRegistry(plainGraph); + + it("widens kind to string on a subsumption-affected alias with no explicit option", () => { + const query = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + ) + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + + it("keeps kind literal when the graph declares no subsumption relations", () => { + const query = createQueryBuilder( + plainGraph.id, + plainRegistry, + ) + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf<"MediaAliasTest">(); + }); + + it("widens kind to string when the ontology tuple is annotated as bare OntologyRelation[]", () => { + // The changeset's Breaking-changes bullet blesses this exact pattern: + // "Code that annotates a relation's result as `OntologyRelation` still + // compiles unchanged." Annotating the array itself erases every element + // to the untyped `OntologyRelation` shape, so `SubsumptionAffected` can + // no longer see the `to: { kind: "MediaAliasTest" }` literal by + // `Extract`ing it — it must fall back to conservative widening instead + // of silently reporting `false`, which would let the alias stay narrow + // while the runtime alias is genuinely polymorphic. + const annotatedOntology: readonly OntologyRelation[] = [ + subClassOf(PodcastKind, MediaKind), + ]; + const annotatedGraph = defineGraph({ + id: "alias_typing_annotated", + nodes: { + MediaAliasTest: { type: MediaKind }, + PodcastAliasTest: { type: PodcastKind }, + }, + edges: {}, + ontology: annotatedOntology, + }); + const annotatedRegistry = buildKindRegistry(annotatedGraph); + + const query = createQueryBuilder( + annotatedGraph.id, + annotatedRegistry, + ) + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + + describe("list sources", () => { + const CompanyKind = defineNode("CompanyListAliasTest", { + schema: z.object({ title: z.string() }), + }); + const listGraph = defineGraph({ + id: "alias_typing_list", + nodes: { + MediaAliasTest: { type: MediaKind }, + PodcastAliasTest: { type: PodcastKind }, + CompanyListAliasTest: { type: CompanyKind }, + }, + edges: {}, + ontology: [subClassOf(PodcastKind, MediaKind)], + }); + const listRegistry = buildKindRegistry(listGraph); + const LISTED = ["MediaAliasTest", "CompanyListAliasTest"] as const; + const listQuery = () => + createQueryBuilder(listGraph.id, listRegistry); + + it("types each listed kind as a single-kind alias over it would be when no expansion is stated", () => { + const query = listQuery() + .from(LISTED, "n") + .select((ctx) => ctx.n); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf< + Extract["id"] + >().toEqualTypeOf>(); + }); + + it('widens every listed kind under expansion: "subclasses"', () => { + const query = listQuery() + .from(LISTED, "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n); + expect(query).toBeDefined(); + const media = listQuery() + .from("MediaAliasTest", "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n); + const company = listQuery() + .from("CompanyListAliasTest", "n", { expansion: "subclasses" }) + .select((ctx) => ctx.n); + expect(media).toBeDefined(); + expect(company).toBeDefined(); + type Row = Awaited>[number]; + type MediaRow = Awaited>[number]; + type CompanyRow = Awaited>[number]; + expectTypeOf< + Extract + >().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it('keeps the exact union under expansion: "exact"', () => { + const query = listQuery() + .from(LISTED, "n", { expansion: "exact" }) + .select((ctx) => ctx.n); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf< + "MediaAliasTest" | "CompanyListAliasTest" + >(); + }); + + it("types an axis-unknown options bag as a single-kind source types it", () => { + const options: AliasExpansionOptions = {}; + const listed = listQuery() + .from(LISTED, "n", options) + .select((ctx) => ctx.n); + const single = listQuery() + .from("CompanyListAliasTest", "n", options) + .select((ctx) => ctx.n); + expect(listed).toBeDefined(); + expect(single).toBeDefined(); + expectTypeOf< + Awaited>[number] + >().toEqualTypeOf>[number]>(); + }); + }); + + it('expansion: "exact" keeps kind literal even on an affected alias', () => { + const query = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + ) + .from("MediaAliasTest", "m", { expansion: "exact" }) + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf<"MediaAliasTest">(); + }); + + it('a store-wide default of "exact" changes the rows, not the alias type', () => { + const query = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + { defaultExpansion: "exact" }, + ) + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + + it("refuses passing a polymorphic alias's row.id to the exact-kind collection's update()", () => { + // A polymorphic-affected alias's `id` is widened so an exact `NodeId` + // is assignable TO it but not FROM it — the row may be a subclass, so `store.nodes..update(row.id)` + // must not typecheck against the exact collection. + const query = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + ) + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m); + type Row = Awaited>[number]; + + // Type-checked only — never called, so nothing here runs at test time. + function assertRowIdRejectedByExactUpdate(): void { + const store = undefined as unknown as Store; + const row = undefined as unknown as Row; + // @ts-expect-error - row.id is widened (may be a PodcastAliasTest id); Media's update() requires an exact NodeId + void store.nodes.MediaAliasTest.update(row.id, { title: "x" }); + } + void assertRowIdRejectedByExactUpdate; + expect(query).toBeDefined(); + }); + + it("accepts an empty options object and an explicit undefined, typed like no options at all", () => { + // R-S1: ordinary option forwarding. A caller threading an options bag + // it did not populate must not have to drop the argument entirely. + const builder = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + ); + const emptyOptions = builder + .from("MediaAliasTest", "m", {}) + .select((ctx) => ctx.m); + const undefinedOptions = builder + .from("MediaAliasTest", "m", undefined) + .select((ctx) => ctx.m); + expect(emptyOptions).toBeDefined(); + expect(undefinedOptions).toBeDefined(); + + type EmptyRow = Awaited>[number]; + type UndefinedRow = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + }); + + it("forwards an unstated option identically on every expansion entry point", () => { + // R-S1, load-bearing at COMPILE time: each call below fails to typecheck + // if its entry point's default overload stops admitting the shape the + // caller passes. `exactOptionalPropertyTypes` makes the three shapes + // genuinely distinct — a bare `expansion?: "subclasses"` rejects the + // stated `undefined`, which is what `fromDynamic`/`toDynamic` used to do + // while `from`/`to` accepted it. + const edgeGraph = defineGraph({ + id: "rs1_forwarding", + nodes: { + MediaAliasTest: { type: MediaKind }, + PodcastAliasTest: { type: PodcastKind }, + }, + edges: { + citesAliasTest: { + type: cites, + from: [MediaKind], + to: [MediaKind, PodcastKind], + }, + }, + ontology: [subClassOf(PodcastKind, MediaKind)], + }); + const edgeRegistry = buildKindRegistry(edgeGraph); + const builder = () => + createQueryBuilder(edgeGraph.id, edgeRegistry); + const unstated: { expansion?: undefined } = {}; + + const fromCalls = [ + builder().from("MediaAliasTest", "m"), + builder().from("MediaAliasTest", "m", {}), + builder().from("MediaAliasTest", "m", undefined), + builder().from("MediaAliasTest", "m", { expansion: undefined }), + builder().from("MediaAliasTest", "m", unstated), + ]; + const fromDynamicCalls = [ + builder().fromDynamic("MediaAliasTest", "m"), + builder().fromDynamic("MediaAliasTest", "m", {}), + builder().fromDynamic("MediaAliasTest", "m", undefined), + builder().fromDynamic("MediaAliasTest", "m", { expansion: undefined }), + builder().fromDynamic("MediaAliasTest", "m", unstated), + ]; + const traversal = () => + builder() + .from("MediaAliasTest", "m", { expansion: "exact" }) + .traverse("citesAliasTest", "e"); + const toCalls = [ + traversal().to("MediaAliasTest", "t"), + traversal().to("MediaAliasTest", "t", {}), + traversal().to("MediaAliasTest", "t", undefined), + traversal().to("MediaAliasTest", "t", { expansion: undefined }), + traversal().to("MediaAliasTest", "t", unstated), + ]; + const toDynamicCalls = [ + traversal().toDynamic("MediaAliasTest", "t"), + traversal().toDynamic("MediaAliasTest", "t", {}), + traversal().toDynamic("MediaAliasTest", "t", undefined), + traversal().toDynamic("MediaAliasTest", "t", { expansion: undefined }), + traversal().toDynamic("MediaAliasTest", "t", unstated), + ]; + + // Every shape resolves to the SAME builder type per entry point, so the + // aliases projected off one of them stand for all five: `from` widens to + // the polymorphic kind under the store default, and a `"narrower"`-free + // `toDynamic` does too. + const fromQuery = builder() + .from("MediaAliasTest", "m", {}) + .select((ctx) => ctx.m); + expect(fromQuery).toBeDefined(); + type FromRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const toQuery = traversal() + .to("MediaAliasTest", "t", { expansion: undefined }) + .select((ctx) => ctx.t); + expect(toQuery).toBeDefined(); + type ToRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + + for (const call of [ + ...fromCalls, + ...fromDynamicCalls, + ...toCalls, + ...toDynamicCalls, + ]) { + expect(call).toBeDefined(); + } + }); + + it("forwards an options bag that can carry an axis, on every entry point", () => { + // The other half of forwarding: a wrapper that narrows SOME calls has to + // be able to hand its own bag through. Every shape below carries a + // stateable axis, so none of them matches a per-axis overload — each one + // is a compile error if the forwarding overload is missing. The alias + // type is the conservative untyped one, because the axis is not a literal + // at the call site. + const edgeGraph = defineGraph({ + id: "rs1_forwarding_axis", + nodes: { + MediaAliasTest: { type: MediaKind }, + PodcastAliasTest: { type: PodcastKind }, + }, + edges: { + citesAxisTest: { + type: cites, + from: [MediaKind], + to: [MediaKind, PodcastKind], + }, + }, + ontology: [subClassOf(PodcastKind, MediaKind)], + }); + const edgeRegistry = buildKindRegistry(edgeGraph); + const builder = () => + createQueryBuilder(edgeGraph.id, edgeRegistry); + const owned: AliasExpansionOptions = { expansion: "exact" }; + const narrowingOnly: { expansion?: "exact" } = {}; + const eitherSubsumptionAxis: { + expansion?: "exact" | "subclasses" | undefined; + } = {}; + const bags = [owned, narrowingOnly, eitherSubsumptionAxis]; + const traversal = () => + builder() + .from("MediaAliasTest", "m", { expansion: "exact" }) + .traverse("citesAxisTest", "e"); + const calls = bags.flatMap((bag) => [ + builder().from("MediaAliasTest", "m", bag), + builder().fromDynamic("MediaAliasTest", "m", bag), + traversal().to("MediaAliasTest", "t", bag), + traversal().toDynamic("MediaAliasTest", "t", bag), + ]); + for (const call of calls) expect(call).toBeDefined(); + + const forwardedQuery = builder() + .from("MediaAliasTest", "m", owned) + .select((ctx) => ctx.m); + expect(forwardedQuery).toBeDefined(); + type ForwardedRow = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf(); + + // The store-wide spellings of the same option forward a stated + // `undefined` too — a bare optional would reject it under + // `exactOptionalPropertyTypes`. + const defaultAxis: DefaultAliasExpansionAxis | undefined = undefined; + const scopedBuilder = createQueryBuilder( + edgeGraph.id, + edgeRegistry, + { defaultExpansion: defaultAxis }, + ); + expect(scopedBuilder).toBeDefined(); + const storeOptions: BaseStoreOptions = { + queryDefaults: { expansion: defaultAxis }, + }; + expect(storeOptions).toBeDefined(); + }); + + it("types an expanded search hit like the alias it expands to, for every search form", () => { + // Type-checked only — never called, so nothing here runs at test time. + async function assertExpandedSearchTyping(): Promise { + const store = undefined as unknown as Store; + const vectorLeg = { fieldPath: "embedding", queryEmbedding: [0, 1] }; + const fulltextLeg = { query: "x" }; + + // Exact, three spellings: omitted, stated, and an explicit undefined. + const exactFulltext = first( + await store.search.fulltext("MediaAliasTest", { query: "x", limit: 1 }), + ).node; + const exactVector = first( + await store.search.vector("MediaAliasTest", { + ...vectorLeg, + limit: 1, + expansion: "exact", + }), + ).node; + const exactHybrid = first( + await store.search.hybrid("MediaAliasTest", { + vector: vectorLeg, + fulltext: fulltextLeg, + limit: 1, + expansion: undefined, + }), + ).node; + expectTypeOf(exactFulltext.kind).toEqualTypeOf<"MediaAliasTest">(); + expectTypeOf(exactVector.kind).toEqualTypeOf<"MediaAliasTest">(); + expectTypeOf(exactHybrid.kind).toEqualTypeOf<"MediaAliasTest">(); + void store.nodes.MediaAliasTest.update(exactFulltext.id, { title: "x" }); + + const aliasRow = first( + await store + .query() + .from("MediaAliasTest", "m") + .select((ctx) => ctx.m) + .execute(), + ); + const fulltext = first( + await store.search.fulltext("MediaAliasTest", { + query: "x", + limit: 1, + expansion: "subclasses", + where: (media) => media.title.eq("x"), + }), + ).node; + const vector = first( + await store.search.vector("MediaAliasTest", { + ...vectorLeg, + limit: 1, + expansion: "subclasses", + }), + ).node; + const hybridHit = first( + await store.search.hybrid("MediaAliasTest", { + vector: vectorLeg, + fulltext: fulltextLeg, + limit: 1, + expansion: "subclasses", + }), + ); + expectTypeOf(fulltext.kind).toEqualTypeOf(); + expectTypeOf(vector.kind).toEqualTypeOf(); + expectTypeOf(hybridHit.node.kind).toEqualTypeOf(); + expectTypeOf(hybridHit.vector?.node.kind).toEqualTypeOf< + string | undefined + >(); + expectTypeOf(hybridHit.fulltext?.node.kind).toEqualTypeOf< + string | undefined + >(); + expectTypeOf(aliasRow.id).toEqualTypeOf(); + expectTypeOf(aliasRow.id).toEqualTypeOf(); + expectTypeOf(aliasRow.id).toEqualTypeOf(); + expectTypeOf(fulltext.title).toEqualTypeOf(); + // @ts-expect-error - the hit may be a PodcastAliasTest row; Media's update() requires an exact NodeId + void store.nodes.MediaAliasTest.update(fulltext.id, { title: "x" }); + + // A view's search facade is the store's own, so it types identically. + const viewHit = first( + await store + .view({ mode: "current" }) + .search.fulltext("MediaAliasTest", { + query: "x", + limit: 1, + expansion: "subclasses", + }), + ); + expectTypeOf(viewHit.node.kind).toEqualTypeOf(); + + // An axis the call site does not pin types conservatively. + const forwardedAxis = "exact" as DefaultAliasExpansionAxis; + const forwarded = first( + await store.search.fulltext("MediaAliasTest", { + query: "x", + limit: 1, + expansion: forwardedAxis, + }), + ); + expectTypeOf(forwarded.node.kind).toEqualTypeOf(); + + // Spelling the kind generic by hand still accepts the axis: the hit is + // exact without it and widened with it, never an option the call refuses. + const spelledExact = first( + await store.search.fulltext<"MediaAliasTest">("MediaAliasTest", { + query: "x", + limit: 1, + }), + ); + const spelledExpanded = first( + await store.search.fulltext<"MediaAliasTest">("MediaAliasTest", { + query: "x", + limit: 1, + expansion: "subclasses", + }), + ); + expectTypeOf(spelledExact.node.kind).toEqualTypeOf<"MediaAliasTest">(); + expectTypeOf(spelledExpanded.node.kind).toEqualTypeOf(); + + // A stated "subclasses" widens even a kind the compile-time ontology + // cannot affect, exactly as from(kind, alias, { expansion: + // "subclasses" }) types its alias: a persisted extension may add a + // subclass the compile-time graph never declares. + const plainStore = undefined as unknown as Store; + const plain = first( + await plainStore.search.fulltext("MediaAliasTest", { + query: "x", + limit: 1, + expansion: "subclasses", + }), + ); + const plainAliasRow = first( + await plainStore + .query() + .from("MediaAliasTest", "m", { expansion: "subclasses" }) + .select((ctx) => ctx.m) + .execute(), + ); + expectTypeOf(plain.node.kind).toEqualTypeOf(); + expectTypeOf(plainAliasRow.id).toEqualTypeOf(); + expectTypeOf(plain.node.title).toEqualTypeOf(); + // @ts-expect-error - the hit may be a subclass row a persisted extension added; Media's update() requires an exact NodeId + void plainStore.nodes.MediaAliasTest.update(plain.node.id, { + title: "x", + }); + const plainForwarded = first( + await plainStore.search.fulltext("MediaAliasTest", { + query: "x", + limit: 1, + expansion: forwardedAxis, + }), + ); + expectTypeOf(plainForwarded.node.kind).toEqualTypeOf(); + + // A wrapper forwarding the library's own option types, whose + // `expansion` is optional and either axis, reaches an overload; the + // axis is unpinned, so the hit widens. + async function forwardFulltext( + options: FulltextSearchOptions, + ) { + return store.search.fulltext("MediaAliasTest", options); + } + async function forwardVector( + options: VectorSearchOptions, + ) { + return store.search.vector("MediaAliasTest", options); + } + async function forwardHybrid( + options: HybridSearchOptions, + ) { + return store.search.hybrid("MediaAliasTest", options); + } + const forwardedFulltext = first( + await forwardFulltext({ query: "x", limit: 1 }), + ); + const forwardedVector = first( + await forwardVector({ ...vectorLeg, limit: 1 }), + ); + const forwardedHybrid = first( + await forwardHybrid({ + vector: vectorLeg, + fulltext: fulltextLeg, + limit: 1, + }), + ); + expectTypeOf(forwardedFulltext.node.kind).toEqualTypeOf(); + expectTypeOf(forwardedVector.node.kind).toEqualTypeOf(); + expectTypeOf(forwardedHybrid.node.kind).toEqualTypeOf(); + expectTypeOf(forwardedFulltext.node.title).toEqualTypeOf(); + } + void assertExpandedSearchTyping; + expect(affectedGraph.id).toBe("alias_typing_affected"); + }); + + it("refuses an expansion axis outside the option's domain", () => { + // Unreachable through the typed overloads; a JavaScript caller can still + // get here, and expanding to the wrong kind list silently would be worse + // than a refusal. Decided in `resolveAliasExpansion` before any SQL is + // emitted, so this belongs here and not in the cross-backend suite — the + // two dialects cannot disagree about it. + const builder = createQueryBuilder( + affectedGraph.id, + affectedRegistry, + ); + let caught: unknown; + try { + builder.from("MediaAliasTest", "m", { + expansion: "subClasses", + } as never); + } catch (error) { + caught = error; + } + expect(caught).toBeInstanceOf(ConfigurationError); + expect((caught as ConfigurationError).details["code"]).toBe( + "QUERY_ALIAS_EXPANSION_INVALID", + ); + expect((caught as ConfigurationError).message).toContain( + 'Unknown alias expansion "subClasses"', + ); + }); +}); + +describe("typed relations and conservative widening", () => { + const IriMedia = defineNode("IriMedia", { + schema: z.object({ title: z.string() }), + }); + const IriPerson = defineNode("IriPerson", { + schema: z.object({ name: z.string() }), + }); + + const iriGraph = defineGraph({ + id: "r2_iri_equivalence", + nodes: { IriMedia: { type: IriMedia }, IriPerson: { type: IriPerson } }, + edges: {}, + ontology: [equivalentTo(IriMedia, "https://schema.org/CreativeWork")], + }); + const iriRegistry = buildKindRegistry(iriGraph); + + it("widens because an IRI-routed equivalence really does expand at runtime", () => { + // The RUNTIME premise behind the type widening above: a second kind + // mapped to the same external term joins the first kind's subclass + // closure, so a query on `IriMedia` can genuinely return rows of a kind + // its declared type never named. Without this, the widening would be + // pessimism rather than soundness. + const CoRegistered = defineNode("IriCoRegistered", { + schema: z.object({ title: z.string() }), + }); + const sharedIri = "https://schema.org/CreativeWork"; + const sharedRegistry = buildKindRegistry( + defineGraph({ + id: "r2_iri_shared_term", + nodes: { + IriMedia: { type: IriMedia }, + IriCoRegistered: { type: CoRegistered }, + }, + edges: {}, + ontology: [ + equivalentTo(IriMedia, sharedIri), + equivalentTo(CoRegistered, sharedIri), + ], + }), + ); + expect(sharedRegistry.expandSubClasses("IriMedia")).toContain( + "IriCoRegistered", + ); + expect(sharedRegistry.expandSubClasses("IriMedia")).not.toContain( + sharedIri, + ); + }); + + it("widens the alias of a kind whose only equivalence is IRI-routed", () => { + const query = createQueryBuilder(iriGraph.id, iriRegistry) + .from("IriMedia", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + + it("keeps a kind the IRI equivalence does not name exact", () => { + // The other half of the load-bearing pair: before the IRI overload was + // typed it returned a bare `OntologyRelation`, which the conservative + // arm widens WHOLESALE — every kind in the graph, including this one. + // Only a relation carrying `from: { kind: "IriMedia" }` can widen one + // kind and leave the other alone. + const query = createQueryBuilder(iriGraph.id, iriRegistry) + .from("IriPerson", "p") + .select((ctx) => ctx.p); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf<"IriPerson">(); + }); + + it("keeps a kind exact when every tuple element is a typed non-subsumption relation", () => { + const TaxonomyMedia = defineNode("TaxonomyMedia", { + schema: z.object({ title: z.string() }), + }); + const TaxonomyPodcast = defineNode("TaxonomyPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), + }); + const taxonomyGraph = defineGraph({ + id: "r2_typed_tuple", + nodes: { + TaxonomyMedia: { type: TaxonomyMedia }, + TaxonomyPodcast: { type: TaxonomyPodcast }, + }, + edges: {}, + ontology: [ + broader(TaxonomyPodcast, TaxonomyMedia), + relatedTo(TaxonomyMedia, TaxonomyPodcast), + ], + }); + const taxonomyRegistry = buildKindRegistry(taxonomyGraph); + const query = createQueryBuilder( + taxonomyGraph.id, + taxonomyRegistry, + ) + .from("TaxonomyMedia", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf<"TaxonomyMedia">(); + }); + + it("widens every kind when one tuple element is annotated as a bare OntologyRelation", () => { + const AnnotatedMedia = defineNode("AnnotatedMedia", { + schema: z.object({ title: z.string() }), + }); + const AnnotatedPerson = defineNode("AnnotatedPerson", { + schema: z.object({ name: z.string() }), + }); + // The element — not the array — is annotated, so the tuple keeps its + // literal length `1` and `OntologyTypeErased` stays false. Only the + // per-element bare check can see this. + const annotatedRelation: OntologyRelation = broader( + AnnotatedPerson, + AnnotatedMedia, + ); + const annotatedGraph = defineGraph({ + id: "r2_annotated_element", + nodes: { + AnnotatedMedia: { type: AnnotatedMedia }, + AnnotatedPerson: { type: AnnotatedPerson }, + }, + edges: {}, + ontology: [annotatedRelation], + }); + const annotatedRegistry = buildKindRegistry(annotatedGraph); + const query = createQueryBuilder( + annotatedGraph.id, + annotatedRegistry, + ) + .from("AnnotatedPerson", "p") + .select((ctx) => ctx.p); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + it("widens every kind when a subClassOf's endpoint kind literals are erased", () => { + const ErasedMedia = defineNode("ErasedMedia", { + schema: z.object({ title: z.string() }), + }); + const ErasedPodcast = defineNode("ErasedPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), + }); + // `declareTaxonomy` states only `NodeType` for its endpoints. The + // meta-edge NAME literal survives — `MetaEdge<"subClassOf">` is not + // mutually assignable with `MetaEdge`, so a bare-relation test + // cannot see this — and the tuple keeps its literal length `1`, so + // `OntologyTypeErased` cannot either. What is gone is `to.kind`: the + // exact literal the `Extract` matches on. + const erasedGraph = defineGraph({ + id: "r2_erased_endpoint_kinds", + nodes: { + ErasedMedia: { type: ErasedMedia }, + ErasedPodcast: { type: ErasedPodcast }, + }, + edges: {}, + ontology: [declareTaxonomy(ErasedPodcast, ErasedMedia)], + }); + const erasedRegistry = buildKindRegistry(erasedGraph); + // The runtime premise: the relation is a real `subClassOf`, so a + // supertype query genuinely comes back with subtype rows. + expect(erasedRegistry.expandSubClasses("ErasedMedia")).toContain( + "ErasedPodcast", + ); + const query = createQueryBuilder( + erasedGraph.id, + erasedRegistry, + ) + .from("ErasedMedia", "m") + .select((ctx) => ctx.m); + expect(query).toBeDefined(); + type Row = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + }); + + it("keeps an unnamed kind exact when only the subClassOf child is erased", () => { + // Precision half of the same arm: the `Extract` reads a `subClassOf`'s + // `to`, never its `from`, so an erased CHILD leaves the decision intact. + // The supertype this relation names widens; a kind it does not name + // stays exact instead of the whole graph widening. + const ChildErasedMedia = defineNode("ChildErasedMedia", { + schema: z.object({ title: z.string() }), + }); + const ChildErasedPodcast = defineNode("ChildErasedPodcast", { + schema: z.object({ title: z.string(), rssUrl: z.string() }), + }); + const ChildErasedPerson = defineNode("ChildErasedPerson", { + schema: z.object({ name: z.string() }), + }); + const childErased: TypedOntologyRelation< + "subClassOf", + NodeType, + typeof ChildErasedMedia + > = subClassOf(ChildErasedPodcast, ChildErasedMedia); + const childErasedGraph = defineGraph({ + id: "r2_erased_child_kind", + nodes: { + ChildErasedMedia: { type: ChildErasedMedia }, + ChildErasedPodcast: { type: ChildErasedPodcast }, + ChildErasedPerson: { type: ChildErasedPerson }, + }, + edges: {}, + ontology: [childErased], + }); + const childErasedRegistry = buildKindRegistry(childErasedGraph); + const builder = () => + createQueryBuilder( + childErasedGraph.id, + childErasedRegistry, + ); + const namedQuery = builder() + .from("ChildErasedMedia", "m") + .select((ctx) => ctx.m); + expect(namedQuery).toBeDefined(); + type NamedRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const unnamedQuery = builder() + .from("ChildErasedPerson", "p") + .select((ctx) => ctx.p); + expect(unnamedQuery).toBeDefined(); + type UnnamedRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf<"ChildErasedPerson">(); + }); + + it("widens every kind an endpoint kind UNION can hold", () => { + // A caller helper that declares a subclass of EITHER media root. Its + // `to` is a union of two node types, so the relation keeps both its + // meta-edge name literal and literal endpoint kinds — neither erased arm + // sees it — yet no single `{ to: { kind: K } }` shape describes it. + const UnionAudio = defineNode("UnionAudio", { + schema: z.object({ title: z.string() }), + }); + const UnionVideo = defineNode("UnionVideo", { + schema: z.object({ title: z.string() }), + }); + const UnionEpisode = defineNode("UnionEpisode", { + schema: z.object({ title: z.string(), durationSeconds: z.number() }), + }); + const UnionUnrelated = defineNode("UnionUnrelated", { + schema: z.object({ name: z.string() }), + }); + type MediaRoot = typeof UnionAudio | typeof UnionVideo; + function declareEpisodeOf(root: MediaRoot) { + return subClassOf(UnionEpisode, root); + } + const unionNodes = { + UnionAudio: { type: UnionAudio }, + UnionVideo: { type: UnionVideo }, + UnionEpisode: { type: UnionEpisode }, + UnionUnrelated: { type: UnionUnrelated }, + } as const; + const audioRootGraph = defineGraph({ + id: "r2_union_endpoint_audio", + nodes: unionNodes, + edges: {}, + ontology: [declareEpisodeOf(UnionAudio)], + }); + const videoRootGraph = defineGraph({ + id: "r2_union_endpoint_video", + nodes: unionNodes, + edges: {}, + ontology: [declareEpisodeOf(UnionVideo)], + }); + // The soundness argument, stated as an assertion: the two graphs have + // IDENTICAL ontology types and DIFFERENT runtime subclass closures, so a + // type that answered "unaffected" for either root would be wrong for one + // of them. + expectTypeOf<(typeof audioRootGraph)["ontology"]>().toEqualTypeOf< + (typeof videoRootGraph)["ontology"] + >(); + const audioRegistry = buildKindRegistry(audioRootGraph); + const videoRegistry = buildKindRegistry(videoRootGraph); + expect(audioRegistry.expandSubClasses("UnionAudio")).toContain( + "UnionEpisode", + ); + expect(audioRegistry.expandSubClasses("UnionVideo")).not.toContain( + "UnionEpisode", + ); + expect(videoRegistry.expandSubClasses("UnionVideo")).toContain( + "UnionEpisode", + ); + const builder = () => + createQueryBuilder( + audioRootGraph.id, + audioRegistry, + ); + const audioQuery = builder() + .from("UnionAudio", "a") + .select((ctx) => ctx.a); + expect(audioQuery).toBeDefined(); + type AudioRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const videoQuery = builder() + .from("UnionVideo", "v") + .select((ctx) => ctx.v); + expect(videoQuery).toBeDefined(); + type VideoRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + // Precision: distributing over the union widens the kinds the union can + // hold and nothing else — a kind no endpoint names stays exact instead of + // the whole graph widening the way an erased arm would. + const unrelatedQuery = builder() + .from("UnionUnrelated", "u") + .select((ctx) => ctx.u); + expect(unrelatedQuery).toBeDefined(); + type UnrelatedRow = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf<"UnionUnrelated">(); + }); + + it("widens every kind an equivalentTo endpoint union can hold, on either side", () => { + const EitherLeftA = defineNode("EitherLeftA", { + schema: z.object({ title: z.string() }), + }); + const EitherLeftB = defineNode("EitherLeftB", { + schema: z.object({ title: z.string() }), + }); + const EitherRightA = defineNode("EitherRightA", { + schema: z.object({ title: z.string() }), + }); + const EitherRightB = defineNode("EitherRightB", { + schema: z.object({ title: z.string() }), + }); + const EitherUnrelated = defineNode("EitherUnrelated", { + schema: z.object({ name: z.string() }), + }); + type LeftMember = typeof EitherLeftA | typeof EitherLeftB; + type RightMember = typeof EitherRightA | typeof EitherRightB; + // `equivalentTo` is matched on BOTH endpoints, so one relation whose two + // sides are each a union must widen all four kinds it can hold. + function declareEquivalence(left: LeftMember, right: RightMember) { + return equivalentTo(left, right); + } + const eitherGraph = defineGraph({ + id: "r2_union_endpoint_equivalence", + nodes: { + EitherLeftA: { type: EitherLeftA }, + EitherLeftB: { type: EitherLeftB }, + EitherRightA: { type: EitherRightA }, + EitherRightB: { type: EitherRightB }, + EitherUnrelated: { type: EitherUnrelated }, + }, + edges: {}, + ontology: [declareEquivalence(EitherLeftA, EitherRightB)], + }); + const eitherRegistry = buildKindRegistry(eitherGraph); + expect(eitherRegistry.expandSubClasses("EitherLeftA")).toContain( + "EitherRightB", + ); + const builder = () => + createQueryBuilder(eitherGraph.id, eitherRegistry); + const leftAQuery = builder() + .from("EitherLeftA", "x") + .select((ctx) => ctx.x); + expect(leftAQuery).toBeDefined(); + type LeftARow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const leftBQuery = builder() + .from("EitherLeftB", "x") + .select((ctx) => ctx.x); + expect(leftBQuery).toBeDefined(); + type LeftBRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const rightAQuery = builder() + .from("EitherRightA", "y") + .select((ctx) => ctx.y); + expect(rightAQuery).toBeDefined(); + type RightARow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const rightBQuery = builder() + .from("EitherRightB", "y") + .select((ctx) => ctx.y); + expect(rightBQuery).toBeDefined(); + type RightBRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const unrelatedQuery = builder() + .from("EitherUnrelated", "u") + .select((ctx) => ctx.u); + expect(unrelatedQuery).toBeDefined(); + type UnrelatedRow = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf<"EitherUnrelated">(); + }); + + it("widens every kind a single endpoint's kind-literal union can hold", () => { + // The second shape an endpoint union takes: not a union of endpoint + // TYPES but a union inside ONE endpoint's `kind`, from a helper annotated + // `NodeType<"LiteralAudio" | "LiteralVideo">`. Same unsoundness, same + // remedy — the decision reduces the endpoint to the kind literals it can + // hold rather than asking for assignability to a single-kind shape. + const LiteralAudio = defineNode("LiteralAudio", { + schema: z.object({ title: z.string() }), + }); + const LiteralVideo = defineNode("LiteralVideo", { + schema: z.object({ title: z.string() }), + }); + const LiteralEpisode = defineNode("LiteralEpisode", { + schema: z.object({ title: z.string(), durationSeconds: z.number() }), + }); + const LiteralUnrelated = defineNode("LiteralUnrelated", { + schema: z.object({ name: z.string() }), + }); + function declareEpisodeUnderEither( + root: NodeType<"LiteralAudio" | "LiteralVideo">, + ) { + return subClassOf(LiteralEpisode, root); + } + const literalUnionGraph = defineGraph({ + id: "r2_union_endpoint_kind_literal", + nodes: { + LiteralAudio: { type: LiteralAudio }, + LiteralVideo: { type: LiteralVideo }, + LiteralEpisode: { type: LiteralEpisode }, + LiteralUnrelated: { type: LiteralUnrelated }, + }, + edges: {}, + ontology: [declareEpisodeUnderEither(LiteralAudio)], + }); + const literalUnionRegistry = buildKindRegistry(literalUnionGraph); + expect(literalUnionRegistry.expandSubClasses("LiteralAudio")).toContain( + "LiteralEpisode", + ); + const builder = () => + createQueryBuilder( + literalUnionGraph.id, + literalUnionRegistry, + ); + const audioQuery = builder() + .from("LiteralAudio", "a") + .select((ctx) => ctx.a); + expect(audioQuery).toBeDefined(); + type AudioRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const videoQuery = builder() + .from("LiteralVideo", "v") + .select((ctx) => ctx.v); + expect(videoQuery).toBeDefined(); + type VideoRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + const unrelatedQuery = builder() + .from("LiteralUnrelated", "u") + .select((ctx) => ctx.u); + expect(unrelatedQuery).toBeDefined(); + type UnrelatedRow = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf<"LiteralUnrelated">(); + }); +}); + +describe("typed edge collections under subsumption", () => { + const Staff = defineNode("EndpointStaff", { + schema: z.object({ name: z.string() }), + }); + const Engineer = defineNode("EndpointEngineer", { + schema: z.object({ name: z.string(), level: z.number() }), + }); + const Lead = defineNode("EndpointLead", { + schema: z.object({ + name: z.string(), + level: z.number(), + reports: z.number(), + }), + }); + const Contractor = defineNode("EndpointContractor", { + schema: z.object({ name: z.string() }), + }); + const Vendor = defineNode("EndpointVendor", { + schema: z.object({ vendorCode: z.string() }), + }); + const Supplier = defineNode("EndpointSupplier", { + schema: z.object({ vendorCode: z.string() }), + }); + const Firm = defineNode("EndpointFirm", { + schema: z.object({ name: z.string() }), + }); + const employs = defineEdge("endpointEmploys", { schema: z.object({}) }); + + const nodes = { + EndpointStaff: { type: Staff }, + EndpointEngineer: { type: Engineer }, + EndpointLead: { type: Lead }, + EndpointContractor: { type: Contractor }, + EndpointVendor: { type: Vendor }, + EndpointSupplier: { type: Supplier }, + EndpointFirm: { type: Firm }, + } as const; + const edges = { + endpointEmploys: { type: employs, from: [Firm], to: [Staff] }, + } as const; + + const affectedGraph = defineGraph({ + id: "endpoint_typing_affected", + nodes, + edges, + ontology: [subClassOf(Engineer, Staff), subClassOf(Lead, Engineer)], + }); + const plainGraph = defineGraph({ + id: "endpoint_typing_plain", + nodes, + edges, + ontology: [], + }); + type AffectedStore = Store; + type PlainStore = Store; + type AffectedEdge = Awaited< + ReturnType + >[number]; + type PlainEdge = Awaited< + ReturnType + >[number]; + + it("widens an affected endpoint's kind and id on every edge read", () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf<"EndpointFirm">(); + type ViewEdge = Awaited< + ReturnType< + ReturnType["edges"]["endpointEmploys"]["findTo"] + > + >[number]; + expectTypeOf().toEqualTypeOf(); + type CreatedEdge = Awaited< + ReturnType + >; + expectTypeOf().toEqualTypeOf(); + type BulkEdge = Awaited< + ReturnType + >[number]["edges"][number]; + expectTypeOf().toEqualTypeOf(); + }); + + it("types a bulk read's echoed reference as the input it accepted", () => { + type FromBucket = Awaited< + ReturnType + >[number]; + type ToBucket = Awaited< + ReturnType + >[number]; + type PlainBucket = Awaited< + ReturnType + >[number]; + expectTypeOf().toEqualTypeOf< + GraphNodeReferenceInput + >(); + expectTypeOf().toEqualTypeOf< + GraphNodeReferenceInput + >(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf< + keyof typeof nodes + >(); + }); + + it("keeps declared endpoint literals when the graph declares no subsumption", () => { + expectTypeOf().toEqualTypeOf<"EndpointStaff">(); + expectTypeOf().toEqualTypeOf>(); + }); + + it("refuses an affected endpoint's id on the declared kind's collection", () => { + // Type-checked only — never called. + function assertWidenedIdRefused(): void { + const affected = undefined as unknown as AffectedStore; + const plain = undefined as unknown as PlainStore; + const affectedEdge = undefined as unknown as AffectedEdge; + const plainEdge = undefined as unknown as PlainEdge; + // @ts-expect-error - the target may be an EndpointEngineer row + void affected.nodes.EndpointStaff.getById(affectedEdge.toId); + void plain.nodes.EndpointStaff.getById(plainEdge.toId); + } + void assertWidenedIdRefused; + expectTypeOf().not.toEqualTypeOf< + NodeId + >(); + }); + + it("accepts a subclass node, a transitive descendant and a polymorphic row as an endpoint", () => { + const registry = buildKindRegistry(affectedGraph); + const rows = createQueryBuilder( + affectedGraph.id, + registry, + ) + .from("EndpointStaff", "s") + .select((ctx) => ctx.s); + type StaffRow = Awaited>[number]; + expectTypeOf().toEqualTypeOf(); + + // Type-checked only — never called. + function assertEndpointsAccepted(): void { + const store = undefined as unknown as AffectedStore; + const firm = undefined as unknown as Node; + const engineer = undefined as unknown as Node; + const lead = undefined as unknown as Node; + const row = undefined as unknown as StaffRow; + void store.edges.endpointEmploys.create(firm, engineer); + void store.edges.endpointEmploys.create(firm, lead); + void store.edges.endpointEmploys.create(firm, row); + void store.edges.endpointEmploys.create(firm, { + kind: "EndpointLead", + id: "lead-id", + }); + void store.edges.endpointEmploys.findTo(engineer); + void store.edges.endpointEmploys.bulkFindTo([engineer, row]); + void store.edges.endpointEmploys.bulkCreate([{ from: firm, to: row }]); + void store.edges.endpointEmploys.getOrCreateByEndpoints(firm, lead, {}); + void store + .asOf("2026-01-01T00:00:00.000Z") + .edges.endpointEmploys.findTo(row); + } + void assertEndpointsAccepted; + expect(rows).toBeDefined(); + }); + + it("still refuses an endpoint the ontology does not admit", () => { + // Type-checked only — never called. + function assertEndpointsRefused(): void { + const affected = undefined as unknown as Store; + const plain = undefined as unknown as Store; + const firm = undefined as unknown as Node; + const engineer = undefined as unknown as Node; + const vendor = undefined as unknown as Node; + // @ts-expect-error - EndpointVendor is no subclass of EndpointStaff + void affected.edges.endpointEmploys.create(firm, vendor); + // @ts-expect-error - EndpointContractor is no subclass of EndpointStaff + void affected.edges.endpointEmploys.create(firm, { + kind: "EndpointContractor", + id: "contractor-id", + }); + // @ts-expect-error - without the subClassOf, EndpointEngineer is not admitted + void plain.edges.endpointEmploys.create(firm, engineer); + } + void assertEndpointsRefused; + expect( + buildKindRegistry(plainGraph).expandSubClasses("EndpointStaff"), + ).toEqual(["EndpointStaff"]); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf<"EndpointStaff" | "EndpointEngineer" | "EndpointLead">(); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf<"EndpointEngineer" | "EndpointLead">(); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf<"EndpointStaff">(); + }); + + it("passes a widened endpoint and a polymorphic row back into every endpoint input", () => { + const registry = buildKindRegistry(affectedGraph); + const rows = createQueryBuilder( + affectedGraph.id, + registry, + ) + .from("EndpointStaff", "s") + .select((ctx) => ctx.s); + type StaffRow = Awaited>[number]; + + // Type-checked only — never called. + async function chainHops( + store: AffectedStore, + edge: AffectedEdge, + row: StaffRow, + ): Promise { + const target = { kind: edge.toKind, id: edge.toId }; + const edgeKinds = ["endpointEmploys"] as const; + void store.edges.endpointEmploys.findTo(target); + void store.bulkFindEdgesTo({ + targets: [{ kind: edge.toKind, ids: [edge.toId] }], + edgeKinds, + }); + void store.bulkFindEdgesFrom({ + sources: [ + { kind: edge.toKind, ids: [edge.toId] }, + { kind: row.kind, ids: [row.id] }, + { kind: edge.fromKind, ids: [edge.fromId] }, + ], + edgeKinds, + }); + void store.neighbors(target, { edges: edgeKinds, direction: "in" }); + void store.neighbors(row, { edges: edgeKinds, direction: "in" }); + void store.countNeighbors(target, { edges: edgeKinds }); + void store.batchOnce((read) => [ + read.neighbors(target, { edges: edgeKinds }), + read.countNeighbors(row, { edges: edgeKinds }), + ]); + await store.transaction(async (tx) => { + await tx.neighbors(target, { edges: edgeKinds }); + await tx.countNeighbors(row, { edges: edgeKinds }); + }); + if (edge.toKind === "EndpointEngineer") { + const engineer = { kind: edge.toKind, id: edge.toId }; + void store.edges.endpointEmploys.findTo(engineer); + void store.neighbors(engineer, { edges: edgeKinds }); + void store.bulkFindEdgesTo({ + targets: [{ kind: edge.toKind, ids: [edge.toId] }], + edgeKinds, + }); + } + } + void chainHops; + expect(rows).toBeDefined(); + }); + + it("refuses a reference whose id brand no admitted kind carries", () => { + // Type-checked only — never called. + function assertReferencesRefused(): void { + const affected = undefined as unknown as AffectedStore; + const plain = undefined as unknown as PlainStore; + const plainEdge = undefined as unknown as PlainEdge; + const vendor = undefined as unknown as Node; + const kind: string = affectedGraph.id; + const edgeKinds = ["endpointEmploys"] as const; + // @ts-expect-error - a bare string id carries no node brand + void affected.neighbors({ kind, id: "raw-id" }, { edges: edgeKinds }); + // @ts-expect-error - EndpointVendor's id is not an EndpointStaff id + void affected.neighbors({ kind, id: vendor.id }, { edges: edgeKinds }); + void affected.bulkFindEdgesTo({ + // @ts-expect-error - EndpointVendor's id is not an EndpointStaff id + targets: [{ kind, ids: [vendor.id] }], + edgeKinds, + }); + // @ts-expect-error - EndpointVendor's id is not an EndpointStaff id + void affected.edges.endpointEmploys.findTo({ kind, id: vendor.id }); + // @ts-expect-error - without subsumption, the kind stays a literal + void plain.neighbors({ kind, id: plainEdge.toId }, { edges: edgeKinds }); + void plain.neighbors( + { kind: plainEdge.toKind, id: plainEdge.toId }, + { edges: edgeKinds }, + ); + } + void assertReferencesRefused; + expectTypeOf< + SubsumedNodeReference + >().toBeNever(); + expectTypeOf< + SubsumedNodeReference + >().toBeNever(); + }); + + it("admits an unrelated kind whose properties include the declared kind's, structurally", () => { + // Type-checked only — never called. The id brand is structural, so an + // EndpointContractor (same properties as EndpointStaff, no ontology + // relation) compiles; the runtime endpoint check refuses it with + // EndpointError. + type ContractorNode = Node; + function assertStructuralAdmission(): void { + const store = undefined as unknown as AffectedStore; + const firm = undefined as unknown as Node; + const contractor = undefined as unknown as ContractorNode; + void store.edges.endpointEmploys.create(firm, contractor); + } + void assertStructuralAdmission; + expect( + buildKindRegistry(affectedGraph).isAssignableTo( + "EndpointContractor", + "EndpointStaff", + ), + ).toBe(false); + }); + + it("admits every kind folded together by equivalence, through a shared IRI too", () => { + const iri = "https://example.com/Worker"; + const equivalenceGraph = defineGraph({ + id: "endpoint_typing_equivalence", + nodes, + edges, + ontology: [ + equivalentTo(Staff, Contractor), + subClassOf(Engineer, Contractor), + equivalentTo(Vendor, iri), + equivalentTo(Supplier, iri), + ], + }); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf< + "EndpointStaff" | "EndpointContractor" | "EndpointEngineer" + >(); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf<"EndpointVendor" | "EndpointSupplier">(); + // The runtime closure the type mirrors. + const registry = buildKindRegistry(equivalenceGraph); + expect(registry.expandSubClasses("EndpointStaff").toSorted()).toEqual([ + "EndpointContractor", + "EndpointEngineer", + "EndpointStaff", + ]); + expect(registry.expandSubClasses("EndpointVendor").toSorted()).toEqual([ + "EndpointSupplier", + "EndpointVendor", + ]); + }); + + it("admits every kind when the ontology tuple lost its literals", () => { + const erasedOntology: readonly OntologyRelation[] = [ + subClassOf(Engineer, Staff), + ]; + const erasedGraph = defineGraph({ + id: "endpoint_typing_erased", + nodes, + edges, + ontology: erasedOntology, + }); + expectTypeOf< + AssignableNodeKinds + >().toEqualTypeOf(); + expect( + buildKindRegistry(erasedGraph).isAssignableTo( + "EndpointEngineer", + "EndpointStaff", + ), + ).toBe(true); + }); +}); + +describe("getNodeByRef result typing", () => { + const Staff = defineNode("RefStaff", { + schema: z.object({ name: z.string() }), + }); + const Engineer = defineNode("RefEngineer", { + schema: z.object({ name: z.string(), level: z.number() }), + }); + const Firm = defineNode("RefFirm", { + schema: z.object({ title: z.string() }), + }); + const employs = defineEdge("refEmploys", { schema: z.object({}) }); + const graph = defineGraph({ + id: "node_by_ref_typing", + nodes: { + RefStaff: { type: Staff }, + RefEngineer: { type: Engineer }, + RefFirm: { type: Firm }, + }, + edges: { refEmploys: { type: employs, from: [Firm], to: [Staff] } }, + ontology: [subClassOf(Engineer, Staff)], + }); + type GraphStore = Store; + type Employment = Awaited< + ReturnType + >[number]; + + it("resolves a typed reference to its kind's node", () => { + // Type-checked only — never called. + async function resolveFirm( + store: Store, + edge: Employment, + ): Promise { + const firm = await store.getNodeByRef({ + kind: edge.fromKind, + id: edge.fromId, + }); + expectTypeOf(firm).toEqualTypeOf | undefined>(); + } + void resolveFirm; + expectTypeOf().toEqualTypeOf<"RefFirm">(); + expect( + buildKindRegistry(graph).isAssignableTo("RefEngineer", "RefStaff"), + ).toBe(true); + }); + + it("resolves a widened endpoint to a kind-discriminated union", () => { + // Type-checked only — never called. + async function narrowResolvedEndpoint( + store: GraphStore, + edge: Employment, + ): Promise { + if (edge.toKind === "RefEngineer") { + const engineer = await store.getNodeByRef({ + kind: edge.toKind, + id: edge.toId, + }); + expectTypeOf(engineer).toEqualTypeOf< + Node | undefined + >(); + } + const target = await store.getNodeByRef({ + kind: edge.toKind, + id: edge.toId, + }); + if (target?.kind === "RefEngineer") { + expectTypeOf(target).toEqualTypeOf | Node>(); + } + } + void narrowResolvedEndpoint; + type Resolved = Awaited>; + expectTypeOf().toEqualTypeOf< + | Node + | Node + | Node + | Node + | undefined + >(); + }); + + it("offers the same resolver on transactions and views", () => { + type TransactionResolve = TransactionContext["getNodeByRef"]; + expectTypeOf().toEqualTypeOf< + NodeReferenceLookup + >(); + type ViewResolved = Awaited< + ReturnType["getNodeByRef"]> + >; + expectTypeOf().toEqualTypeOf< + Awaited> + >(); + }); +}); diff --git a/packages/typegraph/tests/unique-constraint-option-values.test.ts b/packages/typegraph/tests/unique-constraint-option-values.test.ts new file mode 100644 index 000000000..07fa32648 --- /dev/null +++ b/packages/typegraph/tests/unique-constraint-option-values.test.ts @@ -0,0 +1,155 @@ +/** + * `defineGraph` writes the defaults into a unique constraint that leaves + * `scope` or `collation` out, and refuses one that states either outside its + * domain. + * + * The store reads any scope but `"kind"` as subclass-wide, so an omitted + * scope has always been enforced across the subclass component. Writing that + * default into the registration is what makes the serializer and the + * stored-schema parser agree with it. + */ +import { describe, expect, it } from "vitest"; +import { z } from "zod"; + +import { + ConfigurationError, + createStoreWithSchema, + defineGraph, + defineNode, + subClassOf, + UniquenessError, +} from "../src"; +import { + COLLATION_VALUES, + UNIQUENESS_SCOPE_VALUES, +} from "../src/core/unique-constraint-options"; +import { createTestBackend } from "./test-utils"; + +const Account = defineNode("Account", { + schema: z.object({ email: z.string() }), +}); +const Admin = defineNode("Admin", { + schema: z.object({ email: z.string() }), +}); + +type StatedDeclaration = Readonly<{ + name: "account_email"; + fields: readonly ["email"]; + scope: "kind"; + collation: "binary"; +}>; + +/** + * An untyped caller's declaration, on `Account` and on its subclass `Admin`: + * the types require both members. + */ +function graphDeclaring(members: Readonly>) { + const unique = [ + { name: "account_email", fields: ["email"], ...members }, + ] as unknown as readonly StatedDeclaration[]; + return defineGraph({ + id: "unique_constraint_option_values", + nodes: { + Account: { type: Account, unique }, + Admin: { type: Admin, unique }, + }, + edges: {}, + ontology: [subClassOf(Admin, Account)], + }); +} + +function refusalOf(members: Readonly>): unknown { + try { + graphDeclaring(members); + return "accepted"; + } catch (error) { + return error; + } +} + +const INVALID_STATEMENTS = [ + { option: "scope", value: "kindWithSubclasses" }, + { option: "scope", value: "Kind" }, + { option: "scope", value: true }, + // eslint-disable-next-line unicorn/no-null -- an untyped caller can state null + { option: "scope", value: null }, + { option: "collation", value: "CaseInsensitive" }, + { option: "collation", value: "caseSensitive" }, + { option: "collation", value: 1 }, +] as const; + +describe("unique constraint option values", () => { + it.each(INVALID_STATEMENTS)( + "refuses $option: $value at defineGraph", + ({ option, value }) => { + const refusal = refusalOf({ [option]: value }); + + expect(refusal).toBeInstanceOf(ConfigurationError); + expect((refusal as ConfigurationError).details).toEqual({ + code: "UNIQUE_CONSTRAINT_OPTION_INVALID", + kind: "Account", + constraintName: "account_email", + option, + value, + }); + }, + ); + // MUTATION CHECK: dropping the `assertUniqueConstraintOptions` loop from + // `withUniqueConstraintDefaultsWritten` accepts every statement here, and + // each case fails. + + it.each([ + { members: {}, scope: "kindWithSubClasses", collation: "binary" }, + { + members: { collation: "caseInsensitive" }, + scope: "kindWithSubClasses", + collation: "caseInsensitive", + }, + { members: { scope: "kind" }, scope: "kind", collation: "binary" }, + ] as const)( + "writes the defaults into a declaration stating $members", + ({ members, scope, collation }) => { + const graph = graphDeclaring(members); + + expect(graph.nodes.Account.unique).toEqual([ + { name: "account_email", fields: ["email"], scope, collation }, + ]); + }, + ); + + it("enforces an omitted scope across the subclass component", async () => { + const [store] = await createStoreWithSchema( + graphDeclaring({}), + createTestBackend(), + ); + + await store.nodes.Account.create({ email: "a@example.com" }); + await expect( + store.nodes.Admin.create({ email: "a@example.com" }), + ).rejects.toBeInstanceOf(UniquenessError); + }); + // MUTATION CHECK: reading an omitted node scope as `"kind"` in + // `NODE_UNIQUE_CONSTRAINT_DEFAULTS` accepts the `Admin` row, and this case + // fails. + + it("keeps a graph whose declarations state both members as given", () => { + for (const scope of UNIQUENESS_SCOPE_VALUES) { + for (const collation of COLLATION_VALUES) { + const registration = { + type: Account, + unique: [ + { name: "account_email", fields: ["email"], scope, collation }, + ], + } as const; + + const graph = defineGraph({ + id: "unique_constraint_option_values_stated", + nodes: { Account: registration }, + edges: {}, + }); + + expect(graph.nodes.Account).toBe(registration); + } + } + }); +}); diff --git a/packages/typegraph/tests/vector-compiler.test.ts b/packages/typegraph/tests/vector-compiler.test.ts index 6117079af..1346108a8 100644 --- a/packages/typegraph/tests/vector-compiler.test.ts +++ b/packages/typegraph/tests/vector-compiler.test.ts @@ -51,7 +51,7 @@ function buildVectorAst( start: { alias: "d", kinds: ["Document"], - includeSubClasses: false, + expansion: "exact" as const, }, traversals: [], predicates: [ @@ -99,7 +99,7 @@ describe("vector compilation semantics", () => { expect(sql).not.toContain("typegraph_node_embeddings"); // The single declaring kind needs no UNION, but the kind is carried as a // `'Document' AS node_kind` literal so the output contract is identical - // to the multi-kind (includeSubClasses) UNION-ALL case. + // to the multi-kind (expansion: "subclasses") UNION-ALL case. expect(sql).toContain("AS node_kind"); // The empty-body fallback (WHERE 1 = 0) only fires when no kind in the // alias declares the field — the slot map prevents that here. @@ -145,7 +145,7 @@ describe("vector compilation semantics", () => { }); }); - it("unions per-(kind, field) tables across kinds the alias resolves to (includeSubClasses)", () => { + it('unions per-(kind, field) tables across kinds the alias resolves to (expansion: "subclasses")', () => { const childSlots: VectorSlotMap = new Map([ [ vectorSlotKey("Document", "embedding"), @@ -161,7 +161,7 @@ describe("vector compilation semantics", () => { start: { alias: "d", kinds: ["Document", "Memo"], - includeSubClasses: true, + expansion: "subclasses" as const, }, }; @@ -174,7 +174,7 @@ describe("vector compilation semantics", () => { ); // One per-field table per declaring kind, fused by UNION ALL — only the - // includeSubClasses path yields more than one branch. + // the subclasses-expansion path yields more than one branch. expect(sql).toContain("tg_vec_graph_1_document_embedding"); expect(sql).toContain("tg_vec_graph_1_memo_embedding"); expect(sql).toMatch(/UNION ALL/); diff --git a/packages/typegraph/tests/write-pipeline-ratchet.test.ts b/packages/typegraph/tests/write-pipeline-ratchet.test.ts index 14e304fe3..65733fb1f 100644 --- a/packages/typegraph/tests/write-pipeline-ratchet.test.ts +++ b/packages/typegraph/tests/write-pipeline-ratchet.test.ts @@ -129,10 +129,12 @@ const UNFENCED_TARGET_ESCAPE_INVENTORY = [ * `findActiveEdgesBySourceV1` (137→138) is an optional graph-entity read. * The optional durable edge identity owner read (138→139) joins the read * surface: it is a bounded lookup and never mutates rows. - * `lockNodes` (139→140) is a read too: it row-locks what it returns for the + * `setActiveVersionWithPreflight` (139→140) joined the schema-version + * lifecycle beside `setActiveVersion`, owned by the schema manager. + * `lockNodes` (140→141) is a read too: it row-locks what it returns for the * edge-endpoint / delete pairing and writes no row. */ -const MEMBER_COUNT = 140; +const MEMBER_COUNT = 141; type Violation = Readonly<{ file: string; member: string; line: number }>; diff --git a/packages/typegraph/tests/write-plan-statement-order.test.ts b/packages/typegraph/tests/write-plan-statement-order.test.ts index 509c363b8..b733db236 100644 --- a/packages/typegraph/tests/write-plan-statement-order.test.ts +++ b/packages/typegraph/tests/write-plan-statement-order.test.ts @@ -251,12 +251,23 @@ function indexOfSchemaFence(): number { * The first statement that WRITES a row — the work every lock above exists to * protect. Lock statements are `SELECT`s, so they cannot be mistaken for one. */ +/** + * A lock acquisition is not row work, even though the per-graph write fence + * advances its fence row in the statement that takes the advisory lock. + */ +function isAdvisoryLockAcquisition(statement: LoggedStatement): boolean { + return statement.query.includes("pg_advisory_xact_lock"); +} + function indexOfFirstRowStatement(): number { return statements.findIndex( (statement) => - /^\s*(insert|update|delete)\b/iu.test(statement.query) || - (/^\s*with\b/iu.test(statement.query) && - /\b(?:insert\s+into|update|delete\s+from)\b/iu.test(statement.query)), + !isAdvisoryLockAcquisition(statement) && + (/^\s*(insert|update|delete)\b/iu.test(statement.query) || + (/^\s*with\b/iu.test(statement.query) && + /\b(?:insert\s+into|update|delete\s+from)\b/iu.test( + statement.query, + ))), ); } @@ -598,12 +609,14 @@ describe("generated node ids avoid identity lock round trips", () => { ); const callerSuppliedIdentityLocks = countAdvisoryLocks(IDENTITY_NAMESPACE); - // The caller-supplied path acquires the write-frame lock and the identity - // fold helper's lock. Generated ids cannot collide across kinds, so it - // needs neither. This is the measured RTT reduction for the common - // generated-id create path, not a statement-count estimate. + // The caller-supplied path acquires the write-frame lock, under which its + // identity fold is decided before the insert; no live node of another + // kind shares the id, so the fold writes nothing and takes no lock of its + // own. Generated ids cannot collide across kinds, so they need neither. + // This is the measured RTT reduction for the common generated-id create + // path, not a statement-count estimate. expect(generatedIdentityLocks).toBe(0); - expect(callerSuppliedIdentityLocks).toBe(2); + expect(callerSuppliedIdentityLocks).toBe(1); }); }); diff --git a/packages/typegraph/tsconfig.declarations.json b/packages/typegraph/tsconfig.declarations.json new file mode 100644 index 000000000..42112a381 --- /dev/null +++ b/packages/typegraph/tsconfig.declarations.json @@ -0,0 +1,13 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "declarationMap": false, + "emitDeclarationOnly": true, + "sourceMap": false, + "outDir": "./.declaration-emit" + }, + "include": ["src"] +} diff --git a/packages/typegraph/tsup.config.ts b/packages/typegraph/tsup.config.ts index 1deda0d59..d2d235b36 100644 --- a/packages/typegraph/tsup.config.ts +++ b/packages/typegraph/tsup.config.ts @@ -1,30 +1,96 @@ import { defineConfig } from "tsup"; +/** + * The published entrypoints, keyed by their `dist` path. The sole owner of the + * dist↔src map: `scripts/drizzle-reachability-scan.ts` cross-checks + * `package.json#exports` against it, and the declaration rollup below derives + * its own entry list from it, so neither can name a different set of roots. + */ +const ENTRY = { + index: "src/index.ts", + "backend/index": "src/backend/index.ts", + "core/index": "src/core/index.ts", + "interchange/index": "src/interchange/index.ts", + "profiler/index": "src/profiler/index.ts", + "schema/index": "src/schema/index.ts", + "indexes/index": "src/indexes/index.ts", + "graph-extension/index": "src/graph-extension/index.ts", + "graph-merge/index": "src/graph-merge/index.ts", + "provenance/index": "src/provenance/index.ts", + "backend/sqlite/index": "src/backend/sqlite/index.ts", + "backend/sqlite/local": "src/backend/sqlite/local.ts", + "backend/sqlite/local-store": "src/backend/sqlite/local-store.ts", + "backend/sqlite/libsql": "src/backend/sqlite/libsql.ts", + "backend/drizzle/indexes": "src/backend/drizzle/indexes.ts", + "backend/postgres/index": "src/backend/postgres/index.ts", + "backend/postgres/working-copy": "src/backend/postgres/working-copy.ts", + "backend/postgres/pglite": "src/backend/postgres/pglite.ts", + "backend/postgres/pglite-store": "src/backend/postgres/pglite-store.ts", + "backend/drizzle/engine/index": "src/backend/drizzle/engine/index.ts", +} as const; + +/** + * Where `scripts/build.ts` has `tsc` write the declaration tree that the + * rollup below consumes, relative to the package root (which is the build's + * working directory). Gitignored, and rebuilt by every build. + * + * It must NOT live under `node_modules`, even in a cache directory: + * rollup-plugin-dts resolves imports through `ts.resolveModuleName` and treats + * everything it reports as an external library import — which is every path + * under `node_modules` — as external, so a tree emitted there would roll up to + * bare re-exports of files that do not ship. + * + * This module deliberately imports nothing but tsup: it is loaded by + * `scripts/drizzle-reachability-scan.ts` through a plain `require`, where a + * relative TypeScript import would not resolve. + */ +export const DECLARATION_EMIT_DIR = ".declaration-emit"; + +/** + * The emitted declaration file for an entry's source path. `tsc` mirrors the + * source tree under the emit directory, so `src/backend/index.ts` becomes + * `.declaration-emit/backend/index.d.ts`. + * + * The sole owner of that mapping: every declaration entry below is derived + * through it, so the declaration rollup's roots cannot drift from the + * JavaScript build's. + */ +export function declarationEntryFor(sourcePath: string): string { + const SOURCE_PREFIX = "src/"; + if (!sourcePath.startsWith(SOURCE_PREFIX)) { + throw new Error( + `Declaration entry ${JSON.stringify(sourcePath)} does not start with "${SOURCE_PREFIX}"; the emitted declaration tree mirrors "src" only.`, + ); + } + if (!sourcePath.endsWith(".ts")) { + throw new Error( + `Declaration entry ${JSON.stringify(sourcePath)} does not end with ".ts".`, + ); + } + const withoutSourcePrefix = sourcePath.slice(SOURCE_PREFIX.length); + const withoutExtension = withoutSourcePrefix.slice(0, -".ts".length); + return `${DECLARATION_EMIT_DIR}/${withoutExtension}.d.ts`; +} + +/** + * The declaration rollup's roots: the `.d.ts` `scripts/build.ts` emitted for + * each entry, not the entry's source. Rolling pre-emitted declarations is what + * keeps the build's peak memory off the ~10 GiB plateau a source-rooted rollup + * needed — see `scripts/declaration-emit.ts`. `pnpm build` is therefore the + * supported way to build: a bare `tsup` run has no emit to roll and fails on + * the unresolved declaration entries rather than shipping stale ones. + */ +export const DECLARATION_ENTRY = Object.fromEntries( + Object.entries(ENTRY).map(([name, source]) => [ + name, + declarationEntryFor(source), + ]), +); + export default defineConfig({ - entry: { - index: "src/index.ts", - "backend/index": "src/backend/index.ts", - "core/index": "src/core/index.ts", - "interchange/index": "src/interchange/index.ts", - "profiler/index": "src/profiler/index.ts", - "schema/index": "src/schema/index.ts", - "indexes/index": "src/indexes/index.ts", - "graph-extension/index": "src/graph-extension/index.ts", - "graph-merge/index": "src/graph-merge/index.ts", - "provenance/index": "src/provenance/index.ts", - "backend/sqlite/index": "src/backend/sqlite/index.ts", - "backend/sqlite/local": "src/backend/sqlite/local.ts", - "backend/sqlite/local-store": "src/backend/sqlite/local-store.ts", - "backend/sqlite/libsql": "src/backend/sqlite/libsql.ts", - "backend/drizzle/indexes": "src/backend/drizzle/indexes.ts", - "backend/postgres/index": "src/backend/postgres/index.ts", - "backend/postgres/working-copy": "src/backend/postgres/working-copy.ts", - "backend/postgres/pglite": "src/backend/postgres/pglite.ts", - "backend/postgres/pglite-store": "src/backend/postgres/pglite-store.ts", - "backend/drizzle/engine/index": "src/backend/drizzle/engine/index.ts", - }, + entry: ENTRY, format: ["esm", "cjs"], - dts: true, + dts: { entry: DECLARATION_ENTRY }, splitting: true, sourcemap: true, clean: true, diff --git a/packages/typegraph/vitest.pglite-project.ts b/packages/typegraph/vitest.pglite-project.ts index 13054f92e..2262c7fe6 100644 --- a/packages/typegraph/vitest.pglite-project.ts +++ b/packages/typegraph/vitest.pglite-project.ts @@ -5,6 +5,7 @@ * pure-unit budget. */ export const PGLITE_TEST_FILES = [ + "tests/atomic-edge-target-cardinality.test.ts", "tests/atomic-generated-edge-batch-pglite.test.ts", "tests/atomic-generated-node-batch-pglite.test.ts", "tests/atomic-mutation-program-pglite-routing.test.ts", @@ -16,10 +17,14 @@ export const PGLITE_TEST_FILES = [ "tests/caller-serialized-queue.test.ts", "tests/capability-bundle-dialect-honesty.test.ts", "tests/capability-declaration-validation.test.ts", + "tests/composition-cascade-pglite.test.ts", + "tests/composition-edge-delete-pglite.test.ts", + "tests/composition-existence-fresh-snapshot-pglite.test.ts", "tests/constraint-claim-inventory.test.ts", "tests/constraint-write-fence.test.ts", "tests/contribution-rebuild-lock.test.ts", "tests/durable-edge-match-identity.test.ts", + "tests/edge-acyclicity.test.ts", "tests/edge-convergence-command.test.ts", "tests/edge-match-identity-ddl.test.ts", "tests/engine-catalog-probes.test.ts", @@ -28,9 +33,11 @@ export const PGLITE_TEST_FILES = [ "tests/engine-profile-derivation.test.ts", "tests/engine-profile-parity.test.ts", "tests/engine-profile-refusals.test.ts", + "tests/fence-row-scope.test.ts", "tests/fused-edge-endpoints.test.ts", "tests/graph-templates.test.ts", "tests/guarded-edge-cardinality-claim.test.ts", + "tests/heterogeneous-node-upsert-batch-composition-pglite.test.ts", "tests/import-edge-match-identity-atomicity.test.ts", "tests/lineage-fork-clear-epoch.test.ts", "tests/lineage-interleaved-writer-pglite.test.ts", @@ -40,6 +47,7 @@ export const PGLITE_TEST_FILES = [ "tests/lock-fence-refusal.test.ts", "tests/materialize-trigram-extension.test.ts", "tests/node-claim-write-fusion.test.ts", + "tests/node-delete-policy-root-atomic-bypass-pglite.test.ts", "tests/node-fulltext-write-fusion.test.ts", "tests/node-vector-write-fusion.test.ts", "tests/optimistic-tier.test.ts", @@ -50,6 +58,7 @@ export const PGLITE_TEST_FILES = [ "tests/schema-fused-insert.test.ts", "tests/schema-graph-write-fence.test.ts", "tests/schema-kind-emptiness.test.ts", + "tests/snapshot-fence-retry.test.ts", "tests/subgraph-membership-postgres.test.ts", "tests/transaction-retry.test.ts", "tests/write-plan-fence-threading.test.ts",